Skip to content

Instantly share code, notes, and snippets.

@simonw
Created December 18, 2025 05:53
Show Gist options
  • Select an option

  • Save simonw/1dc30fd8e4c0ddd48e2a58ae2f6e4c2c to your computer and use it in GitHub Desktop.

Select an option

Save simonw/1dc30fd8e4c0ddd48e2a58ae2f6e4c2c to your computer and use it in GitHub Desktop.
This file has been truncated, but you can view the full file.
{"type":"summary","summary":"Add Copy Prompts Button to Timeline HTML","leafUuid":"014285fe-4b68-406e-9b31-3207f2ea18eb"}
{"type":"file-history-snapshot","messageId":"11cf2c88-128e-4ca4-9a83-46c5672aa70e","snapshot":{"messageId":"11cf2c88-128e-4ca4-9a83-46c5672aa70e","trackedFileBackups":{},"timestamp":"2025-12-18T05:51:48.598Z"},"isSnapshotUpdate":false}
{"parentUuid":null,"isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","type":"user","message":{"role":"user","content":"The claude-code-timeline.html file needs an update - the codex-timeline.html file implements a \"Show prompts\" button which opens a modal dialog with the prompts and a Copy button - the Claude one currently just has a Copy prompts button. Upgrade the Claude one to instead use a Show prompts button that works like the Codex one."},"uuid":"11cf2c88-128e-4ca4-9a83-46c5672aa70e","timestamp":"2025-12-18T05:51:48.592Z","thinkingMetadata":{"level":"high","disabled":false,"triggers":[]},"todos":[]}
{"parentUuid":"11cf2c88-128e-4ca4-9a83-46c5672aa70e","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011ppGJyngLH4SNdteWTG76u","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to upgrade the claude-code-timeline.html file to have a \"Show prompts\" button that opens a modal dialog, similar to what's in the codex-timeline.html file. \n\nLet me start by exploring these files to understand the current implementation and what needs to be changed.\n\nFirst, I should:\n1. Read both files to understand the current implementation\n2. See how the codex-timeline implements the \"Show prompts\" button and modal\n3. Apply similar implementation to claude-code-timeline.html\n\nLet me start by finding these files.","signature":"Er8FCkYIChgCKkCVMmbpwPey9iieEoZY1+gmjTHWayTk8BzYOLAaTTyJeVyLmFiwGJO5DbtdisFg+SMy3b6xLD2Is9c1Cp+QD1EGEgztf7gDOVfHfDXUkc0aDEGfxHzj7pzIZBVoziIwIDbLqL4qqsoGXUlFyjxK9+DdgisRsfehqFuVXI9ODupJ1qoWvYdraoKUKqy2IIioKqYEAaxb5BFvf9/eOfGap+GLDupMolXzp9wMrBm+mh2LyQlwTvVig5zjxAto+2bgLhFWhCLUSaUszYBC7qwUlrb//FMqyKBFnIJSKBJKmNTWpU2cZYsILjqL2bEjycWPPSDXVrUGXg5B4RQv4CZuMWVvb6ZWwj6WTRTb+YIxZ0HR/zUGA++FUlyJfhxOuLvbieU83feZ1pdTCgNMby1JQYfUTkjXp3tknsefsdm5m6HeB/5YeOFaQBm6u7IKLpf9HloyDLaCzOVWp8vCu2LeN5K51CmgezIkLFda8WSFIKKI1YOU5GymI7/kbaoq4ROdvZ7J7sOxO0J/MTmxHI6RbY2WuGOnGJzFcmchCxCz3TOKxus8I1AOLq/Uo8f0McvdNSoeIXls75+O/nMHOcQjHIaOSGQB4zLoKxGL8cCASGUjVb3D/H9mVq46GEY+KlxNOOMvuvPBsEY9MWONAamDbiaS0M2dtbJyOgCV3VpS361GjcM/OVJVcnCshmW0WjfrncyDthclwz/m5TxocmDoWCrXvMe/oioZP8SFZEzwCev2UZ/j8W5mXh87HkolcVluYozb7IkHEAm+ttBi18tQ7zc1ZLOPJQ1oa9h62yvDUfEa2ukdOWtAiHBkiH6lXkJeHpJksDW/d9ooG1cF2XoR1qjuB8pzMv3pjOMjVVKW03c+O7sMpiPTSqOesZQg3XBB2HT8SbITUvd1jaov/oHMVeNPw3jo5XQ6vhgB"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":5712,"cache_read_input_tokens":12997,"cache_creation":{"ephemeral_5m_input_tokens":5712,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard"}},"requestId":"req_011CWDdZMLPeSG28Z1ddnYzC","type":"assistant","uuid":"902297eb-2bde-4867-81b7-e69ae5a6a250","timestamp":"2025-12-18T05:51:50.972Z"}
{"parentUuid":"902297eb-2bde-4867-81b7-e69ae5a6a250","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011ppGJyngLH4SNdteWTG76u","type":"message","role":"assistant","content":[{"type":"text","text":"I'll help you upgrade the claude-code-timeline.html file to use a \"Show prompts\" button with a modal dialog. Let me start by examining both files to understand the current implementations."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":5712,"cache_read_input_tokens":12997,"cache_creation":{"ephemeral_5m_input_tokens":5712,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard"}},"requestId":"req_011CWDdZMLPeSG28Z1ddnYzC","type":"assistant","uuid":"2ef3cf1b-0064-4abf-a6d0-88c66b823957","timestamp":"2025-12-18T05:51:51.230Z"}
{"parentUuid":"2ef3cf1b-0064-4abf-a6d0-88c66b823957","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011ppGJyngLH4SNdteWTG76u","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01XBdvLRk4hTb8EBTgtQyQPk","name":"Glob","input":{"pattern":"**/claude-code-timeline.html"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":5712,"cache_read_input_tokens":12997,"cache_creation":{"ephemeral_5m_input_tokens":5712,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard"}},"requestId":"req_011CWDdZMLPeSG28Z1ddnYzC","type":"assistant","uuid":"9a59dbef-835f-452c-a772-7723b8599a9e","timestamp":"2025-12-18T05:51:51.446Z"}
{"parentUuid":"9a59dbef-835f-452c-a772-7723b8599a9e","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011ppGJyngLH4SNdteWTG76u","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01AixQyRyHHSMhJ2dPVK7dqj","name":"Glob","input":{"pattern":"**/codex-timeline.html"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":5712,"cache_read_input_tokens":12997,"cache_creation":{"ephemeral_5m_input_tokens":5712,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard"}},"requestId":"req_011CWDdZMLPeSG28Z1ddnYzC","type":"assistant","uuid":"b6cd1075-0b8b-44d2-8fde-9733519cf520","timestamp":"2025-12-18T05:51:51.551Z"}
{"parentUuid":"b6cd1075-0b8b-44d2-8fde-9733519cf520","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","slug":"glittery-skipping-rivest","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01XBdvLRk4hTb8EBTgtQyQPk","type":"tool_result","content":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html"}]},"uuid":"18c6adf8-5d1c-4fb3-b138-6738ab67e53a","timestamp":"2025-12-18T05:51:51.647Z","toolUseResult":{"filenames":["/Users/simon/Dropbox/dev/tools/claude-code-timeline.html"],"durationMs":40,"numFiles":1,"truncated":false}}
{"parentUuid":"18c6adf8-5d1c-4fb3-b138-6738ab67e53a","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","slug":"glittery-skipping-rivest","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01AixQyRyHHSMhJ2dPVK7dqj","type":"tool_result","content":"/Users/simon/Dropbox/dev/tools/codex-timeline.html"}]},"uuid":"a1e6ef88-3f59-4329-9495-595acb17c0c7","timestamp":"2025-12-18T05:51:51.650Z","toolUseResult":{"filenames":["/Users/simon/Dropbox/dev/tools/codex-timeline.html"],"durationMs":43,"numFiles":1,"truncated":false}}
{"parentUuid":"a1e6ef88-3f59-4329-9495-595acb17c0c7","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","slug":"glittery-skipping-rivest","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01RuVTiMzJfYHgJTq3HGDc1C","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Good, I found both files. Now let me read them to see how they differ.","signature":"Eu4BCkYIChgCKkAanWc+IcgWq4w3caykRNwHSZzuCGhrOQC5FnfDbdgoPk93dFQavBWDy1StLg2yM1I2m6cilrNG1qv+b60IaY6MEgx6m5kmtynEzG141qMaDEH4Do87r8RSgfsMISIwDhZbNGjC/Z5Pva7kDFVCI3Ho+i4WnPhLEi3B/6xa7UCsItR9fVdIdPDnUW+XblYUKlYNQcLxfYDtP7DTe3oyPfwZ6seCRk4GaGsqZw6J/g9kY8GRTYuE362tWVK9ItHOlaPKOuAyxv1wrwJFPaJJsrxYl8x8UTEMpsKFhak+JT27l99NBMjdxhgB"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":14,"cache_creation_input_tokens":376,"cache_read_input_tokens":18709,"cache_creation":{"ephemeral_5m_input_tokens":376,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWDdZa37wrFU1TdshXPTB","type":"assistant","uuid":"5deb3f76-4fc8-469c-a685-2068e0bcb330","timestamp":"2025-12-18T05:51:53.305Z"}
{"parentUuid":"5deb3f76-4fc8-469c-a685-2068e0bcb330","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","slug":"glittery-skipping-rivest","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01RuVTiMzJfYHgJTq3HGDc1C","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Kn8X1rXfJwuTk5ehRjDDAR","name":"Read","input":{"file_path":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":14,"cache_creation_input_tokens":376,"cache_read_input_tokens":18709,"cache_creation":{"ephemeral_5m_input_tokens":376,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWDdZa37wrFU1TdshXPTB","type":"assistant","uuid":"cf9a4a32-a044-4e0f-8962-392825d277f1","timestamp":"2025-12-18T05:51:53.363Z"}
{"parentUuid":"cf9a4a32-a044-4e0f-8962-392825d277f1","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","slug":"glittery-skipping-rivest","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01RuVTiMzJfYHgJTq3HGDc1C","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_019PPh2LPbQgLZweW6CQGYN1","name":"Read","input":{"file_path":"/Users/simon/Dropbox/dev/tools/codex-timeline.html"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":14,"cache_creation_input_tokens":376,"cache_read_input_tokens":18709,"cache_creation":{"ephemeral_5m_input_tokens":376,"ephemeral_1h_input_tokens":0},"output_tokens":152,"service_tier":"standard"}},"requestId":"req_011CWDdZa37wrFU1TdshXPTB","type":"assistant","uuid":"fa479eba-4f9e-4297-80db-a2cd0893bcd1","timestamp":"2025-12-18T05:51:53.380Z"}
{"parentUuid":"fa479eba-4f9e-4297-80db-a2cd0893bcd1","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","slug":"glittery-skipping-rivest","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Kn8X1rXfJwuTk5ehRjDDAR","type":"tool_result","content":" 1→<!DOCTYPE html>\n 2→<html lang=\"en\">\n 3→<head>\n 4→ <meta charset=\"UTF-8\" />\n 5→ <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n 6→ <title>Claude Code Timeline Viewer</title>\n 7→ <script src=\"https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js\" integrity=\"sha384-osZDKVu4ipZP703HmPOhWdyBajcFyjX2Psjk//TG1Rc0AdwEtuToaylrmcK3LdAl\" crossorigin=\"anonymous\"></script>\n 8→ <script src=\"https://cdnjs.cloudflare.com/ajax/libs/markdown-it/4.4.0/markdown-it.min.js\" integrity=\"sha384-cxGp+QDeMxACP73aA0Lj0Sc/h4z1X73+umcvCyI0Vbyh3OYxgrPI+0mxG9slsMTN\" crossorigin=\"anonymous\"></script>\n 9→ <style>\n 10→ :root {\n 11→ color-scheme: light dark;\n 12→ --bg: #0b0c0f;\n 13→ --panel: #12141a;\n 14→ --panel-2: #0f1116;\n 15→ --text: #e8eaf0;\n 16→ --muted: #a6adbf;\n 17→ --line: rgba(255, 255, 255, 0.10);\n 18→ --line-2: rgba(255, 255, 255, 0.16);\n 19→ --accent: #6aa9ff;\n 20→ --accent-2: #8a5cff;\n 21→ --good: #46d39a;\n 22→ --warn: #ffcc66;\n 23→ --bad: #ff6b6b;\n 24→ --shadow: 0 18px 50px rgba(0, 0, 0, 0.35);\n 25→ --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\",\n 26→ \"Courier New\", monospace;\n 27→ --sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial,\n 28→ \"Apple Color Emoji\", \"Segoe UI Emoji\";\n 29→ }\n 30→\n 31→ @media (prefers-color-scheme: light) {\n 32→ :root {\n 33→ --bg: #f7f8fc;\n 34→ --panel: #ffffff;\n 35→ --panel-2: #ffffff;\n 36→ --text: #10121a;\n 37→ --muted: #5b647a;\n 38→ --line: rgba(16, 18, 26, 0.12);\n 39→ --line-2: rgba(16, 18, 26, 0.18);\n 40→ --shadow: 0 18px 50px rgba(16, 18, 26, 0.10);\n 41→ }\n 42→ }\n 43→\n 44→ * { box-sizing: border-box; }\n 45→ html, body { height: 100%; }\n 46→ body {\n 47→ margin: 0;\n 48→ font-family: var(--sans);\n 49→ background: radial-gradient(1200px 600px at 10% -10%, rgba(106, 169, 255, 0.18), transparent 45%),\n 50→ radial-gradient(1200px 600px at 90% -20%, rgba(138, 92, 255, 0.16), transparent 50%),\n 51→ var(--bg);\n 52→ color: var(--text);\n 53→ }\n 54→\n 55→ .wrap {\n 56→ max-width: none;\n 57→ margin: 0;\n 58→ padding: 22px 16px 42px;\n 59→ }\n 60→\n 61→ header {\n 62→ display: grid;\n 63→ gap: 10px;\n 64→ margin-bottom: 14px;\n 65→ }\n 66→\n 67→ h1 {\n 68→ margin: 0;\n 69→ font-size: clamp(22px, 3vw, 32px);\n 70→ letter-spacing: 0.2px;\n 71→ }\n 72→\n 73→ .sub {\n 74→ margin: 6px 0 0;\n 75→ color: var(--muted);\n 76→ line-height: 1.35;\n 77→ max-width: 70ch;\n 78→ }\n 79→\n 80→ .controls {\n 81→ display: flex;\n 82→ flex-wrap: wrap;\n 83→ gap: 10px;\n 84→ align-items: center;\n 85→ justify-content: flex-start;\n 86→ }\n 87→\n 88→ .controls .field {\n 89→ display: grid;\n 90→ gap: 4px;\n 91→ min-width: 180px;\n 92→ }\n 93→\n 94→ .label {\n 95→ font-size: 12px;\n 96→ color: var(--muted);\n 97→ }\n 98→\n 99→ input[type=\"search\"], input[type=\"url\"], select, textarea {\n 100→ font: inherit;\n 101→ color: inherit;\n 102→ border: 1px solid var(--line);\n 103→ background: color-mix(in srgb, var(--panel) 92%, transparent);\n 104→ border-radius: 10px;\n 105→ padding: 10px 11px;\n 106→ outline: none;\n 107→ }\n 108→\n 109→ input[type=\"search\"]:focus, input[type=\"url\"]:focus, select:focus, textarea:focus {\n 110→ border-color: color-mix(in srgb, var(--accent) 60%, var(--line));\n 111→ box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);\n 112→ }\n 113→\n 114→ .segmented {\n 115→ display: inline-flex;\n 116→ border: 1px solid var(--line);\n 117→ border-radius: 999px;\n 118→ overflow: hidden;\n 119→ background: color-mix(in srgb, var(--panel) 92%, transparent);\n 120→ }\n 121→\n 122→ .segmented button {\n 123→ border: none;\n 124→ padding: 9px 12px;\n 125→ font: inherit;\n 126→ background: transparent;\n 127→ color: var(--muted);\n 128→ cursor: pointer;\n 129→ min-width: 74px;\n 130→ }\n 131→\n 132→ .segmented button.active {\n 133→ background: color-mix(in srgb, var(--accent) 16%, transparent);\n 134→ color: var(--text);\n 135→ }\n 136→\n 137→ .segmented button:focus-visible {\n 138→ outline: 2px solid color-mix(in srgb, var(--accent) 70%, transparent);\n 139→ outline-offset: -2px;\n 140→ }\n 141→\n 142→ .btn {\n 143→ border: 1px solid var(--line);\n 144→ background: color-mix(in srgb, var(--panel) 92%, transparent);\n 145→ color: var(--text);\n 146→ border-radius: 10px;\n 147→ padding: 10px 12px;\n 148→ font: inherit;\n 149→ cursor: pointer;\n 150→ transition: transform 80ms ease, border-color 120ms ease;\n 151→ }\n 152→\n 153→ .btn:hover { border-color: var(--line-2); }\n 154→ .btn:active { transform: translateY(1px); }\n 155→ .btn.primary {\n 156→ border-color: color-mix(in srgb, var(--accent) 55%, var(--line));\n 157→ background: color-mix(in srgb, var(--accent) 14%, transparent);\n 158→ }\n 159→\n 160→ .btn:disabled {\n 161→ opacity: 0.55;\n 162→ cursor: not-allowed;\n 163→ }\n 164→\n 165→ .panel {\n 166→ background: var(--panel);\n 167→ border: 1px solid var(--line);\n 168→ border-radius: 16px;\n 169→ box-shadow: var(--shadow);\n 170→ }\n 171→\n 172→ .input-panel {\n 173→ padding: 14px;\n 174→ margin-bottom: 14px;\n 175→ }\n 176→\n 177→ .filters-panel {\n 178→ padding: 14px;\n 179→ margin-bottom: 14px;\n 180→ }\n 181→\n 182→ .input-grid {\n 183→ display: grid;\n 184→ grid-template-columns: 1fr;\n 185→ gap: 14px;\n 186→ align-items: stretch;\n 187→ }\n 188→\n 189→ @media (min-width: 920px) {\n 190→ .input-grid {\n 191→ grid-template-columns: 380px 1fr;\n 192→ }\n 193→ }\n 194→\n 195→ .dropzone {\n 196→ border: 1px dashed color-mix(in srgb, var(--accent) 40%, var(--line));\n 197→ border-radius: 14px;\n 198→ background: linear-gradient(\n 199→ 135deg,\n 200→ color-mix(in srgb, var(--accent) 12%, transparent),\n 201→ transparent 45%,\n 202→ color-mix(in srgb, var(--accent-2) 10%, transparent)\n 203→ );\n 204→ padding: 16px;\n 205→ display: grid;\n 206→ gap: 6px;\n 207→ cursor: pointer;\n 208→ min-height: 124px;\n 209→ align-content: center;\n 210→ }\n 211→\n 212→ .dropzone.dragover {\n 213→ border-style: solid;\n 214→ border-color: color-mix(in srgb, var(--accent) 70%, var(--line));\n 215→ box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);\n 216→ }\n 217→\n 218→ .dropzone strong { font-size: 15px; }\n 219→ .muted { color: var(--muted); }\n 220→ .tiny { font-size: 12px; color: var(--muted); }\n 221→\n 222→ .tabs {\n 223→ display: flex;\n 224→ gap: 8px;\n 225→ margin-bottom: 10px;\n 226→ flex-wrap: wrap;\n 227→ align-items: center;\n 228→ }\n 229→\n 230→ .tab {\n 231→ border: 1px solid var(--line);\n 232→ background: color-mix(in srgb, var(--panel-2) 94%, transparent);\n 233→ color: var(--muted);\n 234→ border-radius: 999px;\n 235→ padding: 7px 10px;\n 236→ cursor: pointer;\n 237→ font: inherit;\n 238→ }\n 239→\n 240→ .tab.active {\n 241→ color: var(--text);\n 242→ border-color: color-mix(in srgb, var(--accent) 55%, var(--line));\n 243→ background: color-mix(in srgb, var(--accent) 12%, transparent);\n 244→ }\n 245→\n 246→ textarea {\n 247→ width: 100%;\n 248→ min-height: 190px;\n 249→ resize: vertical;\n 250→ font-family: var(--mono);\n 251→ font-size: 12px;\n 252→ line-height: 1.4;\n 253→ }\n 254→\n 255→ .row {\n 256→ display: flex;\n 257→ gap: 10px;\n 258→ flex-wrap: wrap;\n 259→ align-items: center;\n 260→ margin-top: 10px;\n 261→ }\n 262→\n 263→ .status {\n 264→ margin-top: 12px;\n 265→ padding: 10px 12px;\n 266→ border-radius: 12px;\n 267→ border: 1px solid var(--line);\n 268→ background: color-mix(in srgb, var(--panel-2) 94%, transparent);\n 269→ display: none;\n 270→ white-space: pre-wrap;\n 271→ word-break: break-word;\n 272→ }\n 273→ .status.visible { display: block; }\n 274→ .status.error { border-color: color-mix(in srgb, var(--bad) 60%, var(--line)); }\n 275→ .status.good { border-color: color-mix(in srgb, var(--good) 55%, var(--line)); }\n 276→ .status.warn { border-color: color-mix(in srgb, var(--warn) 55%, var(--line)); }\n 277→\n 278→ main {\n 279→ display: grid;\n 280→ gap: 14px;\n 281→ grid-template-columns: 1fr;\n 282→ min-height: 560px;\n 283→ }\n 284→\n 285→ @media (min-width: 1060px) {\n 286→ main {\n 287→ grid-template-columns: 1fr 520px;\n 288→ align-items: stretch;\n 289→ }\n 290→ }\n 291→\n 292→ .timeline-panel {\n 293→ display: grid;\n 294→ grid-template-rows: auto 1fr;\n 295→ min-height: 560px;\n 296→ }\n 297→\n 298→ .stats {\n 299→ display: flex;\n 300→ gap: 12px;\n 301→ flex-wrap: wrap;\n 302→ align-items: baseline;\n 303→ justify-content: space-between;\n 304→ padding: 12px 14px;\n 305→ border-bottom: 1px solid var(--line);\n 306→ }\n 307→\n 308→ .stats .big {\n 309→ font-size: 14px;\n 310→ font-weight: 600;\n 311→ }\n 312→\n 313→ .timeline {\n 314→ overflow: auto;\n 315→ padding: 8px 0;\n 316→ }\n 317→\n 318→ .timeline:focus-visible {\n 319→ outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent);\n 320→ outline-offset: -2px;\n 321→ border-radius: 14px;\n 322→ }\n 323→\n 324→ .day-sep {\n 325→ position: sticky;\n 326→ top: 0;\n 327→ z-index: 2;\n 328→ padding: 8px 14px;\n 329→ background: color-mix(in srgb, var(--panel) 92%, transparent);\n 330→ border-top: 1px solid var(--line);\n 331→ border-bottom: 1px solid var(--line);\n 332→ font-size: 12px;\n 333→ color: var(--muted);\n 334→ backdrop-filter: blur(6px);\n 335→ }\n 336→\n 337→ .event {\n 338→ display: grid;\n 339→ grid-template-columns: 176px 1fr;\n 340→ gap: 12px;\n 341→ padding: 10px 14px;\n 342→ border-bottom: 1px solid color-mix(in srgb, var(--line) 85%, transparent);\n 343→ cursor: pointer;\n 344→ }\n 345→\n 346→ .event:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); }\n 347→ .event.selected {\n 348→ background: color-mix(in srgb, var(--accent) 12%, transparent);\n 349→ outline: 2px solid color-mix(in srgb, var(--accent) 35%, transparent);\n 350→ outline-offset: -2px;\n 351→ }\n 352→\n 353→ .time {\n 354→ font-family: var(--mono);\n 355→ font-size: 12px;\n 356→ line-height: 1.35;\n 357→ color: var(--muted);\n 358→ display: grid;\n 359→ gap: 3px;\n 360→ }\n 361→\n 362→ .time .ts {\n 363→ color: var(--text);\n 364→ font-weight: 600;\n 365→ }\n 366→\n 367→ .badges {\n 368→ display: flex;\n 369→ gap: 6px;\n 370→ flex-wrap: wrap;\n 371→ align-items: center;\n 372→ margin-bottom: 6px;\n 373→ }\n 374→\n 375→ .badge {\n 376→ font-size: 11px;\n 377→ font-family: var(--mono);\n 378→ padding: 2px 7px;\n 379→ border-radius: 999px;\n 380→ border: 1px solid var(--line);\n 381→ color: var(--muted);\n 382→ background: color-mix(in srgb, var(--panel-2) 90%, transparent);\n 383→ }\n 384→\n 385→ .badge.k-user { border-color: color-mix(in srgb, #00c8ff 55%, var(--line)); color: color-mix(in srgb, #00c8ff 78%, var(--text)); }\n 386→ .badge.k-assistant { border-color: color-mix(in srgb, #ff73c4 55%, var(--line)); color: color-mix(in srgb, #ff73c4 78%, var(--text)); }\n 387→ .badge.k-file-history-snapshot { border-color: color-mix(in srgb, var(--warn) 55%, var(--line)); color: color-mix(in srgb, var(--warn) 75%, var(--text)); }\n 388→\n 389→ .badge.ct-text { border-color: color-mix(in srgb, var(--good) 55%, var(--line)); color: color-mix(in srgb, var(--good) 75%, var(--text)); }\n 390→ .badge.ct-thinking { border-color: color-mix(in srgb, var(--accent-2) 55%, var(--line)); color: color-mix(in srgb, var(--accent-2) 75%, var(--text)); }\n 391→ .badge.ct-tool_use { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); color: color-mix(in srgb, var(--accent) 75%, var(--text)); }\n 392→ .badge.ct-tool_result { border-color: color-mix(in srgb, #8690a8 55%, var(--line)); color: color-mix(in srgb, #8690a8 70%, var(--text)); }\n 393→ .badge.ct-image { border-color: color-mix(in srgb, #ff9f43 55%, var(--line)); color: color-mix(in srgb, #ff9f43 75%, var(--text)); }\n 394→\n 395→ .badge.role-user { border-color: color-mix(in srgb, #00c8ff 55%, var(--line)); color: color-mix(in srgb, #00c8ff 78%, var(--text)); }\n 396→ .badge.role-assistant { border-color: color-mix(in srgb, #ff73c4 55%, var(--line)); color: color-mix(in srgb, #ff73c4 78%, var(--text)); }\n 397→\n 398→ .summary {\n 399→ font-size: 13px;\n 400→ line-height: 1.35;\n 401→ color: var(--text);\n 402→ word-break: break-word;\n 403→ }\n 404→\n 405→ .summary code {\n 406→ font-family: var(--mono);\n 407→ font-size: 12px;\n 408→ background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n 409→ border: 1px solid var(--line);\n 410→ padding: 2px 5px;\n 411→ border-radius: 7px;\n 412→ }\n 413→\n 414→ .detail {\n 415→ display: grid;\n 416→ grid-template-rows: auto auto auto auto 1fr;\n 417→ min-height: 560px;\n 418→ }\n 419→\n 420→ @media (min-width: 1060px) {\n 421→ .detail {\n 422→ position: sticky;\n 423→ top: 16px;\n 424→ align-self: start;\n 425→ max-height: calc(100vh - 32px);\n 426→ }\n 427→ }\n 428→\n 429→ .detail.inline {\n 430→ position: static;\n 431→ top: auto;\n 432→ align-self: auto;\n 433→ max-height: none;\n 434→ min-height: 0;\n 435→ margin: 8px 14px 12px;\n 436→ box-shadow: none;\n 437→ }\n 438→\n 439→ .detail.inline pre {\n 440→ max-height: 52vh;\n 441→ }\n 442→\n 443→ .detail-head {\n 444→ padding: 12px 14px;\n 445→ border-bottom: 1px solid var(--line);\n 446→ display: grid;\n 447→ gap: 8px;\n 448→ }\n 449→\n 450→ .detail-title {\n 451→ font-size: 14px;\n 452→ font-weight: 700;\n 453→ line-height: 1.25;\n 454→ margin: 0;\n 455→ overflow: hidden;\n 456→ text-overflow: ellipsis;\n 457→ white-space: nowrap;\n 458→ }\n 459→\n 460→ .detail-actions {\n 461→ display: flex;\n 462→ gap: 8px;\n 463→ flex-wrap: wrap;\n 464→ align-items: center;\n 465→ justify-content: flex-start;\n 466→ }\n 467→\n 468→ .check {\n 469→ display: inline-flex;\n 470→ align-items: center;\n 471→ gap: 6px;\n 472→ color: var(--muted);\n 473→ font-size: 12px;\n 474→ user-select: none;\n 475→ }\n 476→\n 477→ pre {\n 478→ margin: 0;\n 479→ padding: 12px 14px 16px;\n 480→ overflow: auto;\n 481→ font-family: var(--mono);\n 482→ font-size: 12px;\n 483→ line-height: 1.45;\n 484→ color: color-mix(in srgb, var(--text) 92%, transparent);\n 485→ background: transparent;\n 486→ white-space: pre-wrap;\n 487→ word-break: break-word;\n 488→ }\n 489→\n 490→ .detail-images {\n 491→ display: flex;\n 492→ gap: 10px;\n 493→ padding: 12px 14px;\n 494→ border-bottom: 1px solid var(--line);\n 495→ overflow-x: auto;\n 496→ overscroll-behavior-x: contain;\n 497→ }\n 498→\n 499→ .thumb {\n 500→ border: 1px solid var(--line);\n 501→ background: color-mix(in srgb, var(--panel-2) 92%, transparent);\n 502→ border-radius: 12px;\n 503→ padding: 0;\n 504→ cursor: pointer;\n 505→ width: 78px;\n 506→ height: 58px;\n 507→ overflow: hidden;\n 508→ flex: 0 0 auto;\n 509→ display: grid;\n 510→ place-items: stretch;\n 511→ }\n 512→\n 513→ .thumb:hover { border-color: var(--line-2); }\n 514→\n 515→ .thumb img {\n 516→ width: 100%;\n 517→ height: 100%;\n 518→ display: block;\n 519→ object-fit: cover;\n 520→ }\n 521→\n 522→ .image-modal {\n 523→ position: fixed;\n 524→ inset: 0;\n 525→ z-index: 9999;\n 526→ background: rgba(0, 0, 0, 0.68);\n 527→ display: none;\n 528→ padding: 18px;\n 529→ place-items: center;\n 530→ }\n 531→\n 532→ .image-modal.open { display: grid; }\n 533→\n 534→ .image-modal .dialog {\n 535→ position: relative;\n 536→ border-radius: 16px;\n 537→ border: 1px solid var(--line);\n 538→ background: color-mix(in srgb, var(--panel) 98%, transparent);\n 539→ box-shadow: var(--shadow);\n 540→ padding: 14px;\n 541→ max-width: min(1200px, 96vw);\n 542→ max-height: 92vh;\n 543→ display: grid;\n 544→ place-items: center;\n 545→ }\n 546→\n 547→ .image-modal img {\n 548→ display: block;\n 549→ max-width: min(1140px, 92vw);\n 550→ max-height: 84vh;\n 551→ width: auto;\n 552→ height: auto;\n 553→ object-fit: contain;\n 554→ border-radius: 10px;\n 555→ background: color-mix(in srgb, var(--panel-2) 92%, transparent);\n 556→ }\n 557→\n 558→ .image-modal .close {\n 559→ position: absolute;\n 560→ top: 10px;\n 561→ right: 10px;\n 562→ border: 1px solid var(--line);\n 563→ background: color-mix(in srgb, var(--panel) 92%, transparent);\n 564→ color: var(--text);\n 565→ border-radius: 999px;\n 566→ padding: 8px 10px;\n 567→ font: inherit;\n 568→ cursor: pointer;\n 569→ }\n 570→\n 571→ .image-modal .close:hover { border-color: var(--line-2); }\n 572→\n 573→ .empty {\n 574→ padding: 14px;\n 575→ color: var(--muted);\n 576→ font-size: 13px;\n 577→ line-height: 1.35;\n 578→ }\n 579→\n 580→ /* Pretty view styles */\n 581→ .detail-pretty {\n 582→ padding: 14px;\n 583→ border-bottom: 1px solid var(--line);\n 584→ overflow: auto;\n 585→ max-height: 50vh;\n 586→ }\n 587→\n 588→ .detail.inline .detail-pretty {\n 589→ max-height: 40vh;\n 590→ }\n 591→\n 592→ .pretty-section {\n 593→ margin-bottom: 14px;\n 594→ }\n 595→\n 596→ .pretty-section:last-child {\n 597→ margin-bottom: 0;\n 598→ }\n 599→\n 600→ .pretty-label {\n 601→ font-size: 11px;\n 602→ font-weight: 600;\n 603→ text-transform: uppercase;\n 604→ letter-spacing: 0.5px;\n 605→ color: var(--muted);\n 606→ margin-bottom: 6px;\n 607→ }\n 608→\n 609→ .pretty-content {\n 610→ font-size: 13px;\n 611→ line-height: 1.5;\n 612→ }\n 613→\n 614→ .pretty-content p {\n 615→ margin: 0 0 10px;\n 616→ }\n 617→\n 618→ .pretty-content p:last-child {\n 619→ margin-bottom: 0;\n 620→ }\n 621→\n 622→ .pretty-content h1, .pretty-content h2, .pretty-content h3,\n 623→ .pretty-content h4, .pretty-content h5, .pretty-content h6 {\n 624→ margin: 14px 0 8px;\n 625→ font-weight: 600;\n 626→ line-height: 1.3;\n 627→ }\n 628→\n 629→ .pretty-content h1:first-child, .pretty-content h2:first-child,\n 630→ .pretty-content h3:first-child {\n 631→ margin-top: 0;\n 632→ }\n 633→\n 634→ .pretty-content h1 { font-size: 1.4em; }\n 635→ .pretty-content h2 { font-size: 1.25em; }\n 636→ .pretty-content h3 { font-size: 1.1em; }\n 637→\n 638→ .pretty-content ul, .pretty-content ol {\n 639→ margin: 8px 0;\n 640→ padding-left: 1.5em;\n 641→ list-style-position: outside;\n 642→ }\n 643→\n 644→ .pretty-content li {\n 645→ margin: 4px 0;\n 646→ }\n 647→\n 648→ .thinking-block ul, .thinking-block ol {\n 649→ padding-left: 2em;\n 650→ }\n 651→\n 652→ .pretty-content code {\n 653→ font-family: var(--mono);\n 654→ font-size: 0.9em;\n 655→ background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n 656→ border: 1px solid var(--line);\n 657→ padding: 2px 5px;\n 658→ border-radius: 5px;\n 659→ }\n 660→\n 661→ .pretty-content pre {\n 662→ margin: 10px 0;\n 663→ padding: 12px;\n 664→ background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n 665→ border: 1px solid var(--line);\n 666→ border-radius: 10px;\n 667→ overflow-x: auto;\n 668→ }\n 669→\n 670→ .pretty-content pre code {\n 671→ background: none;\n 672→ border: none;\n 673→ padding: 0;\n 674→ font-size: 12px;\n 675→ }\n 676→\n 677→ .pretty-content blockquote {\n 678→ margin: 10px 0;\n 679→ padding: 8px 12px;\n 680→ border-left: 3px solid var(--accent);\n 681→ background: color-mix(in srgb, var(--accent) 8%, transparent);\n 682→ border-radius: 0 8px 8px 0;\n 683→ }\n 684→\n 685→ .pretty-content a {\n 686→ color: var(--accent);\n 687→ text-decoration: none;\n 688→ }\n 689→\n 690→ .pretty-content a:hover {\n 691→ text-decoration: underline;\n 692→ }\n 693→\n 694→ .pretty-content strong {\n 695→ font-weight: 600;\n 696→ }\n 697→\n 698→ .pretty-content em {\n 699→ font-style: italic;\n 700→ }\n 701→\n 702→ .pretty-content table {\n 703→ border-collapse: collapse;\n 704→ margin: 10px 0;\n 705→ width: 100%;\n 706→ font-size: 13px;\n 707→ }\n 708→\n 709→ .pretty-content th,\n 710→ .pretty-content td {\n 711→ border: 1px solid var(--line);\n 712→ padding: 8px 10px;\n 713→ text-align: left;\n 714→ }\n 715→\n 716→ .pretty-content th {\n 717→ background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n 718→ font-weight: 600;\n 719→ }\n 720→\n 721→ .pretty-content tr:hover {\n 722→ background: color-mix(in srgb, var(--accent) 6%, transparent);\n 723→ }\n 724→\n 725→ .command-block {\n 726→ background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n 727→ border: 1px solid var(--line);\n 728→ border-radius: 10px;\n 729→ overflow: hidden;\n 730→ margin: 8px 0;\n 731→ }\n 732→\n 733→ .command-header {\n 734→ display: flex;\n 735→ align-items: center;\n 736→ gap: 8px;\n 737→ padding: 8px 12px;\n 738→ background: color-mix(in srgb, var(--accent) 12%, transparent);\n 739→ border-bottom: 1px solid var(--line);\n 740→ font-size: 12px;\n 741→ }\n 742→\n 743→ .command-header .tool-name {\n 744→ font-weight: 600;\n 745→ color: var(--accent);\n 746→ }\n 747→\n 748→ .command-header .tool-desc {\n 749→ color: var(--muted);\n 750→ }\n 751→\n 752→ .command-input {\n 753→ padding: 10px 12px;\n 754→ font-family: var(--mono);\n 755→ font-size: 12px;\n 756→ line-height: 1.4;\n 757→ white-space: pre-wrap;\n 758→ word-break: break-word;\n 759→ border-bottom: 1px solid var(--line);\n 760→ }\n 761→\n 762→ .command-input::before {\n 763→ content: '$ ';\n 764→ color: var(--good);\n 765→ font-weight: 600;\n 766→ }\n 767→\n 768→ .command-output {\n 769→ padding: 10px 12px;\n 770→ font-family: var(--mono);\n 771→ font-size: 12px;\n 772→ line-height: 1.4;\n 773→ white-space: pre-wrap;\n 774→ word-break: break-word;\n 775→ max-height: 300px;\n 776→ overflow: auto;\n 777→ color: var(--muted);\n 778→ }\n 779→\n 780→ .file-path {\n 781→ font-family: var(--mono);\n 782→ font-size: 12px;\n 783→ padding: 6px 10px;\n 784→ background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n 785→ border: 1px solid var(--line);\n 786→ border-radius: 8px;\n 787→ display: inline-block;\n 788→ margin: 4px 0;\n 789→ }\n 790→\n 791→ .tool-params {\n 792→ display: grid;\n 793→ gap: 8px;\n 794→ margin-top: 8px;\n 795→ }\n 796→\n 797→ .tool-param {\n 798→ display: grid;\n 799→ grid-template-columns: auto 1fr;\n 800→ gap: 8px;\n 801→ align-items: start;\n 802→ font-size: 12px;\n 803→ }\n 804→\n 805→ .tool-param-name {\n 806→ font-family: var(--mono);\n 807→ font-weight: 600;\n 808→ color: var(--accent);\n 809→ padding: 2px 6px;\n 810→ background: color-mix(in srgb, var(--accent) 12%, transparent);\n 811→ border-radius: 4px;\n 812→ }\n 813→\n 814→ .tool-param-value {\n 815→ font-family: var(--mono);\n 816→ white-space: pre-wrap;\n 817→ word-break: break-word;\n 818→ padding: 2px 0;\n 819→ }\n 820→\n 821→ .thinking-block {\n 822→ background: color-mix(in srgb, var(--accent-2) 10%, transparent);\n 823→ border: 1px solid color-mix(in srgb, var(--accent-2) 30%, var(--line));\n 824→ border-radius: 10px;\n 825→ padding: 12px 16px;\n 826→ font-size: 13px;\n 827→ line-height: 1.5;\n 828→ }\n 829→\n 830→ .result-block {\n 831→ background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n 832→ border: 1px solid var(--line);\n 833→ border-radius: 10px;\n 834→ padding: 12px;\n 835→ font-family: var(--mono);\n 836→ font-size: 12px;\n 837→ line-height: 1.4;\n 838→ white-space: pre-wrap;\n 839→ word-break: break-word;\n 840→ max-height: 400px;\n 841→ overflow: auto;\n 842→ }\n 843→\n 844→ .result-error {\n 845→ border-color: color-mix(in srgb, var(--bad) 50%, var(--line));\n 846→ background: color-mix(in srgb, var(--bad) 8%, transparent);\n 847→ }\n 848→\n 849→ .json-toggle {\n 850→ display: flex;\n 851→ align-items: center;\n 852→ justify-content: space-between;\n 853→ padding: 8px 14px;\n 854→ border-bottom: 1px solid var(--line);\n 855→ background: color-mix(in srgb, var(--panel-2) 50%, transparent);\n 856→ }\n 857→\n 858→ .json-toggle-label {\n 859→ font-size: 12px;\n 860→ color: var(--muted);\n 861→ font-weight: 500;\n 862→ }\n 863→ </style>\n 864→</head>\n 865→ <body>\n 866→ <div class=\"wrap\">\n 867→ <header>\n 868→ <div>\n 869→ <h1>Claude Code Timeline Viewer</h1>\n 870→ <p class=\"sub\">Open a Claude Code session <code>.jsonl</code> file and explore it as an interactive timeline. Times are shown in your local timezone by default. Find sessions in <code>~/.claude/projects/</code>.</p>\n 871→ </div>\n 872→ </header>\n 873→\n 874→ <section class=\"panel input-panel\" aria-label=\"Input\">\n 875→ <div class=\"input-grid\">\n 876→ <div>\n 877→ <div id=\"dropzone\" class=\"dropzone\" tabindex=\"0\">\n 878→ <strong>Drop a <code>.jsonl</code> file here</strong>\n 879→ <div class=\"muted\">or click to choose a file</div>\n 880→ <div class=\"tiny\">Supported: file picker, drag-and-drop, paste, or URL fetch (CORS-enabled).</div>\n 881→ <input id=\"file-input\" type=\"file\" accept=\".jsonl,application/json,text/plain\" hidden />\n 882→ </div>\n 883→ </div>\n 884→ <div>\n 885→ <div class=\"tabs\" role=\"tablist\" aria-label=\"Input tabs\">\n 886→ <button type=\"button\" class=\"tab active\" data-tab=\"paste\" aria-selected=\"true\">Paste</button>\n 887→ <button type=\"button\" class=\"tab\" data-tab=\"url\" aria-selected=\"false\">URL</button>\n 888→ </div>\n 889→\n 890→ <div id=\"panel-paste\" data-panel=\"paste\">\n 891→ <textarea id=\"paste-input\" placeholder=\"Paste JSONL content here...\"></textarea>\n 892→ <div class=\"row\">\n 893→ <button type=\"button\" class=\"btn primary\" id=\"render-paste\">Render pasted content</button>\n 894→ <div class=\"tiny\">Tip: the timeline uses one JSON object per line.</div>\n 895→ </div>\n 896→ </div>\n 897→\n 898→ <div id=\"panel-url\" data-panel=\"url\" hidden>\n 899→ <input id=\"url-input\" type=\"url\" placeholder=\"https://example.com/session.jsonl\" />\n 900→ <div class=\"row\">\n 901→ <button type=\"button\" class=\"btn primary\" id=\"fetch-url\">Fetch and render</button>\n 902→ <div class=\"tiny\">The URL must allow cross-origin requests (CORS).</div>\n 903→ </div>\n 904→ </div>\n 905→ </div>\n 906→ </div>\n 907→\n 908→ <div id=\"status\" class=\"status\" role=\"status\" aria-live=\"polite\"></div>\n 909→ </section>\n 910→\n 911→ <section class=\"panel filters-panel\" aria-label=\"Filters\">\n 912→ <div class=\"controls\" aria-label=\"Timeline controls\">\n 913→ <div class=\"field\" style=\"min-width: 170px\">\n 914→ <div class=\"label\">Timezone</div>\n 915→ <div class=\"segmented\" role=\"group\" aria-label=\"Timezone toggle\">\n 916→ <button type=\"button\" id=\"tz-local\" class=\"active\">Local</button>\n 917→ <button type=\"button\" id=\"tz-utc\">UTC</button>\n 918→ </div>\n 919→ </div>\n 920→ <div class=\"field\" style=\"min-width: 220px\">\n 921→ <div class=\"label\">Search</div>\n 922→ <input id=\"search\" type=\"search\" placeholder=\"message, tool name, cwd...\" autocomplete=\"off\" />\n 923→ </div>\n 924→ <div class=\"field\" style=\"min-width: 190px\">\n 925→ <div class=\"label\">Type</div>\n 926→ <select id=\"type-filter\"></select>\n 927→ </div>\n 928→ <div class=\"field\" style=\"min-width: 210px\">\n 929→ <div class=\"label\">Content Type</div>\n 930→ <select id=\"content-type-filter\"></select>\n 931→ </div>\n 932→ <div class=\"field\" style=\"min-width: 160px\">\n 933→ <div class=\"label\">Role</div>\n 934→ <select id=\"role-filter\"></select>\n 935→ </div>\n 936→ <label class=\"check\" title=\"Hide thinking blocks (can be verbose)\">\n 937→ <input type=\"checkbox\" id=\"hide-thinking\" />\n 938→ Hide thinking\n 939→ </label>\n 940→ <button type=\"button\" class=\"btn\" id=\"clear-filters\">Clear</button>\n 941→ </div>\n 942→ </section>\n 943→\n 944→ <div class=\"controls\" style=\"margin-bottom: 14px;\">\n 945→ <button type=\"button\" class=\"btn\" id=\"copy-prompts\">Copy prompts</button>\n 946→ </div>\n 947→\n 948→ <main>\n 949→ <section class=\"panel timeline-panel\" aria-label=\"Timeline\">\n 950→ <div class=\"stats\">\n 951→ <div class=\"big\"><span id=\"stats-showing\">0</span> <span class=\"muted\">showing</span> <span class=\"muted\">/</span> <span id=\"stats-total\">0</span> <span class=\"muted\">events</span></div>\n 952→ <div class=\"muted\" id=\"stats-range\">-</div>\n 953→ </div>\n 954→ <div id=\"timeline\" class=\"timeline\" role=\"list\" tabindex=\"0\" aria-label=\"Events list\"></div>\n 955→ </section>\n 956→\n 957→ <aside class=\"panel detail\" aria-label=\"Details\">\n 958→ <div class=\"detail-head\">\n 959→ <div class=\"detail-title\" id=\"detail-title\">Select an event</div>\n 960→ <div class=\"detail-actions\">\n 961→ <button type=\"button\" class=\"btn\" id=\"copy-json\" disabled>Copy JSON</button>\n 962→ <button type=\"button\" class=\"btn\" id=\"copy-raw\" disabled>Copy raw line</button>\n 963→ <label class=\"check\" title=\"Truncate very long strings in the JSON preview\">\n 964→ <input type=\"checkbox\" id=\"truncate-strings\" checked />\n 965→ Truncate long strings\n 966→ </label>\n 967→ </div>\n 968→ </div>\n 969→ <div id=\"detail-images\" class=\"detail-images\" hidden aria-label=\"Event images\"></div>\n 970→ <div id=\"detail-pretty\" class=\"detail-pretty\" hidden aria-label=\"Formatted event view\"></div>\n 971→ <div class=\"json-toggle\">\n 972→ <span class=\"json-toggle-label\">Raw JSON</span>\n 973→ </div>\n 974→ <pre id=\"detail-json\" aria-label=\"Selected event JSON\" class=\"empty\">Load a file to get started.</pre>\n 975→ </aside>\n 976→ </main>\n 977→ </div>\n 978→\n 979→ <div id=\"image-modal\" class=\"image-modal\" role=\"dialog\" aria-modal=\"true\" aria-hidden=\"true\">\n 980→ <div class=\"dialog\">\n 981→ <button type=\"button\" class=\"close\" id=\"image-modal-close\" aria-label=\"Close image\">Close</button>\n 982→ <img id=\"image-modal-img\" alt=\"\" />\n 983→ </div>\n 984→ </div>\n 985→\n 986→ <script>\n 987→ const els = {\n 988→ dropzone: document.getElementById('dropzone'),\n 989→ fileInput: document.getElementById('file-input'),\n 990→ pasteInput: document.getElementById('paste-input'),\n 991→ renderPaste: document.getElementById('render-paste'),\n 992→ urlInput: document.getElementById('url-input'),\n 993→ fetchUrl: document.getElementById('fetch-url'),\n 994→ status: document.getElementById('status'),\n 995→ timeline: document.getElementById('timeline'),\n 996→ detailPanel: document.querySelector('aside.detail'),\n 997→ detailTitle: document.getElementById('detail-title'),\n 998→ detailImages: document.getElementById('detail-images'),\n 999→ detailPretty: document.getElementById('detail-pretty'),\n 1000→ detailJson: document.getElementById('detail-json'),\n 1001→ copyJson: document.getElementById('copy-json'),\n 1002→ copyRaw: document.getElementById('copy-raw'),\n 1003→ truncateStrings: document.getElementById('truncate-strings'),\n 1004→ tzLocal: document.getElementById('tz-local'),\n 1005→ tzUtc: document.getElementById('tz-utc'),\n 1006→ search: document.getElementById('search'),\n 1007→ typeFilter: document.getElementById('type-filter'),\n 1008→ contentTypeFilter: document.getElementById('content-type-filter'),\n 1009→ roleFilter: document.getElementById('role-filter'),\n 1010→ hideThinking: document.getElementById('hide-thinking'),\n 1011→ statsShowing: document.getElementById('stats-showing'),\n 1012→ statsTotal: document.getElementById('stats-total'),\n 1013→ statsRange: document.getElementById('stats-range'),\n 1014→ clearFilters: document.getElementById('clear-filters'),\n 1015→ imageModal: document.getElementById('image-modal'),\n 1016→ imageModalImg: document.getElementById('image-modal-img'),\n 1017→ imageModalClose: document.getElementById('image-modal-close'),\n 1018→ copyPrompts: document.getElementById('copy-prompts'),\n 1019→ };\n 1020→\n 1021→ const state = {\n 1022→ sourceLabel: null,\n 1023→ tzMode: 'local',\n 1024→ events: [],\n 1025→ filtered: [],\n 1026→ selectedId: null,\n 1027→ selected: null,\n 1028→ filters: {\n 1029→ query: '',\n 1030→ type: 'all',\n 1031→ contentType: 'all',\n 1032→ role: 'all',\n 1033→ hideThinking: false,\n 1034→ },\n 1035→ };\n 1036→\n 1037→ let lastActiveElementBeforeModal = null;\n 1038→ let lastBodyOverflow = null;\n 1039→ let suppressUrlStateSync = false;\n 1040→ const wideLayoutMq = window.matchMedia('(min-width: 1060px)');\n 1041→ const detailDock = document.createElement('div');\n 1042→ detailDock.id = 'detail-dock';\n 1043→ detailDock.style.display = 'none';\n 1044→ els.detailPanel.parentElement.insertBefore(detailDock, els.detailPanel);\n 1045→\n 1046→ function updateDetailPlacement() {\n 1047→ const inline = !wideLayoutMq.matches;\n 1048→\n 1049→ if (!inline) {\n 1050→ if (els.detailPanel.previousSibling !== detailDock) detailDock.after(els.detailPanel);\n 1051→ els.detailPanel.classList.remove('inline');\n 1052→ return;\n 1053→ }\n 1054→\n 1055→ const selectedNode = els.timeline.querySelector('.event.selected');\n 1056→ if (!selectedNode) {\n 1057→ if (els.detailPanel.previousSibling !== detailDock) detailDock.after(els.detailPanel);\n 1058→ els.detailPanel.classList.remove('inline');\n 1059→ return;\n 1060→ }\n 1061→\n 1062→ if (selectedNode.nextSibling !== els.detailPanel) selectedNode.after(els.detailPanel);\n 1063→ els.detailPanel.classList.add('inline');\n 1064→ }\n 1065→\n 1066→ function setStatus(message, kind = 'good') {\n 1067→ if (!message) {\n 1068→ els.status.className = 'status';\n 1069→ els.status.textContent = '';\n 1070→ return;\n 1071→ }\n 1072→ els.status.className = `status visible ${kind}`;\n 1073→ els.status.textContent = message;\n 1074→ }\n 1075→\n 1076→ function pad2(number) {\n 1077→ return String(number).padStart(2, '0');\n 1078→ }\n 1079→\n 1080→ function pad3(number) {\n 1081→ return String(number).padStart(3, '0');\n 1082→ }\n 1083→\n 1084→ function getOffsetString(date) {\n 1085→ const offsetMinutes = -date.getTimezoneOffset();\n 1086→ const sign = offsetMinutes >= 0 ? '+' : '-';\n 1087→ const abs = Math.abs(offsetMinutes);\n 1088→ const hours = Math.floor(abs / 60);\n 1089→ const minutes = abs % 60;\n 1090→ return `${sign}${pad2(hours)}:${pad2(minutes)}`;\n 1091→ }\n 1092→\n 1093→ function formatTimestamp(date, tzMode) {\n 1094→ if (!(date instanceof Date) || Number.isNaN(date.getTime())) return '-';\n 1095→ if (tzMode === 'utc') {\n 1096→ return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ` +\n 1097→ `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}:${pad2(date.getUTCSeconds())}.` +\n 1098→ `${pad3(date.getUTCMilliseconds())}Z`;\n 1099→ }\n 1100→ return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ` +\n 1101→ `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}.` +\n 1102→ `${pad3(date.getMilliseconds())} ${getOffsetString(date)}`;\n 1103→ }\n 1104→\n 1105→ function dayKey(date, tzMode) {\n 1106→ if (!(date instanceof Date) || Number.isNaN(date.getTime())) return '-';\n 1107→ if (tzMode === 'utc') {\n 1108→ return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())}`;\n 1109→ }\n 1110→ return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;\n 1111→ }\n 1112→\n 1113→ function formatDelta(ms) {\n 1114→ if (typeof ms !== 'number' || !Number.isFinite(ms)) return '';\n 1115→ const abs = Math.abs(ms);\n 1116→ const sign = ms < 0 ? '-' : '+';\n 1117→ if (abs < 1000) return `${sign}${Math.round(abs)}ms`;\n 1118→ if (abs < 60_000) return `${sign}${(abs / 1000).toFixed(abs < 10_000 ? 2 : 1)}s`;\n 1119→ if (abs < 3_600_000) {\n 1120→ const m = Math.floor(abs / 60_000);\n 1121→ const s = Math.floor((abs % 60_000) / 1000);\n 1122→ return `${sign}${m}m ${pad2(s)}s`;\n 1123→ }\n 1124→ if (abs < 86_400_000) {\n 1125→ const h = Math.floor(abs / 3_600_000);\n 1126→ const m = Math.floor((abs % 3_600_000) / 60_000);\n 1127→ return `${sign}${h}h ${m}m`;\n 1128→ }\n 1129→ const d = Math.floor(abs / 86_400_000);\n 1130→ const h = Math.floor((abs % 86_400_000) / 3_600_000);\n 1131→ return `${sign}${d}d ${h}h`;\n 1132→ }\n 1133→\n 1134→ function safeJsonParse(text) {\n 1135→ try {\n 1136→ return { ok: true, value: JSON.parse(text) };\n 1137→ } catch (error) {\n 1138→ return { ok: false, error };\n 1139→ }\n 1140→ }\n 1141→\n 1142→ function extractMessageText(message) {\n 1143→ if (!message || typeof message !== 'object') return null;\n 1144→ const content = message.content;\n 1145→ if (typeof content === 'string') return content;\n 1146→ if (!Array.isArray(content)) return null;\n 1147→ const parts = [];\n 1148→ for (const item of content) {\n 1149→ if (!item || typeof item !== 'object') continue;\n 1150→ if (typeof item.text === 'string') parts.push(item.text);\n 1151→ else if (typeof item.thinking === 'string') parts.push(item.thinking);\n 1152→ }\n 1153→ const joined = parts.join('');\n 1154→ return joined || null;\n 1155→ }\n 1156→\n 1157→ function getContentTypes(message) {\n 1158→ if (!message || typeof message !== 'object') return [];\n 1159→ const content = message.content;\n 1160→ if (typeof content === 'string') return ['text'];\n 1161→ if (!Array.isArray(content)) return [];\n 1162→ const types = new Set();\n 1163→ for (const item of content) {\n 1164→ if (!item || typeof item !== 'object') continue;\n 1165→ if (item.type) types.add(item.type);\n 1166→ }\n 1167→ return Array.from(types);\n 1168→ }\n 1169→\n 1170→ function firstLine(text) {\n 1171→ if (typeof text !== 'string') return null;\n 1172→ const trimmed = text.trim();\n 1173→ if (!trimmed) return null;\n 1174→ const idx = trimmed.indexOf('\\n');\n 1175→ return idx === -1 ? trimmed : trimmed.slice(0, idx).trim();\n 1176→ }\n 1177→\n 1178→ function normalizeSummaryText(text, maxLen = 260) {\n 1179→ if (typeof text !== 'string') return '';\n 1180→ const singleLine = text.replace(/\\s+/g, ' ').trim();\n 1181→ if (singleLine.length <= maxLen) return singleLine;\n 1182→ return singleLine.slice(0, maxLen - 1) + '...';\n 1183→ }\n 1184→\n 1185→ function summarizeEvent(obj) {\n 1186→ const kind = obj?.type || '';\n 1187→ const message = obj?.message;\n 1188→\n 1189→ if (kind === 'file-history-snapshot') {\n 1190→ const msgId = obj?.messageId ? `msg=${obj.messageId.slice(0, 8)}...` : null;\n 1191→ return normalizeSummaryText(msgId || 'file-history-snapshot');\n 1192→ }\n 1193→\n 1194→ if (kind === 'user' || kind === 'assistant') {\n 1195→ const role = message?.role || kind;\n 1196→ const content = message?.content;\n 1197→\n 1198→ if (typeof content === 'string') {\n 1199→ return normalizeSummaryText(`${role}: ${content}`);\n 1200→ }\n 1201→\n 1202→ if (Array.isArray(content) && content.length > 0) {\n 1203→ for (const item of content) {\n 1204→ if (item?.type === 'text' && item.text) {\n 1205→ return normalizeSummaryText(`${role}: ${item.text}`);\n 1206→ }\n 1207→ if (item?.type === 'thinking' && item.thinking) {\n 1208→ return normalizeSummaryText(`${role} (thinking): ${item.thinking}`);\n 1209→ }\n 1210→ if (item?.type === 'tool_use') {\n 1211→ const name = item.name || 'tool';\n 1212→ const input = item.input;\n 1213→ if (name === 'Bash' && input?.command) {\n 1214→ return normalizeSummaryText(`${name}: ${input.command}`);\n 1215→ }\n 1216→ if (name === 'Read' && input?.file_path) {\n 1217→ return normalizeSummaryText(`${name}: ${input.file_path}`);\n 1218→ }\n 1219→ if (name === 'Write' && input?.file_path) {\n 1220→ return normalizeSummaryText(`${name}: ${input.file_path}`);\n 1221→ }\n 1222→ if (name === 'Edit' && input?.file_path) {\n 1223→ return normalizeSummaryText(`${name}: ${input.file_path}`);\n 1224→ }\n 1225→ if (name === 'Glob' && input?.pattern) {\n 1226→ return normalizeSummaryText(`${name}: ${input.pattern}`);\n 1227→ }\n 1228→ if (name === 'Grep' && input?.pattern) {\n 1229→ return normalizeSummaryText(`${name}: ${input.pattern}`);\n 1230→ }\n 1231→ if (name === 'Task' && input?.description) {\n 1232→ return normalizeSummaryText(`${name}: ${input.description}`);\n 1233→ }\n 1234→ if (input && typeof input === 'object') {\n 1235→ const keys = Object.keys(input).slice(0, 3);\n 1236→ return normalizeSummaryText(`${name}(${keys.join(', ')}${Object.keys(input).length > 3 ? ', ...' : ''})`);\n 1237→ }\n 1238→ return normalizeSummaryText(`${name}()`);\n 1239→ }\n 1240→ if (item?.type === 'tool_result') {\n 1241→ const toolUseId = item.tool_use_id ? item.tool_use_id.slice(0, 12) : 'result';\n 1242→ const resultContent = item.content;\n 1243→ if (typeof resultContent === 'string') {\n 1244→ return normalizeSummaryText(`tool_result: ${firstLine(resultContent) || toolUseId}`);\n 1245→ }\n 1246→ if (Array.isArray(resultContent)) {\n 1247→ for (const rc of resultContent) {\n 1248→ if (rc?.type === 'text' && rc.text) {\n 1249→ return normalizeSummaryText(`tool_result: ${firstLine(rc.text) || toolUseId}`);\n 1250→ }\n 1251→ if (rc?.type === 'image') {\n 1252→ return normalizeSummaryText(`tool_result: [image]`);\n 1253→ }\n 1254→ }\n 1255→ }\n 1256→ return normalizeSummaryText(`tool_result: ${toolUseId}`);\n 1257→ }\n 1258→ if (item?.type === 'image') {\n 1259→ return normalizeSummaryText(`${role}: [image]`);\n 1260→ }\n 1261→ }\n 1262→ return normalizeSummaryText(`${role}: [${content.length} content items]`);\n 1263→ }\n 1264→\n 1265→ return kind;\n 1266→ }\n 1267→\n 1268→ return kind || 'event';\n 1269→ }\n 1270→\n 1271→ function buildSearchText(obj, summary) {\n 1272→ const pieces = [];\n 1273→ pieces.push(obj?.type || '');\n 1274→ pieces.push(obj?.uuid || '');\n 1275→ pieces.push(obj?.sessionId || '');\n 1276→ pieces.push(obj?.cwd || '');\n 1277→ pieces.push(obj?.gitBranch || '');\n 1278→ pieces.push(obj?.slug || '');\n 1279→ const message = obj?.message;\n 1280→ if (message && typeof message === 'object') {\n 1281→ pieces.push(message.role || '');\n 1282→ const msg = extractMessageText(message);\n 1283→ if (msg) pieces.push(msg);\n 1284→ const content = message.content;\n 1285→ if (Array.isArray(content)) {\n 1286→ for (const item of content) {\n 1287→ if (item?.type === 'tool_use') {\n 1288→ pieces.push(item.name || '');\n 1289→ if (item.input && typeof item.input === 'object') {\n 1290→ pieces.push(JSON.stringify(item.input).slice(0, 500));\n 1291→ }\n 1292→ }\n 1293→ if (item?.type === 'tool_result' && typeof item.content === 'string') {\n 1294→ pieces.push(item.content.slice(0, 500));\n 1295→ }\n 1296→ }\n 1297→ }\n 1298→ }\n 1299→ if (typeof summary === 'string') pieces.push(summary);\n 1300→ return pieces.join('\\n').toLowerCase();\n 1301→ }\n 1302→\n 1303→ function stringifyJson(value, truncateLongStrings) {\n 1304→ const maxLen = 8000;\n 1305→ return JSON.stringify(value, (key, v) => {\n 1306→ if (!truncateLongStrings) return v;\n 1307→ if (typeof v === 'string' && v.length > maxLen) {\n 1308→ return v.slice(0, maxLen) + `... [truncated ${v.length - maxLen} chars]`;\n 1309→ }\n 1310→ return v;\n 1311→ }, 2);\n 1312→ }\n 1313→\n 1314→ function updateFilterOptions(events) {\n 1315→ const typeSet = new Set();\n 1316→ const contentTypeSet = new Set();\n 1317→ const roleSet = new Set();\n 1318→ for (const e of events) {\n 1319→ if (e.kind) typeSet.add(e.kind);\n 1320→ for (const ct of e.contentTypes) {\n 1321→ contentTypeSet.add(ct);\n 1322→ }\n 1323→ if (!e.contentTypes.length) contentTypeSet.add('(none)');\n 1324→ if (e.role) roleSet.add(e.role);\n 1325→ }\n 1326→\n 1327→ function setOptions(select, items, current) {\n 1328→ select.innerHTML = '';\n 1329→ const optAll = document.createElement('option');\n 1330→ optAll.value = 'all';\n 1331→ optAll.textContent = 'All';\n 1332→ select.appendChild(optAll);\n 1333→ for (const item of items) {\n 1334→ const opt = document.createElement('option');\n 1335→ opt.value = item;\n 1336→ opt.textContent = item;\n 1337→ select.appendChild(opt);\n 1338→ }\n 1339→ select.value = current;\n 1340→ }\n 1341→\n 1342→ setOptions(els.typeFilter, Array.from(typeSet).sort(), state.filters.type);\n 1343→ setOptions(els.contentTypeFilter, Array.from(contentTypeSet).sort(), state.filters.contentType);\n 1344→ setOptions(els.roleFilter, Array.from(roleSet).sort(), state.filters.role);\n 1345→\n 1346→ if (events.length) {\n 1347→ state.filters.type = els.typeFilter.value || 'all';\n 1348→ state.filters.contentType = els.contentTypeFilter.value || 'all';\n 1349→ state.filters.role = els.roleFilter.value || 'all';\n 1350→ }\n 1351→ }\n 1352→\n 1353→ function readHashParams() {\n 1354→ const raw = window.location.hash && window.location.hash.startsWith('#')\n 1355→ ? window.location.hash.slice(1)\n 1356→ : '';\n 1357→ return new URLSearchParams(raw);\n 1358→ }\n 1359→\n 1360→ function writeHashParams(params) {\n 1361→ const base = window.location.href.split('#')[0];\n 1362→ const hash = params.toString();\n 1363→ const next = base + (hash ? `#${hash}` : '');\n 1364→ window.history.replaceState(null, '', next);\n 1365→ }\n 1366→\n 1367→ function syncUrlHashFromControls({ clearSel = false } = {}) {\n 1368→ if (suppressUrlStateSync) return;\n 1369→ const params = readHashParams();\n 1370→ params.set('tz', state.tzMode);\n 1371→ params.set('q', (els.search.value || '').trim());\n 1372→ params.set('type', state.filters.type || 'all');\n 1373→ params.set('ct', state.filters.contentType || 'all');\n 1374→ params.set('role', state.filters.role || 'all');\n 1375→ params.set('hide', state.filters.hideThinking ? '1' : '0');\n 1376→ params.set('truncate', els.truncateStrings.checked ? '1' : '0');\n 1377→ const selectedLine = state.selected?.line;\n 1378→ if (typeof selectedLine === 'number' && Number.isFinite(selectedLine)) {\n 1379→ params.set('sel', String(selectedLine));\n 1380→ } else if (clearSel) {\n 1381→ params.delete('sel');\n 1382→ }\n 1383→ writeHashParams(params);\n 1384→ }\n 1385→\n 1386→ function applySelectionFromHash() {\n 1387→ if (!state.events.length) return;\n 1388→ const params = readHashParams();\n 1389→ const raw = params.get('sel');\n 1390→ if (!raw) return;\n 1391→ const line = Number(raw);\n 1392→ if (!Number.isFinite(line)) return;\n 1393→ const found = state.events.find(e => e.line === line);\n 1394→ if (!found) return;\n 1395→ if (state.selectedId === found.id) return;\n 1396→ suppressUrlStateSync = true;\n 1397→ try {\n 1398→ selectEvent(found.id, { scroll: true });\n 1399→ } finally {\n 1400→ suppressUrlStateSync = false;\n 1401→ }\n 1402→ }\n 1403→\n 1404→ function applyControlsFromHash() {\n 1405→ const params = readHashParams();\n 1406→ suppressUrlStateSync = true;\n 1407→ try {\n 1408→ const q = params.get('q');\n 1409→ if (q !== null) {\n 1410→ els.search.value = q;\n 1411→ state.filters.query = q.trim().toLowerCase();\n 1412→ }\n 1413→\n 1414→ const type = params.get('type');\n 1415→ if (type !== null) state.filters.type = type;\n 1416→ const ct = params.get('ct');\n 1417→ if (ct !== null) state.filters.contentType = ct;\n 1418→ const role = params.get('role');\n 1419→ if (role !== null) state.filters.role = role;\n 1420→\n 1421→ const hide = params.get('hide');\n 1422→ if (hide !== null) {\n 1423→ els.hideThinking.checked = hide === '1' || hide === 'true';\n 1424→ state.filters.hideThinking = els.hideThinking.checked;\n 1425→ }\n 1426→\n 1427→ const truncate = params.get('truncate');\n 1428→ if (truncate !== null) {\n 1429→ els.truncateStrings.checked = truncate === '1' || truncate === 'true';\n 1430→ }\n 1431→\n 1432→ const tz = params.get('tz');\n 1433→ if (tz === 'utc' || tz === 'local') {\n 1434→ state.tzMode = tz;\n 1435→ els.tzLocal.classList.toggle('active', tz === 'local');\n 1436→ els.tzUtc.classList.toggle('active', tz === 'utc');\n 1437→ }\n 1438→ } finally {\n 1439→ suppressUrlStateSync = false;\n 1440→ }\n 1441→ }\n 1442→\n 1443→ function applyFilters() {\n 1444→ const query = state.filters.query;\n 1445→ const type = state.filters.type;\n 1446→ const contentType = state.filters.contentType;\n 1447→ const role = state.filters.role;\n 1448→ const hideThinking = state.filters.hideThinking;\n 1449→\n 1450→ const out = [];\n 1451→ for (const e of state.events) {\n 1452→ if (hideThinking && e.contentTypes.includes('thinking') && e.contentTypes.length === 1) continue;\n 1453→ if (type !== 'all' && e.kind !== type) continue;\n 1454→ if (contentType !== 'all') {\n 1455→ const ct = e.contentTypes.length ? e.contentTypes : ['(none)'];\n 1456→ if (!ct.includes(contentType)) continue;\n 1457→ }\n 1458→ if (role !== 'all' && e.role !== role) continue;\n 1459→ if (query && !e.search.includes(query)) continue;\n 1460→ out.push(e);\n 1461→ }\n 1462→\n 1463→ state.filtered = out;\n 1464→ els.statsShowing.textContent = String(out.length);\n 1465→ els.statsTotal.textContent = String(state.events.length);\n 1466→\n 1467→ const start = out.find(e => e.date instanceof Date && !Number.isNaN(e.date.getTime()))?.date;\n 1468→ const end = [...out].reverse().find(e => e.date instanceof Date && !Number.isNaN(e.date.getTime()))?.date;\n 1469→ if (start && end) {\n 1470→ els.statsRange.textContent = `${formatTimestamp(start, state.tzMode)} -> ${formatTimestamp(end, state.tzMode)}`;\n 1471→ } else {\n 1472→ els.statsRange.textContent = '-';\n 1473→ }\n 1474→\n 1475→ renderTimeline();\n 1476→ }\n 1477→\n 1478→ function clearSelection({ syncHash = true } = {}) {\n 1479→ state.selectedId = null;\n 1480→ state.selected = null;\n 1481→ els.detailTitle.textContent = 'Select an event';\n 1482→ renderDetailImages(null);\n 1483→ renderPrettyView(null);\n 1484→ els.detailJson.textContent = state.events.length ? 'Select an event from the timeline.' : 'Load a file to get started.';\n 1485→ els.detailJson.classList.add('empty');\n 1486→ els.copyJson.disabled = true;\n 1487→ els.copyRaw.disabled = true;\n 1488→ for (const node of els.timeline.querySelectorAll('.event.selected')) {\n 1489→ node.classList.remove('selected');\n 1490→ }\n 1491→ updateDetailPlacement();\n 1492→ if (syncHash) syncUrlHashFromControls({ clearSel: true });\n 1493→ }\n 1494→\n 1495→ function renderTimeline() {\n 1496→ els.timeline.innerHTML = '';\n 1497→\n 1498→ if (!state.filtered.length) {\n 1499→ const empty = document.createElement('div');\n 1500→ empty.className = 'empty';\n 1501→ empty.textContent = state.events.length ? 'No events match your filters.' : 'Load a file to see a timeline.';\n 1502→ els.timeline.appendChild(empty);\n 1503→ clearSelection();\n 1504→ return;\n 1505→ }\n 1506→\n 1507→ const fragment = document.createDocumentFragment();\n 1508→ let lastDay = null;\n 1509→ let prevDate = null;\n 1510→\n 1511→ for (const e of state.filtered) {\n 1512→ const day = dayKey(e.date, state.tzMode);\n 1513→ if (day !== lastDay) {\n 1514→ lastDay = day;\n 1515→ const sep = document.createElement('div');\n 1516→ sep.className = 'day-sep';\n 1517→ sep.textContent = day;\n 1518→ fragment.appendChild(sep);\n 1519→ }\n 1520→\n 1521→ const row = document.createElement('div');\n 1522→ row.className = 'event';\n 1523→ row.setAttribute('role', 'listitem');\n 1524→ row.dataset.eventId = String(e.id);\n 1525→\n 1526→ const time = document.createElement('div');\n 1527→ time.className = 'time';\n 1528→\n 1529→ const ts = document.createElement('div');\n 1530→ ts.className = 'ts';\n 1531→ ts.textContent = formatTimestamp(e.date, state.tzMode);\n 1532→ ts.dataset.kind = 'timestamp';\n 1533→\n 1534→ const meta = document.createElement('div');\n 1535→ meta.textContent = [\n 1536→ `#${e.line}`,\n 1537→ (prevDate && e.date) ? formatDelta(e.date.getTime() - prevDate.getTime()) : null,\n 1538→ ].filter(Boolean).join(' . ');\n 1539→\n 1540→ time.appendChild(ts);\n 1541→ time.appendChild(meta);\n 1542→\n 1543→ const body = document.createElement('div');\n 1544→\n 1545→ const badges = document.createElement('div');\n 1546→ badges.className = 'badges';\n 1547→ const badgeKind = document.createElement('span');\n 1548→ badgeKind.className = `badge k-${e.kind}`;\n 1549→ badgeKind.textContent = e.kind;\n 1550→ badges.appendChild(badgeKind);\n 1551→\n 1552→ for (const ct of e.contentTypes.slice(0, 2)) {\n 1553→ const badgeCt = document.createElement('span');\n 1554→ badgeCt.className = `badge ct-${ct}`;\n 1555→ badgeCt.textContent = ct;\n 1556→ badges.appendChild(badgeCt);\n 1557→ }\n 1558→ if (e.contentTypes.length > 2) {\n 1559→ const badgeMore = document.createElement('span');\n 1560→ badgeMore.className = 'badge';\n 1561→ badgeMore.textContent = `+${e.contentTypes.length - 2}`;\n 1562→ badges.appendChild(badgeMore);\n 1563→ }\n 1564→\n 1565→ if (e.role) {\n 1566→ const badgeRole = document.createElement('span');\n 1567→ badgeRole.className = `badge role-${e.role}`;\n 1568→ badgeRole.textContent = e.role;\n 1569→ badges.appendChild(badgeRole);\n 1570→ }\n 1571→\n 1572→ const summary = document.createElement('div');\n 1573→ summary.className = 'summary';\n 1574→ summary.textContent = e.summary;\n 1575→\n 1576→ body.appendChild(badges);\n 1577→ body.appendChild(summary);\n 1578→\n 1579→ row.appendChild(time);\n 1580→ row.appendChild(body);\n 1581→\n 1582→ row.addEventListener('click', () => selectEvent(e.id, { focusTimeline: true }));\n 1583→ fragment.appendChild(row);\n 1584→\n 1585→ if (e.date instanceof Date && !Number.isNaN(e.date.getTime())) prevDate = e.date;\n 1586→ }\n 1587→\n 1588→ els.timeline.appendChild(fragment);\n 1589→\n 1590→ if (state.selectedId !== null) {\n 1591→ const stillThere = state.filtered.some(e => e.id === state.selectedId);\n 1592→ if (!stillThere) clearSelection();\n 1593→ else selectEvent(state.selectedId, { scroll: false });\n 1594→ } else {\n 1595→ clearSelection({ syncHash: false });\n 1596→ }\n 1597→ }\n 1598→\n 1599→ function selectEvent(id, { scroll = false, focusTimeline = false } = {}) {\n 1600→ const e = state.filtered.find(item => item.id === id) || state.events.find(item => item.id === id);\n 1601→ if (!e) return;\n 1602→ state.selectedId = id;\n 1603→ state.selected = e;\n 1604→\n 1605→ if (focusTimeline) {\n 1606→ els.timeline.focus({ preventScroll: true });\n 1607→ }\n 1608→\n 1609→ for (const node of els.timeline.querySelectorAll('.event.selected')) {\n 1610→ node.classList.remove('selected');\n 1611→ }\n 1612→ const node = els.timeline.querySelector(`.event[data-event-id=\"${CSS.escape(String(id))}\"]`);\n 1613→ if (node) {\n 1614→ node.classList.add('selected');\n 1615→ updateDetailPlacement();\n 1616→ if (scroll) node.scrollIntoView({ block: 'center' });\n 1617→ }\n 1618→\n 1619→ els.detailJson.classList.remove('empty');\n 1620→ const titleParts = [\n 1621→ formatTimestamp(e.date, state.tzMode),\n 1622→ `#${e.line}`,\n 1623→ e.kind,\n 1624→ e.role ? `(${e.role})` : null,\n 1625→ ].filter(Boolean);\n 1626→ els.detailTitle.textContent = titleParts.join(' ');\n 1627→\n 1628→ const shouldTruncate = els.truncateStrings.checked;\n 1629→ renderDetailImages(e.obj);\n 1630→ renderPrettyView(e.obj);\n 1631→ els.detailJson.textContent = stringifyJson(e.obj, shouldTruncate) || '';\n 1632→ els.copyJson.disabled = false;\n 1633→ els.copyRaw.disabled = false;\n 1634→ updateDetailPlacement();\n 1635→ syncUrlHashFromControls();\n 1636→ }\n 1637→\n 1638→ function extractImageUrls(obj) {\n 1639→ const urls = [];\n 1640→ const message = obj?.message;\n 1641→ if (!message || typeof message !== 'object') return urls;\n 1642→ const content = message.content;\n 1643→ if (!Array.isArray(content)) return urls;\n 1644→\n 1645→ for (const item of content) {\n 1646→ if (!item || typeof item !== 'object') continue;\n 1647→\n 1648→ // Direct image in content\n 1649→ if (item.type === 'image' && item.source?.type === 'base64' && item.source?.data) {\n 1650→ const mediaType = item.source.media_type || 'image/png';\n 1651→ urls.push(`data:${mediaType};base64,${item.source.data}`);\n 1652→ }\n 1653→\n 1654→ // Image in tool_result content\n 1655→ if (item.type === 'tool_result' && Array.isArray(item.content)) {\n 1656→ for (const rc of item.content) {\n 1657→ if (rc?.type === 'image' && rc.source?.type === 'base64' && rc.source?.data) {\n 1658→ const mediaType = rc.source.media_type || 'image/png';\n 1659→ urls.push(`data:${mediaType};base64,${rc.source.data}`);\n 1660→ }\n 1661→ }\n 1662→ }\n 1663→ }\n 1664→\n 1665→ return urls;\n 1666→ }\n 1667→\n 1668→ function escapeHtml(text) {\n 1669→ const div = document.createElement('div');\n 1670→ div.textContent = text;\n 1671→ return div.innerHTML;\n 1672→ }\n 1673→\n 1674→ // Initialize markdown-it with GFM-like settings\n 1675→ const md = window.markdownit({\n 1676→ html: false, // Disable raw HTML input\n 1677→ linkify: true, // Auto-convert URLs to links\n 1678→ typographer: true, // Smart quotes, dashes\n 1679→ breaks: true // Convert \\n to <br>\n 1680→ });\n 1681→\n 1682→ function renderMarkdown(text) {\n 1683→ if (typeof text !== 'string') return '';\n 1684→\n 1685→ // Render markdown to HTML\n 1686→ const html = md.render(text);\n 1687→\n 1688→ // Sanitize with DOMPurify to prevent XSS\n 1689→ return DOMPurify.sanitize(html);\n 1690→ }\n 1691→\n 1692→ function renderPrettyView(obj) {\n 1693→ els.detailPretty.innerHTML = '';\n 1694→\n 1695→ if (!obj || typeof obj !== 'object') {\n 1696→ els.detailPretty.hidden = true;\n 1697→ return;\n 1698→ }\n 1699→\n 1700→ const message = obj.message;\n 1701→ if (!message || typeof message !== 'object') {\n 1702→ els.detailPretty.hidden = true;\n 1703→ return;\n 1704→ }\n 1705→\n 1706→ const content = message.content;\n 1707→ const fragment = document.createDocumentFragment();\n 1708→ let hasContent = false;\n 1709→\n 1710→ // Handle string content (simple text message)\n 1711→ if (typeof content === 'string') {\n 1712→ const section = document.createElement('div');\n 1713→ section.className = 'pretty-section';\n 1714→ const label = document.createElement('div');\n 1715→ label.className = 'pretty-label';\n 1716→ label.textContent = message.role || 'Message';\n 1717→ const body = document.createElement('div');\n 1718→ body.className = 'pretty-content';\n 1719→ body.innerHTML = renderMarkdown(content);\n 1720→ section.appendChild(label);\n 1721→ section.appendChild(body);\n 1722→ fragment.appendChild(section);\n 1723→ hasContent = true;\n 1724→ }\n 1725→\n 1726→ // Handle array content\n 1727→ if (Array.isArray(content)) {\n 1728→ for (const item of content) {\n 1729→ if (!item || typeof item !== 'object') continue;\n 1730→\n 1731→ // Text content\n 1732→ if (item.type === 'text' && item.text) {\n 1733→ const section = document.createElement('div');\n 1734→ section.className = 'pretty-section';\n 1735→ const label = document.createElement('div');\n 1736→ label.className = 'pretty-label';\n 1737→ label.textContent = message.role === 'assistant' ? 'Response' : 'Message';\n 1738→ const body = document.createElement('div');\n 1739→ body.className = 'pretty-content';\n 1740→ body.innerHTML = renderMarkdown(item.text);\n 1741→ section.appendChild(label);\n 1742→ section.appendChild(body);\n 1743→ fragment.appendChild(section);\n 1744→ hasContent = true;\n 1745→ }\n 1746→\n 1747→ // Thinking content\n 1748→ if (item.type === 'thinking' && item.thinking) {\n 1749→ const section = document.createElement('div');\n 1750→ section.className = 'pretty-section';\n 1751→ const label = document.createElement('div');\n 1752→ label.className = 'pretty-label';\n 1753→ label.textContent = 'Thinking';\n 1754→ const body = document.createElement('div');\n 1755→ body.className = 'thinking-block pretty-content';\n 1756→ body.innerHTML = renderMarkdown(item.thinking);\n 1757→ section.appendChild(label);\n 1758→ section.appendChild(body);\n 1759→ fragment.appendChild(section);\n 1760→ hasContent = true;\n 1761→ }\n 1762→\n 1763→ // Tool use\n 1764→ if (item.type === 'tool_use') {\n 1765→ const section = document.createElement('div');\n 1766→ section.className = 'pretty-section';\n 1767→ const label = document.createElement('div');\n 1768→ label.className = 'pretty-label';\n 1769→ label.textContent = 'Tool Call';\n 1770→\n 1771→ const toolName = item.name || 'Unknown Tool';\n 1772→ const input = item.input || {};\n 1773→\n 1774→ // Special handling for Bash command\n 1775→ if (toolName === 'Bash' && input.command) {\n 1776→ const block = document.createElement('div');\n 1777→ block.className = 'command-block';\n 1778→\n 1779→ const header = document.createElement('div');\n 1780→ header.className = 'command-header';\n 1781→ const nameSpan = document.createElement('span');\n 1782→ nameSpan.className = 'tool-name';\n 1783→ nameSpan.textContent = toolName;\n 1784→ header.appendChild(nameSpan);\n 1785→ if (input.description) {\n 1786→ const descSpan = document.createElement('span');\n 1787→ descSpan.className = 'tool-desc';\n 1788→ descSpan.textContent = input.description;\n 1789→ header.appendChild(descSpan);\n 1790→ }\n 1791→\n 1792→ const cmdInput = document.createElement('div');\n 1793→ cmdInput.className = 'command-input';\n 1794→ cmdInput.textContent = input.command;\n 1795→\n 1796→ block.appendChild(header);\n 1797→ block.appendChild(cmdInput);\n 1798→ section.appendChild(label);\n 1799→ section.appendChild(block);\n 1800→ }\n 1801→ // Special handling for file operations\n 1802→ else if (['Read', 'Write', 'Edit', 'Glob', 'Grep'].includes(toolName)) {\n 1803→ const block = document.createElement('div');\n 1804→ block.className = 'command-block';\n 1805→\n 1806→ const header = document.createElement('div');\n 1807→ header.className = 'command-header';\n 1808→ const nameSpan = document.createElement('span');\n 1809→ nameSpan.className = 'tool-name';\n 1810→ nameSpan.textContent = toolName;\n 1811→ header.appendChild(nameSpan);\n 1812→\n 1813→ block.appendChild(header);\n 1814→\n 1815→ const params = document.createElement('div');\n 1816→ params.className = 'tool-params';\n 1817→ params.style.padding = '10px 12px';\n 1818→\n 1819→ for (const [key, value] of Object.entries(input)) {\n 1820→ const param = document.createElement('div');\n 1821→ param.className = 'tool-param';\n 1822→ const paramName = document.createElement('span');\n 1823→ paramName.className = 'tool-param-name';\n 1824→ paramName.textContent = key;\n 1825→ const paramValue = document.createElement('span');\n 1826→ paramValue.className = 'tool-param-value';\n 1827→ if (typeof value === 'string') {\n 1828→ paramValue.textContent = value.length > 500 ? value.slice(0, 500) + '...' : value;\n 1829→ } else {\n 1830→ paramValue.textContent = JSON.stringify(value, null, 2);\n 1831→ }\n 1832→ param.appendChild(paramName);\n 1833→ param.appendChild(paramValue);\n 1834→ params.appendChild(param);\n 1835→ }\n 1836→\n 1837→ block.appendChild(params);\n 1838→ section.appendChild(label);\n 1839→ section.appendChild(block);\n 1840→ }\n 1841→ // Generic tool display\n 1842→ else {\n 1843→ const block = document.createElement('div');\n 1844→ block.className = 'command-block';\n 1845→\n 1846→ const header = document.createElement('div');\n 1847→ header.className = 'command-header';\n 1848→ const nameSpan = document.createElement('span');\n 1849→ nameSpan.className = 'tool-name';\n 1850→ nameSpan.textContent = toolName;\n 1851→ header.appendChild(nameSpan);\n 1852→\n 1853→ block.appendChild(header);\n 1854→\n 1855→ if (Object.keys(input).length > 0) {\n 1856→ const params = document.createElement('div');\n 1857→ params.className = 'tool-params';\n 1858→ params.style.padding = '10px 12px';\n 1859→\n 1860→ for (const [key, value] of Object.entries(input)) {\n 1861→ const param = document.createElement('div');\n 1862→ param.className = 'tool-param';\n 1863→ const paramName = document.createElement('span');\n 1864→ paramName.className = 'tool-param-name';\n 1865→ paramName.textContent = key;\n 1866→ const paramValue = document.createElement('span');\n 1867→ paramValue.className = 'tool-param-value';\n 1868→ if (typeof value === 'string') {\n 1869→ paramValue.textContent = value.length > 1000 ? value.slice(0, 1000) + '...' : value;\n 1870→ } else {\n 1871→ paramValue.textContent = JSON.stringify(value, null, 2);\n 1872→ }\n 1873→ param.appendChild(paramName);\n 1874→ param.appendChild(paramValue);\n 1875→ params.appendChild(param);\n 1876→ }\n 1877→\n 1878→ block.appendChild(params);\n 1879→ }\n 1880→\n 1881→ section.appendChild(label);\n 1882→ section.appendChild(block);\n 1883→ }\n 1884→\n 1885→ fragment.appendChild(section);\n 1886→ hasContent = true;\n 1887→ }\n 1888→\n 1889→ // Tool result\n 1890→ if (item.type === 'tool_result') {\n 1891→ const section = document.createElement('div');\n 1892→ section.className = 'pretty-section';\n 1893→ const label = document.createElement('div');\n 1894→ label.className = 'pretty-label';\n 1895→ label.textContent = item.is_error ? 'Tool Error' : 'Tool Result';\n 1896→\n 1897→ const resultContent = item.content;\n 1898→ let resultText = '';\n 1899→\n 1900→ if (typeof resultContent === 'string') {\n 1901→ resultText = resultContent;\n 1902→ } else if (Array.isArray(resultContent)) {\n 1903→ for (const rc of resultContent) {\n 1904→ if (rc?.type === 'text' && rc.text) {\n 1905→ resultText += rc.text;\n 1906→ }\n 1907→ }\n 1908→ }\n 1909→\n 1910→ if (resultText) {\n 1911→ const block = document.createElement('div');\n 1912→ block.className = 'pretty-content' + (item.is_error ? ' result-error' : '');\n 1913→ const displayText = resultText.length > 5000 ? resultText.slice(0, 5000) + '\\n... [truncated]' : resultText;\n 1914→ block.innerHTML = renderMarkdown(displayText);\n 1915→ section.appendChild(label);\n 1916→ section.appendChild(block);\n 1917→ fragment.appendChild(section);\n 1918→ hasContent = true;\n 1919→ }\n 1920→ }\n 1921→ }\n 1922→ }\n 1923→\n 1924→ if (hasContent) {\n 1925→ els.detailPretty.appendChild(fragment);\n 1926→ els.detailPretty.hidden = false;\n 1927→ } else {\n 1928→ els.detailPretty.hidden = true;\n 1929→ }\n 1930→ }\n 1931→\n 1932→ function openImageModal(src, altText = '') {\n 1933→ if (!src) return;\n 1934→ lastActiveElementBeforeModal = document.activeElement instanceof HTMLElement ? document.activeElement : null;\n 1935→ lastBodyOverflow = document.body.style.overflow;\n 1936→ document.body.style.overflow = 'hidden';\n 1937→\n 1938→ els.imageModalImg.src = src;\n 1939→ els.imageModalImg.alt = altText;\n 1940→ els.imageModal.classList.add('open');\n 1941→ els.imageModal.setAttribute('aria-hidden', 'false');\n 1942→ els.imageModalClose.focus();\n 1943→ }\n 1944→\n 1945→ function closeImageModal() {\n 1946→ if (!els.imageModal.classList.contains('open')) return;\n 1947→ els.imageModal.classList.remove('open');\n 1948→ els.imageModal.setAttribute('aria-hidden', 'true');\n 1949→ els.imageModalImg.removeAttribute('src');\n 1950→ els.imageModalImg.alt = '';\n 1951→ document.body.style.overflow = lastBodyOverflow ?? '';\n 1952→\n 1953→ if (lastActiveElementBeforeModal) {\n 1954→ lastActiveElementBeforeModal.focus();\n 1955→ }\n 1956→ lastActiveElementBeforeModal = null;\n 1957→ lastBodyOverflow = null;\n 1958→ }\n 1959→\n 1960→ function renderDetailImages(obj) {\n 1961→ const urls = extractImageUrls(obj);\n 1962→ els.detailImages.innerHTML = '';\n 1963→ if (!urls.length) {\n 1964→ els.detailImages.hidden = true;\n 1965→ return;\n 1966→ }\n 1967→ const fragment = document.createDocumentFragment();\n 1968→ urls.forEach((src, idx) => {\n 1969→ const btn = document.createElement('button');\n 1970→ btn.type = 'button';\n 1971→ btn.className = 'thumb';\n 1972→ btn.title = `Open image ${idx + 1}`;\n 1973→ const img = document.createElement('img');\n 1974→ img.loading = 'lazy';\n 1975→ img.src = src;\n 1976→ img.alt = `Image ${idx + 1}`;\n 1977→ btn.appendChild(img);\n 1978→ btn.addEventListener('click', () => openImageModal(src, `Image ${idx + 1}`));\n 1979→ fragment.appendChild(btn);\n 1980→ });\n 1981→ els.detailImages.appendChild(fragment);\n 1982→ els.detailImages.hidden = false;\n 1983→ }\n 1984→\n 1985→ function isEditableElement(el) {\n 1986→ if (!(el instanceof HTMLElement)) return false;\n 1987→ if (el.isContentEditable) return true;\n 1988→ const tag = el.tagName;\n 1989→ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';\n 1990→ }\n 1991→\n 1992→ function moveSelection(delta) {\n 1993→ if (!state.filtered.length) return;\n 1994→ const idx = state.filtered.findIndex(e => e.id === state.selectedId);\n 1995→ if (idx === -1) {\n 1996→ selectEvent(state.filtered[0].id, { scroll: true });\n 1997→ return;\n 1998→ }\n 1999→ const nextIdx = Math.max(0, Math.min(state.filtered.length - 1, idx + delta));\n 2000→ if (nextIdx === idx) return;\n 2001→ selectEvent(state.filtered[nextIdx].id, { scroll: true });\n 2002→ }\n 2003→\n 2004→ async function copyText(text) {\n 2005→ if (typeof text !== 'string') return;\n 2006→ try {\n 2007→ await navigator.clipboard.writeText(text);\n 2008→ setStatus('Copied to clipboard.', 'good');\n 2009→ } catch (error) {\n 2010→ const ta = document.createElement('textarea');\n 2011→ ta.value = text;\n 2012→ ta.style.position = 'fixed';\n 2013→ ta.style.left = '-9999px';\n 2014→ document.body.appendChild(ta);\n 2015→ ta.select();\n 2016→ document.execCommand('copy');\n 2017→ document.body.removeChild(ta);\n 2018→ setStatus('Copied to clipboard.', 'good');\n 2019→ }\n 2020→ }\n 2021→\n 2022→ function extractUserPrompts() {\n 2023→ const prompts = [];\n 2024→ for (const e of state.events) {\n 2025→ // Filter: type=user, content-type includes text, role=user\n 2026→ if (e.kind !== 'user') continue;\n 2027→ if (e.role !== 'user') continue;\n 2028→ if (!e.contentTypes.includes('text')) continue;\n 2029→\n 2030→ const message = e.obj?.message;\n 2031→ if (!message) continue;\n 2032→\n 2033→ const content = message.content;\n 2034→ if (typeof content === 'string') {\n 2035→ prompts.push(content);\n 2036→ } else if (Array.isArray(content)) {\n 2037→ for (const item of content) {\n 2038→ if (item?.type === 'text' && item.text) {\n 2039→ prompts.push(item.text);\n 2040→ }\n 2041→ }\n 2042→ }\n 2043→ }\n 2044→ return prompts.join('\\n\\n');\n 2045→ }\n 2046→\n 2047→ async function copyUserPrompts() {\n 2048→ const text = extractUserPrompts();\n 2049→ if (!text) {\n 2050→ setStatus('No user prompts found.', 'warn');\n 2051→ return;\n 2052→ }\n 2053→ await copyText(text);\n 2054→ const originalText = els.copyPrompts.textContent;\n 2055→ els.copyPrompts.textContent = 'Copied!';\n 2056→ setTimeout(() => {\n 2057→ els.copyPrompts.textContent = originalText;\n 2058→ }, 2000);\n 2059→ }\n 2060→\n 2061→ function setTimezoneMode(mode) {\n 2062→ state.tzMode = mode;\n 2063→ els.tzLocal.classList.toggle('active', mode === 'local');\n 2064→ els.tzUtc.classList.toggle('active', mode === 'utc');\n 2065→ applyFilters();\n 2066→ if (state.selectedId !== null) selectEvent(state.selectedId);\n 2067→ syncUrlHashFromControls();\n 2068→ }\n 2069→\n 2070→ els.imageModalClose.addEventListener('click', closeImageModal);\n 2071→ els.imageModal.addEventListener('click', (event) => {\n 2072→ if (event.target === els.imageModal) closeImageModal();\n 2073→ });\n 2074→ document.addEventListener('keydown', (event) => {\n 2075→ if (event.key === 'Escape') closeImageModal();\n 2076→ });\n 2077→\n 2078→ document.addEventListener('keydown', (event) => {\n 2079→ if (els.imageModal.classList.contains('open')) return;\n 2080→ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;\n 2081→ if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;\n 2082→ if (isEditableElement(document.activeElement)) return;\n 2083→ if (document.activeElement !== els.timeline && !els.timeline.contains(document.activeElement)) return;\n 2084→ if (state.selectedId === null) return;\n 2085→ event.preventDefault();\n 2086→ moveSelection(event.key === 'ArrowUp' ? -1 : 1);\n 2087→ });\n 2088→\n 2089→ if (typeof wideLayoutMq.addEventListener === 'function') {\n 2090→ wideLayoutMq.addEventListener('change', updateDetailPlacement);\n 2091→ } else if (typeof wideLayoutMq.addListener === 'function') {\n 2092→ wideLayoutMq.addListener(updateDetailPlacement);\n 2093→ }\n 2094→\n 2095→ function setTab(tabName) {\n 2096→ for (const tab of document.querySelectorAll('.tab')) {\n 2097→ const active = tab.dataset.tab === tabName;\n 2098→ tab.classList.toggle('active', active);\n 2099→ tab.setAttribute('aria-selected', active ? 'true' : 'false');\n 2100→ }\n 2101→ for (const panel of document.querySelectorAll('[data-panel]')) {\n 2102→ panel.hidden = panel.dataset.panel !== tabName;\n 2103→ }\n 2104→ }\n 2105→\n 2106→ function setShareUrlParam(url) {\n 2107→ const current = new URL(window.location.href);\n 2108→ if (url) current.searchParams.set('url', url);\n 2109→ else current.searchParams.delete('url');\n 2110→ window.history.replaceState(null, '', current);\n 2111→ }\n 2112→\n 2113→ async function fetchAndRenderUrl(url, { syncUrl = true } = {}) {\n 2114→ const trimmed = (url || '').trim();\n 2115→ if (!trimmed) {\n 2116→ setStatus('Enter a URL first.', 'error');\n 2117→ return;\n 2118→ }\n 2119→ if (syncUrl) setShareUrlParam(trimmed);\n 2120→ setStatus(`Fetching ${trimmed}...`, 'warn');\n 2121→ try {\n 2122→ const resp = await fetch(trimmed, { mode: 'cors' });\n 2123→ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);\n 2124→ const text = await resp.text();\n 2125→ await loadText(text, trimmed);\n 2126→ } catch (error) {\n 2127→ setStatus(`Failed to fetch URL: ${String(error)}`, 'error');\n 2128→ }\n 2129→ }\n 2130→\n 2131→ async function loadText(text, sourceLabel) {\n 2132→ const started = performance.now();\n 2133→ state.sourceLabel = sourceLabel || null;\n 2134→ setStatus(`Parsing ${sourceLabel || 'content'}...`, 'warn');\n 2135→ clearSelection({ syncHash: false });\n 2136→\n 2137→ const lines = text.split(/\\r?\\n/);\n 2138→ const events = [];\n 2139→ const errors = [];\n 2140→ let idCounter = 0;\n 2141→\n 2142→ for (let i = 0; i < lines.length; i++) {\n 2143→ const line = lines[i];\n 2144→ if (!line.trim()) continue;\n 2145→ const parsed = safeJsonParse(line);\n 2146→ if (!parsed.ok) {\n 2147→ errors.push({ line: i + 1, error: parsed.error });\n 2148→ continue;\n 2149→ }\n 2150→ const obj = parsed.value;\n 2151→ const ts = typeof obj.timestamp === 'string' ? new Date(obj.timestamp) : null;\n 2152→ const kind = obj.type || '(unknown)';\n 2153→ const message = obj.message && typeof obj.message === 'object' ? obj.message : null;\n 2154→ const contentTypes = message ? getContentTypes(message) : [];\n 2155→ const role = message?.role ?? null;\n 2156→\n 2157→ const summary = summarizeEvent(obj);\n 2158→ events.push({\n 2159→ id: ++idCounter,\n 2160→ line: i + 1,\n 2161→ raw: line,\n 2162→ obj,\n 2163→ date: ts,\n 2164→ kind,\n 2165→ contentTypes,\n 2166→ role,\n 2167→ summary,\n 2168→ search: buildSearchText(obj, summary),\n 2169→ });\n 2170→ }\n 2171→\n 2172→ state.events = events;\n 2173→ updateFilterOptions(events);\n 2174→ applyFilters();\n 2175→ applySelectionFromHash();\n 2176→ syncUrlHashFromControls();\n 2177→\n 2178→ const durationMs = Math.round(performance.now() - started);\n 2179→ if (errors.length) {\n 2180→ const first = errors[0];\n 2181→ setStatus(\n 2182→ `Loaded ${events.length} events from ${sourceLabel || 'content'} in ${durationMs}ms.\\n` +\n 2183→ `Could not parse ${errors.length} line(s). First error at line ${first.line}: ${String(first.error)}`,\n 2184→ 'warn'\n 2185→ );\n 2186→ } else {\n 2187→ setStatus(`Loaded ${events.length} events from ${sourceLabel || 'content'} in ${durationMs}ms.`, 'good');\n 2188→ }\n 2189→ }\n 2190→\n 2191→ async function handleFile(file) {\n 2192→ if (!file) return;\n 2193→ setShareUrlParam(null);\n 2194→ try {\n 2195→ const text = await file.text();\n 2196→ await loadText(text, file.name);\n 2197→ } catch (error) {\n 2198→ setStatus(`Failed to read file: ${String(error)}`, 'error');\n 2199→ }\n 2200→ }\n 2201→\n 2202→ els.dropzone.addEventListener('click', () => els.fileInput.click());\n 2203→ els.dropzone.addEventListener('keydown', (e) => {\n 2204→ if (e.key === 'Enter' || e.key === ' ') {\n 2205→ e.preventDefault();\n 2206→ els.fileInput.click();\n 2207→ }\n 2208→ });\n 2209→ els.fileInput.addEventListener('change', () => handleFile(els.fileInput.files?.[0]));\n 2210→\n 2211→ els.dropzone.addEventListener('dragover', (e) => {\n 2212→ e.preventDefault();\n 2213→ els.dropzone.classList.add('dragover');\n 2214→ });\n 2215→ els.dropzone.addEventListener('dragleave', () => els.dropzone.classList.remove('dragover'));\n 2216→ els.dropzone.addEventListener('drop', (e) => {\n 2217→ e.preventDefault();\n 2218→ els.dropzone.classList.remove('dragover');\n 2219→ const file = e.dataTransfer?.files?.[0];\n 2220→ if (file) handleFile(file);\n 2221→ });\n 2222→\n 2223→ els.renderPaste.addEventListener('click', async () => {\n 2224→ const text = els.pasteInput.value || '';\n 2225→ if (!text.trim()) {\n 2226→ setStatus('Paste some JSONL content first.', 'error');\n 2227→ return;\n 2228→ }\n 2229→ setShareUrlParam(null);\n 2230→ await loadText(text, 'pasted content');\n 2231→ });\n 2232→\n 2233→ els.fetchUrl.addEventListener('click', async () => {\n 2234→ await fetchAndRenderUrl(els.urlInput.value, { syncUrl: true });\n 2235→ });\n 2236→\n 2237→ for (const tab of document.querySelectorAll('.tab')) {\n 2238→ tab.addEventListener('click', () => setTab(tab.dataset.tab));\n 2239→ }\n 2240→\n 2241→ els.tzLocal.addEventListener('click', () => setTimezoneMode('local'));\n 2242→ els.tzUtc.addEventListener('click', () => setTimezoneMode('utc'));\n 2243→\n 2244→ els.search.addEventListener('input', () => {\n 2245→ state.filters.query = els.search.value.trim().toLowerCase();\n 2246→ applyFilters();\n 2247→ syncUrlHashFromControls();\n 2248→ });\n 2249→ els.typeFilter.addEventListener('change', () => {\n 2250→ state.filters.type = els.typeFilter.value;\n 2251→ applyFilters();\n 2252→ syncUrlHashFromControls();\n 2253→ });\n 2254→ els.contentTypeFilter.addEventListener('change', () => {\n 2255→ state.filters.contentType = els.contentTypeFilter.value;\n 2256→ applyFilters();\n 2257→ syncUrlHashFromControls();\n 2258→ });\n 2259→ els.roleFilter.addEventListener('change', () => {\n 2260→ state.filters.role = els.roleFilter.value;\n 2261→ applyFilters();\n 2262→ syncUrlHashFromControls();\n 2263→ });\n 2264→ els.hideThinking.addEventListener('change', () => {\n 2265→ state.filters.hideThinking = els.hideThinking.checked;\n 2266→ applyFilters();\n 2267→ syncUrlHashFromControls();\n 2268→ });\n 2269→ els.truncateStrings.addEventListener('change', () => {\n 2270→ if (state.selectedId !== null) selectEvent(state.selectedId);\n 2271→ syncUrlHashFromControls();\n 2272→ });\n 2273→\n 2274→ els.clearFilters.addEventListener('click', () => {\n 2275→ els.search.value = '';\n 2276→ state.filters.query = '';\n 2277→ state.filters.type = 'all';\n 2278→ state.filters.contentType = 'all';\n 2279→ state.filters.role = 'all';\n 2280→ state.filters.hideThinking = false;\n 2281→ els.hideThinking.checked = false;\n 2282→ updateFilterOptions(state.events);\n 2283→ applyFilters();\n 2284→ syncUrlHashFromControls();\n 2285→ });\n 2286→\n 2287→ els.copyJson.addEventListener('click', () => {\n 2288→ if (!state.selected) return;\n 2289→ copyText(stringifyJson(state.selected.obj, false));\n 2290→ });\n 2291→ els.copyRaw.addEventListener('click', () => {\n 2292→ if (!state.selected) return;\n 2293→ copyText(state.selected.raw);\n 2294→ });\n 2295→ els.copyPrompts.addEventListener('click', copyUserPrompts);\n 2296→\n 2297→ async function initFromQuery() {\n 2298→ const url = new URLSearchParams(window.location.search).get('url');\n 2299→ if (!url) return;\n 2300→ setTab('url');\n 2301→ els.urlInput.value = url;\n 2302→ await fetchAndRenderUrl(url, { syncUrl: false });\n 2303→ }\n 2304→\n 2305→ // Initialize select options for the empty state.\n 2306→ applyControlsFromHash();\n 2307→ updateFilterOptions([]);\n 2308→ syncUrlHashFromControls();\n 2309→ window.addEventListener('hashchange', () => {\n 2310→ applyControlsFromHash();\n 2311→ updateFilterOptions(state.events);\n 2312→ applyFilters();\n 2313→ applySelectionFromHash();\n 2314→ });\n 2315→ void initFromQuery();\n 2316→ </script>\n 2317→</body>\n 2318→</html>\n 2319→\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"uuid":"ebb0e3e4-cb8d-4395-9240-b5762c279458","timestamp":"2025-12-18T05:51:53.644Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","content":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>Claude Code Timeline Viewer</title>\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js\" integrity=\"sha384-osZDKVu4ipZP703HmPOhWdyBajcFyjX2Psjk//TG1Rc0AdwEtuToaylrmcK3LdAl\" crossorigin=\"anonymous\"></script>\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/markdown-it/4.4.0/markdown-it.min.js\" integrity=\"sha384-cxGp+QDeMxACP73aA0Lj0Sc/h4z1X73+umcvCyI0Vbyh3OYxgrPI+0mxG9slsMTN\" crossorigin=\"anonymous\"></script>\n <style>\n :root {\n color-scheme: light dark;\n --bg: #0b0c0f;\n --panel: #12141a;\n --panel-2: #0f1116;\n --text: #e8eaf0;\n --muted: #a6adbf;\n --line: rgba(255, 255, 255, 0.10);\n --line-2: rgba(255, 255, 255, 0.16);\n --accent: #6aa9ff;\n --accent-2: #8a5cff;\n --good: #46d39a;\n --warn: #ffcc66;\n --bad: #ff6b6b;\n --shadow: 0 18px 50px rgba(0, 0, 0, 0.35);\n --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\",\n \"Courier New\", monospace;\n --sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial,\n \"Apple Color Emoji\", \"Segoe UI Emoji\";\n }\n\n @media (prefers-color-scheme: light) {\n :root {\n --bg: #f7f8fc;\n --panel: #ffffff;\n --panel-2: #ffffff;\n --text: #10121a;\n --muted: #5b647a;\n --line: rgba(16, 18, 26, 0.12);\n --line-2: rgba(16, 18, 26, 0.18);\n --shadow: 0 18px 50px rgba(16, 18, 26, 0.10);\n }\n }\n\n * { box-sizing: border-box; }\n html, body { height: 100%; }\n body {\n margin: 0;\n font-family: var(--sans);\n background: radial-gradient(1200px 600px at 10% -10%, rgba(106, 169, 255, 0.18), transparent 45%),\n radial-gradient(1200px 600px at 90% -20%, rgba(138, 92, 255, 0.16), transparent 50%),\n var(--bg);\n color: var(--text);\n }\n\n .wrap {\n max-width: none;\n margin: 0;\n padding: 22px 16px 42px;\n }\n\n header {\n display: grid;\n gap: 10px;\n margin-bottom: 14px;\n }\n\n h1 {\n margin: 0;\n font-size: clamp(22px, 3vw, 32px);\n letter-spacing: 0.2px;\n }\n\n .sub {\n margin: 6px 0 0;\n color: var(--muted);\n line-height: 1.35;\n max-width: 70ch;\n }\n\n .controls {\n display: flex;\n flex-wrap: wrap;\n gap: 10px;\n align-items: center;\n justify-content: flex-start;\n }\n\n .controls .field {\n display: grid;\n gap: 4px;\n min-width: 180px;\n }\n\n .label {\n font-size: 12px;\n color: var(--muted);\n }\n\n input[type=\"search\"], input[type=\"url\"], select, textarea {\n font: inherit;\n color: inherit;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n border-radius: 10px;\n padding: 10px 11px;\n outline: none;\n }\n\n input[type=\"search\"]:focus, input[type=\"url\"]:focus, select:focus, textarea:focus {\n border-color: color-mix(in srgb, var(--accent) 60%, var(--line));\n box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);\n }\n\n .segmented {\n display: inline-flex;\n border: 1px solid var(--line);\n border-radius: 999px;\n overflow: hidden;\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n }\n\n .segmented button {\n border: none;\n padding: 9px 12px;\n font: inherit;\n background: transparent;\n color: var(--muted);\n cursor: pointer;\n min-width: 74px;\n }\n\n .segmented button.active {\n background: color-mix(in srgb, var(--accent) 16%, transparent);\n color: var(--text);\n }\n\n .segmented button:focus-visible {\n outline: 2px solid color-mix(in srgb, var(--accent) 70%, transparent);\n outline-offset: -2px;\n }\n\n .btn {\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n color: var(--text);\n border-radius: 10px;\n padding: 10px 12px;\n font: inherit;\n cursor: pointer;\n transition: transform 80ms ease, border-color 120ms ease;\n }\n\n .btn:hover { border-color: var(--line-2); }\n .btn:active { transform: translateY(1px); }\n .btn.primary {\n border-color: color-mix(in srgb, var(--accent) 55%, var(--line));\n background: color-mix(in srgb, var(--accent) 14%, transparent);\n }\n\n .btn:disabled {\n opacity: 0.55;\n cursor: not-allowed;\n }\n\n .panel {\n background: var(--panel);\n border: 1px solid var(--line);\n border-radius: 16px;\n box-shadow: var(--shadow);\n }\n\n .input-panel {\n padding: 14px;\n margin-bottom: 14px;\n }\n\n .filters-panel {\n padding: 14px;\n margin-bottom: 14px;\n }\n\n .input-grid {\n display: grid;\n grid-template-columns: 1fr;\n gap: 14px;\n align-items: stretch;\n }\n\n @media (min-width: 920px) {\n .input-grid {\n grid-template-columns: 380px 1fr;\n }\n }\n\n .dropzone {\n border: 1px dashed color-mix(in srgb, var(--accent) 40%, var(--line));\n border-radius: 14px;\n background: linear-gradient(\n 135deg,\n color-mix(in srgb, var(--accent) 12%, transparent),\n transparent 45%,\n color-mix(in srgb, var(--accent-2) 10%, transparent)\n );\n padding: 16px;\n display: grid;\n gap: 6px;\n cursor: pointer;\n min-height: 124px;\n align-content: center;\n }\n\n .dropzone.dragover {\n border-style: solid;\n border-color: color-mix(in srgb, var(--accent) 70%, var(--line));\n box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);\n }\n\n .dropzone strong { font-size: 15px; }\n .muted { color: var(--muted); }\n .tiny { font-size: 12px; color: var(--muted); }\n\n .tabs {\n display: flex;\n gap: 8px;\n margin-bottom: 10px;\n flex-wrap: wrap;\n align-items: center;\n }\n\n .tab {\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel-2) 94%, transparent);\n color: var(--muted);\n border-radius: 999px;\n padding: 7px 10px;\n cursor: pointer;\n font: inherit;\n }\n\n .tab.active {\n color: var(--text);\n border-color: color-mix(in srgb, var(--accent) 55%, var(--line));\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n }\n\n textarea {\n width: 100%;\n min-height: 190px;\n resize: vertical;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.4;\n }\n\n .row {\n display: flex;\n gap: 10px;\n flex-wrap: wrap;\n align-items: center;\n margin-top: 10px;\n }\n\n .status {\n margin-top: 12px;\n padding: 10px 12px;\n border-radius: 12px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel-2) 94%, transparent);\n display: none;\n white-space: pre-wrap;\n word-break: break-word;\n }\n .status.visible { display: block; }\n .status.error { border-color: color-mix(in srgb, var(--bad) 60%, var(--line)); }\n .status.good { border-color: color-mix(in srgb, var(--good) 55%, var(--line)); }\n .status.warn { border-color: color-mix(in srgb, var(--warn) 55%, var(--line)); }\n\n main {\n display: grid;\n gap: 14px;\n grid-template-columns: 1fr;\n min-height: 560px;\n }\n\n @media (min-width: 1060px) {\n main {\n grid-template-columns: 1fr 520px;\n align-items: stretch;\n }\n }\n\n .timeline-panel {\n display: grid;\n grid-template-rows: auto 1fr;\n min-height: 560px;\n }\n\n .stats {\n display: flex;\n gap: 12px;\n flex-wrap: wrap;\n align-items: baseline;\n justify-content: space-between;\n padding: 12px 14px;\n border-bottom: 1px solid var(--line);\n }\n\n .stats .big {\n font-size: 14px;\n font-weight: 600;\n }\n\n .timeline {\n overflow: auto;\n padding: 8px 0;\n }\n\n .timeline:focus-visible {\n outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent);\n outline-offset: -2px;\n border-radius: 14px;\n }\n\n .day-sep {\n position: sticky;\n top: 0;\n z-index: 2;\n padding: 8px 14px;\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n border-top: 1px solid var(--line);\n border-bottom: 1px solid var(--line);\n font-size: 12px;\n color: var(--muted);\n backdrop-filter: blur(6px);\n }\n\n .event {\n display: grid;\n grid-template-columns: 176px 1fr;\n gap: 12px;\n padding: 10px 14px;\n border-bottom: 1px solid color-mix(in srgb, var(--line) 85%, transparent);\n cursor: pointer;\n }\n\n .event:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); }\n .event.selected {\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n outline: 2px solid color-mix(in srgb, var(--accent) 35%, transparent);\n outline-offset: -2px;\n }\n\n .time {\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.35;\n color: var(--muted);\n display: grid;\n gap: 3px;\n }\n\n .time .ts {\n color: var(--text);\n font-weight: 600;\n }\n\n .badges {\n display: flex;\n gap: 6px;\n flex-wrap: wrap;\n align-items: center;\n margin-bottom: 6px;\n }\n\n .badge {\n font-size: 11px;\n font-family: var(--mono);\n padding: 2px 7px;\n border-radius: 999px;\n border: 1px solid var(--line);\n color: var(--muted);\n background: color-mix(in srgb, var(--panel-2) 90%, transparent);\n }\n\n .badge.k-user { border-color: color-mix(in srgb, #00c8ff 55%, var(--line)); color: color-mix(in srgb, #00c8ff 78%, var(--text)); }\n .badge.k-assistant { border-color: color-mix(in srgb, #ff73c4 55%, var(--line)); color: color-mix(in srgb, #ff73c4 78%, var(--text)); }\n .badge.k-file-history-snapshot { border-color: color-mix(in srgb, var(--warn) 55%, var(--line)); color: color-mix(in srgb, var(--warn) 75%, var(--text)); }\n\n .badge.ct-text { border-color: color-mix(in srgb, var(--good) 55%, var(--line)); color: color-mix(in srgb, var(--good) 75%, var(--text)); }\n .badge.ct-thinking { border-color: color-mix(in srgb, var(--accent-2) 55%, var(--line)); color: color-mix(in srgb, var(--accent-2) 75%, var(--text)); }\n .badge.ct-tool_use { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); color: color-mix(in srgb, var(--accent) 75%, var(--text)); }\n .badge.ct-tool_result { border-color: color-mix(in srgb, #8690a8 55%, var(--line)); color: color-mix(in srgb, #8690a8 70%, var(--text)); }\n .badge.ct-image { border-color: color-mix(in srgb, #ff9f43 55%, var(--line)); color: color-mix(in srgb, #ff9f43 75%, var(--text)); }\n\n .badge.role-user { border-color: color-mix(in srgb, #00c8ff 55%, var(--line)); color: color-mix(in srgb, #00c8ff 78%, var(--text)); }\n .badge.role-assistant { border-color: color-mix(in srgb, #ff73c4 55%, var(--line)); color: color-mix(in srgb, #ff73c4 78%, var(--text)); }\n\n .summary {\n font-size: 13px;\n line-height: 1.35;\n color: var(--text);\n word-break: break-word;\n }\n\n .summary code {\n font-family: var(--mono);\n font-size: 12px;\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n padding: 2px 5px;\n border-radius: 7px;\n }\n\n .detail {\n display: grid;\n grid-template-rows: auto auto auto auto 1fr;\n min-height: 560px;\n }\n\n @media (min-width: 1060px) {\n .detail {\n position: sticky;\n top: 16px;\n align-self: start;\n max-height: calc(100vh - 32px);\n }\n }\n\n .detail.inline {\n position: static;\n top: auto;\n align-self: auto;\n max-height: none;\n min-height: 0;\n margin: 8px 14px 12px;\n box-shadow: none;\n }\n\n .detail.inline pre {\n max-height: 52vh;\n }\n\n .detail-head {\n padding: 12px 14px;\n border-bottom: 1px solid var(--line);\n display: grid;\n gap: 8px;\n }\n\n .detail-title {\n font-size: 14px;\n font-weight: 700;\n line-height: 1.25;\n margin: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .detail-actions {\n display: flex;\n gap: 8px;\n flex-wrap: wrap;\n align-items: center;\n justify-content: flex-start;\n }\n\n .check {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n color: var(--muted);\n font-size: 12px;\n user-select: none;\n }\n\n pre {\n margin: 0;\n padding: 12px 14px 16px;\n overflow: auto;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.45;\n color: color-mix(in srgb, var(--text) 92%, transparent);\n background: transparent;\n white-space: pre-wrap;\n word-break: break-word;\n }\n\n .detail-images {\n display: flex;\n gap: 10px;\n padding: 12px 14px;\n border-bottom: 1px solid var(--line);\n overflow-x: auto;\n overscroll-behavior-x: contain;\n }\n\n .thumb {\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel-2) 92%, transparent);\n border-radius: 12px;\n padding: 0;\n cursor: pointer;\n width: 78px;\n height: 58px;\n overflow: hidden;\n flex: 0 0 auto;\n display: grid;\n place-items: stretch;\n }\n\n .thumb:hover { border-color: var(--line-2); }\n\n .thumb img {\n width: 100%;\n height: 100%;\n display: block;\n object-fit: cover;\n }\n\n .image-modal {\n position: fixed;\n inset: 0;\n z-index: 9999;\n background: rgba(0, 0, 0, 0.68);\n display: none;\n padding: 18px;\n place-items: center;\n }\n\n .image-modal.open { display: grid; }\n\n .image-modal .dialog {\n position: relative;\n border-radius: 16px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 98%, transparent);\n box-shadow: var(--shadow);\n padding: 14px;\n max-width: min(1200px, 96vw);\n max-height: 92vh;\n display: grid;\n place-items: center;\n }\n\n .image-modal img {\n display: block;\n max-width: min(1140px, 92vw);\n max-height: 84vh;\n width: auto;\n height: auto;\n object-fit: contain;\n border-radius: 10px;\n background: color-mix(in srgb, var(--panel-2) 92%, transparent);\n }\n\n .image-modal .close {\n position: absolute;\n top: 10px;\n right: 10px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n color: var(--text);\n border-radius: 999px;\n padding: 8px 10px;\n font: inherit;\n cursor: pointer;\n }\n\n .image-modal .close:hover { border-color: var(--line-2); }\n\n .empty {\n padding: 14px;\n color: var(--muted);\n font-size: 13px;\n line-height: 1.35;\n }\n\n /* Pretty view styles */\n .detail-pretty {\n padding: 14px;\n border-bottom: 1px solid var(--line);\n overflow: auto;\n max-height: 50vh;\n }\n\n .detail.inline .detail-pretty {\n max-height: 40vh;\n }\n\n .pretty-section {\n margin-bottom: 14px;\n }\n\n .pretty-section:last-child {\n margin-bottom: 0;\n }\n\n .pretty-label {\n font-size: 11px;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n color: var(--muted);\n margin-bottom: 6px;\n }\n\n .pretty-content {\n font-size: 13px;\n line-height: 1.5;\n }\n\n .pretty-content p {\n margin: 0 0 10px;\n }\n\n .pretty-content p:last-child {\n margin-bottom: 0;\n }\n\n .pretty-content h1, .pretty-content h2, .pretty-content h3,\n .pretty-content h4, .pretty-content h5, .pretty-content h6 {\n margin: 14px 0 8px;\n font-weight: 600;\n line-height: 1.3;\n }\n\n .pretty-content h1:first-child, .pretty-content h2:first-child,\n .pretty-content h3:first-child {\n margin-top: 0;\n }\n\n .pretty-content h1 { font-size: 1.4em; }\n .pretty-content h2 { font-size: 1.25em; }\n .pretty-content h3 { font-size: 1.1em; }\n\n .pretty-content ul, .pretty-content ol {\n margin: 8px 0;\n padding-left: 1.5em;\n list-style-position: outside;\n }\n\n .pretty-content li {\n margin: 4px 0;\n }\n\n .thinking-block ul, .thinking-block ol {\n padding-left: 2em;\n }\n\n .pretty-content code {\n font-family: var(--mono);\n font-size: 0.9em;\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n padding: 2px 5px;\n border-radius: 5px;\n }\n\n .pretty-content pre {\n margin: 10px 0;\n padding: 12px;\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n border-radius: 10px;\n overflow-x: auto;\n }\n\n .pretty-content pre code {\n background: none;\n border: none;\n padding: 0;\n font-size: 12px;\n }\n\n .pretty-content blockquote {\n margin: 10px 0;\n padding: 8px 12px;\n border-left: 3px solid var(--accent);\n background: color-mix(in srgb, var(--accent) 8%, transparent);\n border-radius: 0 8px 8px 0;\n }\n\n .pretty-content a {\n color: var(--accent);\n text-decoration: none;\n }\n\n .pretty-content a:hover {\n text-decoration: underline;\n }\n\n .pretty-content strong {\n font-weight: 600;\n }\n\n .pretty-content em {\n font-style: italic;\n }\n\n .pretty-content table {\n border-collapse: collapse;\n margin: 10px 0;\n width: 100%;\n font-size: 13px;\n }\n\n .pretty-content th,\n .pretty-content td {\n border: 1px solid var(--line);\n padding: 8px 10px;\n text-align: left;\n }\n\n .pretty-content th {\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n font-weight: 600;\n }\n\n .pretty-content tr:hover {\n background: color-mix(in srgb, var(--accent) 6%, transparent);\n }\n\n .command-block {\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n border-radius: 10px;\n overflow: hidden;\n margin: 8px 0;\n }\n\n .command-header {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 8px 12px;\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n border-bottom: 1px solid var(--line);\n font-size: 12px;\n }\n\n .command-header .tool-name {\n font-weight: 600;\n color: var(--accent);\n }\n\n .command-header .tool-desc {\n color: var(--muted);\n }\n\n .command-input {\n padding: 10px 12px;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.4;\n white-space: pre-wrap;\n word-break: break-word;\n border-bottom: 1px solid var(--line);\n }\n\n .command-input::before {\n content: '$ ';\n color: var(--good);\n font-weight: 600;\n }\n\n .command-output {\n padding: 10px 12px;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.4;\n white-space: pre-wrap;\n word-break: break-word;\n max-height: 300px;\n overflow: auto;\n color: var(--muted);\n }\n\n .file-path {\n font-family: var(--mono);\n font-size: 12px;\n padding: 6px 10px;\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n border-radius: 8px;\n display: inline-block;\n margin: 4px 0;\n }\n\n .tool-params {\n display: grid;\n gap: 8px;\n margin-top: 8px;\n }\n\n .tool-param {\n display: grid;\n grid-template-columns: auto 1fr;\n gap: 8px;\n align-items: start;\n font-size: 12px;\n }\n\n .tool-param-name {\n font-family: var(--mono);\n font-weight: 600;\n color: var(--accent);\n padding: 2px 6px;\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n border-radius: 4px;\n }\n\n .tool-param-value {\n font-family: var(--mono);\n white-space: pre-wrap;\n word-break: break-word;\n padding: 2px 0;\n }\n\n .thinking-block {\n background: color-mix(in srgb, var(--accent-2) 10%, transparent);\n border: 1px solid color-mix(in srgb, var(--accent-2) 30%, var(--line));\n border-radius: 10px;\n padding: 12px 16px;\n font-size: 13px;\n line-height: 1.5;\n }\n\n .result-block {\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n border-radius: 10px;\n padding: 12px;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.4;\n white-space: pre-wrap;\n word-break: break-word;\n max-height: 400px;\n overflow: auto;\n }\n\n .result-error {\n border-color: color-mix(in srgb, var(--bad) 50%, var(--line));\n background: color-mix(in srgb, var(--bad) 8%, transparent);\n }\n\n .json-toggle {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 8px 14px;\n border-bottom: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel-2) 50%, transparent);\n }\n\n .json-toggle-label {\n font-size: 12px;\n color: var(--muted);\n font-weight: 500;\n }\n </style>\n</head>\n <body>\n <div class=\"wrap\">\n <header>\n <div>\n <h1>Claude Code Timeline Viewer</h1>\n <p class=\"sub\">Open a Claude Code session <code>.jsonl</code> file and explore it as an interactive timeline. Times are shown in your local timezone by default. Find sessions in <code>~/.claude/projects/</code>.</p>\n </div>\n </header>\n\n <section class=\"panel input-panel\" aria-label=\"Input\">\n <div class=\"input-grid\">\n <div>\n <div id=\"dropzone\" class=\"dropzone\" tabindex=\"0\">\n <strong>Drop a <code>.jsonl</code> file here</strong>\n <div class=\"muted\">or click to choose a file</div>\n <div class=\"tiny\">Supported: file picker, drag-and-drop, paste, or URL fetch (CORS-enabled).</div>\n <input id=\"file-input\" type=\"file\" accept=\".jsonl,application/json,text/plain\" hidden />\n </div>\n </div>\n <div>\n <div class=\"tabs\" role=\"tablist\" aria-label=\"Input tabs\">\n <button type=\"button\" class=\"tab active\" data-tab=\"paste\" aria-selected=\"true\">Paste</button>\n <button type=\"button\" class=\"tab\" data-tab=\"url\" aria-selected=\"false\">URL</button>\n </div>\n\n <div id=\"panel-paste\" data-panel=\"paste\">\n <textarea id=\"paste-input\" placeholder=\"Paste JSONL content here...\"></textarea>\n <div class=\"row\">\n <button type=\"button\" class=\"btn primary\" id=\"render-paste\">Render pasted content</button>\n <div class=\"tiny\">Tip: the timeline uses one JSON object per line.</div>\n </div>\n </div>\n\n <div id=\"panel-url\" data-panel=\"url\" hidden>\n <input id=\"url-input\" type=\"url\" placeholder=\"https://example.com/session.jsonl\" />\n <div class=\"row\">\n <button type=\"button\" class=\"btn primary\" id=\"fetch-url\">Fetch and render</button>\n <div class=\"tiny\">The URL must allow cross-origin requests (CORS).</div>\n </div>\n </div>\n </div>\n </div>\n\n <div id=\"status\" class=\"status\" role=\"status\" aria-live=\"polite\"></div>\n </section>\n\n <section class=\"panel filters-panel\" aria-label=\"Filters\">\n <div class=\"controls\" aria-label=\"Timeline controls\">\n <div class=\"field\" style=\"min-width: 170px\">\n <div class=\"label\">Timezone</div>\n <div class=\"segmented\" role=\"group\" aria-label=\"Timezone toggle\">\n <button type=\"button\" id=\"tz-local\" class=\"active\">Local</button>\n <button type=\"button\" id=\"tz-utc\">UTC</button>\n </div>\n </div>\n <div class=\"field\" style=\"min-width: 220px\">\n <div class=\"label\">Search</div>\n <input id=\"search\" type=\"search\" placeholder=\"message, tool name, cwd...\" autocomplete=\"off\" />\n </div>\n <div class=\"field\" style=\"min-width: 190px\">\n <div class=\"label\">Type</div>\n <select id=\"type-filter\"></select>\n </div>\n <div class=\"field\" style=\"min-width: 210px\">\n <div class=\"label\">Content Type</div>\n <select id=\"content-type-filter\"></select>\n </div>\n <div class=\"field\" style=\"min-width: 160px\">\n <div class=\"label\">Role</div>\n <select id=\"role-filter\"></select>\n </div>\n <label class=\"check\" title=\"Hide thinking blocks (can be verbose)\">\n <input type=\"checkbox\" id=\"hide-thinking\" />\n Hide thinking\n </label>\n <button type=\"button\" class=\"btn\" id=\"clear-filters\">Clear</button>\n </div>\n </section>\n\n <div class=\"controls\" style=\"margin-bottom: 14px;\">\n <button type=\"button\" class=\"btn\" id=\"copy-prompts\">Copy prompts</button>\n </div>\n\n <main>\n <section class=\"panel timeline-panel\" aria-label=\"Timeline\">\n <div class=\"stats\">\n <div class=\"big\"><span id=\"stats-showing\">0</span> <span class=\"muted\">showing</span> <span class=\"muted\">/</span> <span id=\"stats-total\">0</span> <span class=\"muted\">events</span></div>\n <div class=\"muted\" id=\"stats-range\">-</div>\n </div>\n <div id=\"timeline\" class=\"timeline\" role=\"list\" tabindex=\"0\" aria-label=\"Events list\"></div>\n </section>\n\n <aside class=\"panel detail\" aria-label=\"Details\">\n <div class=\"detail-head\">\n <div class=\"detail-title\" id=\"detail-title\">Select an event</div>\n <div class=\"detail-actions\">\n <button type=\"button\" class=\"btn\" id=\"copy-json\" disabled>Copy JSON</button>\n <button type=\"button\" class=\"btn\" id=\"copy-raw\" disabled>Copy raw line</button>\n <label class=\"check\" title=\"Truncate very long strings in the JSON preview\">\n <input type=\"checkbox\" id=\"truncate-strings\" checked />\n Truncate long strings\n </label>\n </div>\n </div>\n <div id=\"detail-images\" class=\"detail-images\" hidden aria-label=\"Event images\"></div>\n <div id=\"detail-pretty\" class=\"detail-pretty\" hidden aria-label=\"Formatted event view\"></div>\n <div class=\"json-toggle\">\n <span class=\"json-toggle-label\">Raw JSON</span>\n </div>\n <pre id=\"detail-json\" aria-label=\"Selected event JSON\" class=\"empty\">Load a file to get started.</pre>\n </aside>\n </main>\n </div>\n\n <div id=\"image-modal\" class=\"image-modal\" role=\"dialog\" aria-modal=\"true\" aria-hidden=\"true\">\n <div class=\"dialog\">\n <button type=\"button\" class=\"close\" id=\"image-modal-close\" aria-label=\"Close image\">Close</button>\n <img id=\"image-modal-img\" alt=\"\" />\n </div>\n </div>\n\n <script>\n const els = {\n dropzone: document.getElementById('dropzone'),\n fileInput: document.getElementById('file-input'),\n pasteInput: document.getElementById('paste-input'),\n renderPaste: document.getElementById('render-paste'),\n urlInput: document.getElementById('url-input'),\n fetchUrl: document.getElementById('fetch-url'),\n status: document.getElementById('status'),\n timeline: document.getElementById('timeline'),\n detailPanel: document.querySelector('aside.detail'),\n detailTitle: document.getElementById('detail-title'),\n detailImages: document.getElementById('detail-images'),\n detailPretty: document.getElementById('detail-pretty'),\n detailJson: document.getElementById('detail-json'),\n copyJson: document.getElementById('copy-json'),\n copyRaw: document.getElementById('copy-raw'),\n truncateStrings: document.getElementById('truncate-strings'),\n tzLocal: document.getElementById('tz-local'),\n tzUtc: document.getElementById('tz-utc'),\n search: document.getElementById('search'),\n typeFilter: document.getElementById('type-filter'),\n contentTypeFilter: document.getElementById('content-type-filter'),\n roleFilter: document.getElementById('role-filter'),\n hideThinking: document.getElementById('hide-thinking'),\n statsShowing: document.getElementById('stats-showing'),\n statsTotal: document.getElementById('stats-total'),\n statsRange: document.getElementById('stats-range'),\n clearFilters: document.getElementById('clear-filters'),\n imageModal: document.getElementById('image-modal'),\n imageModalImg: document.getElementById('image-modal-img'),\n imageModalClose: document.getElementById('image-modal-close'),\n copyPrompts: document.getElementById('copy-prompts'),\n };\n\n const state = {\n sourceLabel: null,\n tzMode: 'local',\n events: [],\n filtered: [],\n selectedId: null,\n selected: null,\n filters: {\n query: '',\n type: 'all',\n contentType: 'all',\n role: 'all',\n hideThinking: false,\n },\n };\n\n let lastActiveElementBeforeModal = null;\n let lastBodyOverflow = null;\n let suppressUrlStateSync = false;\n const wideLayoutMq = window.matchMedia('(min-width: 1060px)');\n const detailDock = document.createElement('div');\n detailDock.id = 'detail-dock';\n detailDock.style.display = 'none';\n els.detailPanel.parentElement.insertBefore(detailDock, els.detailPanel);\n\n function updateDetailPlacement() {\n const inline = !wideLayoutMq.matches;\n\n if (!inline) {\n if (els.detailPanel.previousSibling !== detailDock) detailDock.after(els.detailPanel);\n els.detailPanel.classList.remove('inline');\n return;\n }\n\n const selectedNode = els.timeline.querySelector('.event.selected');\n if (!selectedNode) {\n if (els.detailPanel.previousSibling !== detailDock) detailDock.after(els.detailPanel);\n els.detailPanel.classList.remove('inline');\n return;\n }\n\n if (selectedNode.nextSibling !== els.detailPanel) selectedNode.after(els.detailPanel);\n els.detailPanel.classList.add('inline');\n }\n\n function setStatus(message, kind = 'good') {\n if (!message) {\n els.status.className = 'status';\n els.status.textContent = '';\n return;\n }\n els.status.className = `status visible ${kind}`;\n els.status.textContent = message;\n }\n\n function pad2(number) {\n return String(number).padStart(2, '0');\n }\n\n function pad3(number) {\n return String(number).padStart(3, '0');\n }\n\n function getOffsetString(date) {\n const offsetMinutes = -date.getTimezoneOffset();\n const sign = offsetMinutes >= 0 ? '+' : '-';\n const abs = Math.abs(offsetMinutes);\n const hours = Math.floor(abs / 60);\n const minutes = abs % 60;\n return `${sign}${pad2(hours)}:${pad2(minutes)}`;\n }\n\n function formatTimestamp(date, tzMode) {\n if (!(date instanceof Date) || Number.isNaN(date.getTime())) return '-';\n if (tzMode === 'utc') {\n return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ` +\n `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}:${pad2(date.getUTCSeconds())}.` +\n `${pad3(date.getUTCMilliseconds())}Z`;\n }\n return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ` +\n `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}.` +\n `${pad3(date.getMilliseconds())} ${getOffsetString(date)}`;\n }\n\n function dayKey(date, tzMode) {\n if (!(date instanceof Date) || Number.isNaN(date.getTime())) return '-';\n if (tzMode === 'utc') {\n return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())}`;\n }\n return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;\n }\n\n function formatDelta(ms) {\n if (typeof ms !== 'number' || !Number.isFinite(ms)) return '';\n const abs = Math.abs(ms);\n const sign = ms < 0 ? '-' : '+';\n if (abs < 1000) return `${sign}${Math.round(abs)}ms`;\n if (abs < 60_000) return `${sign}${(abs / 1000).toFixed(abs < 10_000 ? 2 : 1)}s`;\n if (abs < 3_600_000) {\n const m = Math.floor(abs / 60_000);\n const s = Math.floor((abs % 60_000) / 1000);\n return `${sign}${m}m ${pad2(s)}s`;\n }\n if (abs < 86_400_000) {\n const h = Math.floor(abs / 3_600_000);\n const m = Math.floor((abs % 3_600_000) / 60_000);\n return `${sign}${h}h ${m}m`;\n }\n const d = Math.floor(abs / 86_400_000);\n const h = Math.floor((abs % 86_400_000) / 3_600_000);\n return `${sign}${d}d ${h}h`;\n }\n\n function safeJsonParse(text) {\n try {\n return { ok: true, value: JSON.parse(text) };\n } catch (error) {\n return { ok: false, error };\n }\n }\n\n function extractMessageText(message) {\n if (!message || typeof message !== 'object') return null;\n const content = message.content;\n if (typeof content === 'string') return content;\n if (!Array.isArray(content)) return null;\n const parts = [];\n for (const item of content) {\n if (!item || typeof item !== 'object') continue;\n if (typeof item.text === 'string') parts.push(item.text);\n else if (typeof item.thinking === 'string') parts.push(item.thinking);\n }\n const joined = parts.join('');\n return joined || null;\n }\n\n function getContentTypes(message) {\n if (!message || typeof message !== 'object') return [];\n const content = message.content;\n if (typeof content === 'string') return ['text'];\n if (!Array.isArray(content)) return [];\n const types = new Set();\n for (const item of content) {\n if (!item || typeof item !== 'object') continue;\n if (item.type) types.add(item.type);\n }\n return Array.from(types);\n }\n\n function firstLine(text) {\n if (typeof text !== 'string') return null;\n const trimmed = text.trim();\n if (!trimmed) return null;\n const idx = trimmed.indexOf('\\n');\n return idx === -1 ? trimmed : trimmed.slice(0, idx).trim();\n }\n\n function normalizeSummaryText(text, maxLen = 260) {\n if (typeof text !== 'string') return '';\n const singleLine = text.replace(/\\s+/g, ' ').trim();\n if (singleLine.length <= maxLen) return singleLine;\n return singleLine.slice(0, maxLen - 1) + '...';\n }\n\n function summarizeEvent(obj) {\n const kind = obj?.type || '';\n const message = obj?.message;\n\n if (kind === 'file-history-snapshot') {\n const msgId = obj?.messageId ? `msg=${obj.messageId.slice(0, 8)}...` : null;\n return normalizeSummaryText(msgId || 'file-history-snapshot');\n }\n\n if (kind === 'user' || kind === 'assistant') {\n const role = message?.role || kind;\n const content = message?.content;\n\n if (typeof content === 'string') {\n return normalizeSummaryText(`${role}: ${content}`);\n }\n\n if (Array.isArray(content) && content.length > 0) {\n for (const item of content) {\n if (item?.type === 'text' && item.text) {\n return normalizeSummaryText(`${role}: ${item.text}`);\n }\n if (item?.type === 'thinking' && item.thinking) {\n return normalizeSummaryText(`${role} (thinking): ${item.thinking}`);\n }\n if (item?.type === 'tool_use') {\n const name = item.name || 'tool';\n const input = item.input;\n if (name === 'Bash' && input?.command) {\n return normalizeSummaryText(`${name}: ${input.command}`);\n }\n if (name === 'Read' && input?.file_path) {\n return normalizeSummaryText(`${name}: ${input.file_path}`);\n }\n if (name === 'Write' && input?.file_path) {\n return normalizeSummaryText(`${name}: ${input.file_path}`);\n }\n if (name === 'Edit' && input?.file_path) {\n return normalizeSummaryText(`${name}: ${input.file_path}`);\n }\n if (name === 'Glob' && input?.pattern) {\n return normalizeSummaryText(`${name}: ${input.pattern}`);\n }\n if (name === 'Grep' && input?.pattern) {\n return normalizeSummaryText(`${name}: ${input.pattern}`);\n }\n if (name === 'Task' && input?.description) {\n return normalizeSummaryText(`${name}: ${input.description}`);\n }\n if (input && typeof input === 'object') {\n const keys = Object.keys(input).slice(0, 3);\n return normalizeSummaryText(`${name}(${keys.join(', ')}${Object.keys(input).length > 3 ? ', ...' : ''})`);\n }\n return normalizeSummaryText(`${name}()`);\n }\n if (item?.type === 'tool_result') {\n const toolUseId = item.tool_use_id ? item.tool_use_id.slice(0, 12) : 'result';\n const resultContent = item.content;\n if (typeof resultContent === 'string') {\n return normalizeSummaryText(`tool_result: ${firstLine(resultContent) || toolUseId}`);\n }\n if (Array.isArray(resultContent)) {\n for (const rc of resultContent) {\n if (rc?.type === 'text' && rc.text) {\n return normalizeSummaryText(`tool_result: ${firstLine(rc.text) || toolUseId}`);\n }\n if (rc?.type === 'image') {\n return normalizeSummaryText(`tool_result: [image]`);\n }\n }\n }\n return normalizeSummaryText(`tool_result: ${toolUseId}`);\n }\n if (item?.type === 'image') {\n return normalizeSummaryText(`${role}: [image]`);\n }\n }\n return normalizeSummaryText(`${role}: [${content.length} content items]`);\n }\n\n return kind;\n }\n\n return kind || 'event';\n }\n\n function buildSearchText(obj, summary) {\n const pieces = [];\n pieces.push(obj?.type || '');\n pieces.push(obj?.uuid || '');\n pieces.push(obj?.sessionId || '');\n pieces.push(obj?.cwd || '');\n pieces.push(obj?.gitBranch || '');\n pieces.push(obj?.slug || '');\n const message = obj?.message;\n if (message && typeof message === 'object') {\n pieces.push(message.role || '');\n const msg = extractMessageText(message);\n if (msg) pieces.push(msg);\n const content = message.content;\n if (Array.isArray(content)) {\n for (const item of content) {\n if (item?.type === 'tool_use') {\n pieces.push(item.name || '');\n if (item.input && typeof item.input === 'object') {\n pieces.push(JSON.stringify(item.input).slice(0, 500));\n }\n }\n if (item?.type === 'tool_result' && typeof item.content === 'string') {\n pieces.push(item.content.slice(0, 500));\n }\n }\n }\n }\n if (typeof summary === 'string') pieces.push(summary);\n return pieces.join('\\n').toLowerCase();\n }\n\n function stringifyJson(value, truncateLongStrings) {\n const maxLen = 8000;\n return JSON.stringify(value, (key, v) => {\n if (!truncateLongStrings) return v;\n if (typeof v === 'string' && v.length > maxLen) {\n return v.slice(0, maxLen) + `... [truncated ${v.length - maxLen} chars]`;\n }\n return v;\n }, 2);\n }\n\n function updateFilterOptions(events) {\n const typeSet = new Set();\n const contentTypeSet = new Set();\n const roleSet = new Set();\n for (const e of events) {\n if (e.kind) typeSet.add(e.kind);\n for (const ct of e.contentTypes) {\n contentTypeSet.add(ct);\n }\n if (!e.contentTypes.length) contentTypeSet.add('(none)');\n if (e.role) roleSet.add(e.role);\n }\n\n function setOptions(select, items, current) {\n select.innerHTML = '';\n const optAll = document.createElement('option');\n optAll.value = 'all';\n optAll.textContent = 'All';\n select.appendChild(optAll);\n for (const item of items) {\n const opt = document.createElement('option');\n opt.value = item;\n opt.textContent = item;\n select.appendChild(opt);\n }\n select.value = current;\n }\n\n setOptions(els.typeFilter, Array.from(typeSet).sort(), state.filters.type);\n setOptions(els.contentTypeFilter, Array.from(contentTypeSet).sort(), state.filters.contentType);\n setOptions(els.roleFilter, Array.from(roleSet).sort(), state.filters.role);\n\n if (events.length) {\n state.filters.type = els.typeFilter.value || 'all';\n state.filters.contentType = els.contentTypeFilter.value || 'all';\n state.filters.role = els.roleFilter.value || 'all';\n }\n }\n\n function readHashParams() {\n const raw = window.location.hash && window.location.hash.startsWith('#')\n ? window.location.hash.slice(1)\n : '';\n return new URLSearchParams(raw);\n }\n\n function writeHashParams(params) {\n const base = window.location.href.split('#')[0];\n const hash = params.toString();\n const next = base + (hash ? `#${hash}` : '');\n window.history.replaceState(null, '', next);\n }\n\n function syncUrlHashFromControls({ clearSel = false } = {}) {\n if (suppressUrlStateSync) return;\n const params = readHashParams();\n params.set('tz', state.tzMode);\n params.set('q', (els.search.value || '').trim());\n params.set('type', state.filters.type || 'all');\n params.set('ct', state.filters.contentType || 'all');\n params.set('role', state.filters.role || 'all');\n params.set('hide', state.filters.hideThinking ? '1' : '0');\n params.set('truncate', els.truncateStrings.checked ? '1' : '0');\n const selectedLine = state.selected?.line;\n if (typeof selectedLine === 'number' && Number.isFinite(selectedLine)) {\n params.set('sel', String(selectedLine));\n } else if (clearSel) {\n params.delete('sel');\n }\n writeHashParams(params);\n }\n\n function applySelectionFromHash() {\n if (!state.events.length) return;\n const params = readHashParams();\n const raw = params.get('sel');\n if (!raw) return;\n const line = Number(raw);\n if (!Number.isFinite(line)) return;\n const found = state.events.find(e => e.line === line);\n if (!found) return;\n if (state.selectedId === found.id) return;\n suppressUrlStateSync = true;\n try {\n selectEvent(found.id, { scroll: true });\n } finally {\n suppressUrlStateSync = false;\n }\n }\n\n function applyControlsFromHash() {\n const params = readHashParams();\n suppressUrlStateSync = true;\n try {\n const q = params.get('q');\n if (q !== null) {\n els.search.value = q;\n state.filters.query = q.trim().toLowerCase();\n }\n\n const type = params.get('type');\n if (type !== null) state.filters.type = type;\n const ct = params.get('ct');\n if (ct !== null) state.filters.contentType = ct;\n const role = params.get('role');\n if (role !== null) state.filters.role = role;\n\n const hide = params.get('hide');\n if (hide !== null) {\n els.hideThinking.checked = hide === '1' || hide === 'true';\n state.filters.hideThinking = els.hideThinking.checked;\n }\n\n const truncate = params.get('truncate');\n if (truncate !== null) {\n els.truncateStrings.checked = truncate === '1' || truncate === 'true';\n }\n\n const tz = params.get('tz');\n if (tz === 'utc' || tz === 'local') {\n state.tzMode = tz;\n els.tzLocal.classList.toggle('active', tz === 'local');\n els.tzUtc.classList.toggle('active', tz === 'utc');\n }\n } finally {\n suppressUrlStateSync = false;\n }\n }\n\n function applyFilters() {\n const query = state.filters.query;\n const type = state.filters.type;\n const contentType = state.filters.contentType;\n const role = state.filters.role;\n const hideThinking = state.filters.hideThinking;\n\n const out = [];\n for (const e of state.events) {\n if (hideThinking && e.contentTypes.includes('thinking') && e.contentTypes.length === 1) continue;\n if (type !== 'all' && e.kind !== type) continue;\n if (contentType !== 'all') {\n const ct = e.contentTypes.length ? e.contentTypes : ['(none)'];\n if (!ct.includes(contentType)) continue;\n }\n if (role !== 'all' && e.role !== role) continue;\n if (query && !e.search.includes(query)) continue;\n out.push(e);\n }\n\n state.filtered = out;\n els.statsShowing.textContent = String(out.length);\n els.statsTotal.textContent = String(state.events.length);\n\n const start = out.find(e => e.date instanceof Date && !Number.isNaN(e.date.getTime()))?.date;\n const end = [...out].reverse().find(e => e.date instanceof Date && !Number.isNaN(e.date.getTime()))?.date;\n if (start && end) {\n els.statsRange.textContent = `${formatTimestamp(start, state.tzMode)} -> ${formatTimestamp(end, state.tzMode)}`;\n } else {\n els.statsRange.textContent = '-';\n }\n\n renderTimeline();\n }\n\n function clearSelection({ syncHash = true } = {}) {\n state.selectedId = null;\n state.selected = null;\n els.detailTitle.textContent = 'Select an event';\n renderDetailImages(null);\n renderPrettyView(null);\n els.detailJson.textContent = state.events.length ? 'Select an event from the timeline.' : 'Load a file to get started.';\n els.detailJson.classList.add('empty');\n els.copyJson.disabled = true;\n els.copyRaw.disabled = true;\n for (const node of els.timeline.querySelectorAll('.event.selected')) {\n node.classList.remove('selected');\n }\n updateDetailPlacement();\n if (syncHash) syncUrlHashFromControls({ clearSel: true });\n }\n\n function renderTimeline() {\n els.timeline.innerHTML = '';\n\n if (!state.filtered.length) {\n const empty = document.createElement('div');\n empty.className = 'empty';\n empty.textContent = state.events.length ? 'No events match your filters.' : 'Load a file to see a timeline.';\n els.timeline.appendChild(empty);\n clearSelection();\n return;\n }\n\n const fragment = document.createDocumentFragment();\n let lastDay = null;\n let prevDate = null;\n\n for (const e of state.filtered) {\n const day = dayKey(e.date, state.tzMode);\n if (day !== lastDay) {\n lastDay = day;\n const sep = document.createElement('div');\n sep.className = 'day-sep';\n sep.textContent = day;\n fragment.appendChild(sep);\n }\n\n const row = document.createElement('div');\n row.className = 'event';\n row.setAttribute('role', 'listitem');\n row.dataset.eventId = String(e.id);\n\n const time = document.createElement('div');\n time.className = 'time';\n\n const ts = document.createElement('div');\n ts.className = 'ts';\n ts.textContent = formatTimestamp(e.date, state.tzMode);\n ts.dataset.kind = 'timestamp';\n\n const meta = document.createElement('div');\n meta.textContent = [\n `#${e.line}`,\n (prevDate && e.date) ? formatDelta(e.date.getTime() - prevDate.getTime()) : null,\n ].filter(Boolean).join(' . ');\n\n time.appendChild(ts);\n time.appendChild(meta);\n\n const body = document.createElement('div');\n\n const badges = document.createElement('div');\n badges.className = 'badges';\n const badgeKind = document.createElement('span');\n badgeKind.className = `badge k-${e.kind}`;\n badgeKind.textContent = e.kind;\n badges.appendChild(badgeKind);\n\n for (const ct of e.contentTypes.slice(0, 2)) {\n const badgeCt = document.createElement('span');\n badgeCt.className = `badge ct-${ct}`;\n badgeCt.textContent = ct;\n badges.appendChild(badgeCt);\n }\n if (e.contentTypes.length > 2) {\n const badgeMore = document.createElement('span');\n badgeMore.className = 'badge';\n badgeMore.textContent = `+${e.contentTypes.length - 2}`;\n badges.appendChild(badgeMore);\n }\n\n if (e.role) {\n const badgeRole = document.createElement('span');\n badgeRole.className = `badge role-${e.role}`;\n badgeRole.textContent = e.role;\n badges.appendChild(badgeRole);\n }\n\n const summary = document.createElement('div');\n summary.className = 'summary';\n summary.textContent = e.summary;\n\n body.appendChild(badges);\n body.appendChild(summary);\n\n row.appendChild(time);\n row.appendChild(body);\n\n row.addEventListener('click', () => selectEvent(e.id, { focusTimeline: true }));\n fragment.appendChild(row);\n\n if (e.date instanceof Date && !Number.isNaN(e.date.getTime())) prevDate = e.date;\n }\n\n els.timeline.appendChild(fragment);\n\n if (state.selectedId !== null) {\n const stillThere = state.filtered.some(e => e.id === state.selectedId);\n if (!stillThere) clearSelection();\n else selectEvent(state.selectedId, { scroll: false });\n } else {\n clearSelection({ syncHash: false });\n }\n }\n\n function selectEvent(id, { scroll = false, focusTimeline = false } = {}) {\n const e = state.filtered.find(item => item.id === id) || state.events.find(item => item.id === id);\n if (!e) return;\n state.selectedId = id;\n state.selected = e;\n\n if (focusTimeline) {\n els.timeline.focus({ preventScroll: true });\n }\n\n for (const node of els.timeline.querySelectorAll('.event.selected')) {\n node.classList.remove('selected');\n }\n const node = els.timeline.querySelector(`.event[data-event-id=\"${CSS.escape(String(id))}\"]`);\n if (node) {\n node.classList.add('selected');\n updateDetailPlacement();\n if (scroll) node.scrollIntoView({ block: 'center' });\n }\n\n els.detailJson.classList.remove('empty');\n const titleParts = [\n formatTimestamp(e.date, state.tzMode),\n `#${e.line}`,\n e.kind,\n e.role ? `(${e.role})` : null,\n ].filter(Boolean);\n els.detailTitle.textContent = titleParts.join(' ');\n\n const shouldTruncate = els.truncateStrings.checked;\n renderDetailImages(e.obj);\n renderPrettyView(e.obj);\n els.detailJson.textContent = stringifyJson(e.obj, shouldTruncate) || '';\n els.copyJson.disabled = false;\n els.copyRaw.disabled = false;\n updateDetailPlacement();\n syncUrlHashFromControls();\n }\n\n function extractImageUrls(obj) {\n const urls = [];\n const message = obj?.message;\n if (!message || typeof message !== 'object') return urls;\n const content = message.content;\n if (!Array.isArray(content)) return urls;\n\n for (const item of content) {\n if (!item || typeof item !== 'object') continue;\n\n // Direct image in content\n if (item.type === 'image' && item.source?.type === 'base64' && item.source?.data) {\n const mediaType = item.source.media_type || 'image/png';\n urls.push(`data:${mediaType};base64,${item.source.data}`);\n }\n\n // Image in tool_result content\n if (item.type === 'tool_result' && Array.isArray(item.content)) {\n for (const rc of item.content) {\n if (rc?.type === 'image' && rc.source?.type === 'base64' && rc.source?.data) {\n const mediaType = rc.source.media_type || 'image/png';\n urls.push(`data:${mediaType};base64,${rc.source.data}`);\n }\n }\n }\n }\n\n return urls;\n }\n\n function escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n\n // Initialize markdown-it with GFM-like settings\n const md = window.markdownit({\n html: false, // Disable raw HTML input\n linkify: true, // Auto-convert URLs to links\n typographer: true, // Smart quotes, dashes\n breaks: true // Convert \\n to <br>\n });\n\n function renderMarkdown(text) {\n if (typeof text !== 'string') return '';\n\n // Render markdown to HTML\n const html = md.render(text);\n\n // Sanitize with DOMPurify to prevent XSS\n return DOMPurify.sanitize(html);\n }\n\n function renderPrettyView(obj) {\n els.detailPretty.innerHTML = '';\n\n if (!obj || typeof obj !== 'object') {\n els.detailPretty.hidden = true;\n return;\n }\n\n const message = obj.message;\n if (!message || typeof message !== 'object') {\n els.detailPretty.hidden = true;\n return;\n }\n\n const content = message.content;\n const fragment = document.createDocumentFragment();\n let hasContent = false;\n\n // Handle string content (simple text message)\n if (typeof content === 'string') {\n const section = document.createElement('div');\n section.className = 'pretty-section';\n const label = document.createElement('div');\n label.className = 'pretty-label';\n label.textContent = message.role || 'Message';\n const body = document.createElement('div');\n body.className = 'pretty-content';\n body.innerHTML = renderMarkdown(content);\n section.appendChild(label);\n section.appendChild(body);\n fragment.appendChild(section);\n hasContent = true;\n }\n\n // Handle array content\n if (Array.isArray(content)) {\n for (const item of content) {\n if (!item || typeof item !== 'object') continue;\n\n // Text content\n if (item.type === 'text' && item.text) {\n const section = document.createElement('div');\n section.className = 'pretty-section';\n const label = document.createElement('div');\n label.className = 'pretty-label';\n label.textContent = message.role === 'assistant' ? 'Response' : 'Message';\n const body = document.createElement('div');\n body.className = 'pretty-content';\n body.innerHTML = renderMarkdown(item.text);\n section.appendChild(label);\n section.appendChild(body);\n fragment.appendChild(section);\n hasContent = true;\n }\n\n // Thinking content\n if (item.type === 'thinking' && item.thinking) {\n const section = document.createElement('div');\n section.className = 'pretty-section';\n const label = document.createElement('div');\n label.className = 'pretty-label';\n label.textContent = 'Thinking';\n const body = document.createElement('div');\n body.className = 'thinking-block pretty-content';\n body.innerHTML = renderMarkdown(item.thinking);\n section.appendChild(label);\n section.appendChild(body);\n fragment.appendChild(section);\n hasContent = true;\n }\n\n // Tool use\n if (item.type === 'tool_use') {\n const section = document.createElement('div');\n section.className = 'pretty-section';\n const label = document.createElement('div');\n label.className = 'pretty-label';\n label.textContent = 'Tool Call';\n\n const toolName = item.name || 'Unknown Tool';\n const input = item.input || {};\n\n // Special handling for Bash command\n if (toolName === 'Bash' && input.command) {\n const block = document.createElement('div');\n block.className = 'command-block';\n\n const header = document.createElement('div');\n header.className = 'command-header';\n const nameSpan = document.createElement('span');\n nameSpan.className = 'tool-name';\n nameSpan.textContent = toolName;\n header.appendChild(nameSpan);\n if (input.description) {\n const descSpan = document.createElement('span');\n descSpan.className = 'tool-desc';\n descSpan.textContent = input.description;\n header.appendChild(descSpan);\n }\n\n const cmdInput = document.createElement('div');\n cmdInput.className = 'command-input';\n cmdInput.textContent = input.command;\n\n block.appendChild(header);\n block.appendChild(cmdInput);\n section.appendChild(label);\n section.appendChild(block);\n }\n // Special handling for file operations\n else if (['Read', 'Write', 'Edit', 'Glob', 'Grep'].includes(toolName)) {\n const block = document.createElement('div');\n block.className = 'command-block';\n\n const header = document.createElement('div');\n header.className = 'command-header';\n const nameSpan = document.createElement('span');\n nameSpan.className = 'tool-name';\n nameSpan.textContent = toolName;\n header.appendChild(nameSpan);\n\n block.appendChild(header);\n\n const params = document.createElement('div');\n params.className = 'tool-params';\n params.style.padding = '10px 12px';\n\n for (const [key, value] of Object.entries(input)) {\n const param = document.createElement('div');\n param.className = 'tool-param';\n const paramName = document.createElement('span');\n paramName.className = 'tool-param-name';\n paramName.textContent = key;\n const paramValue = document.createElement('span');\n paramValue.className = 'tool-param-value';\n if (typeof value === 'string') {\n paramValue.textContent = value.length > 500 ? value.slice(0, 500) + '...' : value;\n } else {\n paramValue.textContent = JSON.stringify(value, null, 2);\n }\n param.appendChild(paramName);\n param.appendChild(paramValue);\n params.appendChild(param);\n }\n\n block.appendChild(params);\n section.appendChild(label);\n section.appendChild(block);\n }\n // Generic tool display\n else {\n const block = document.createElement('div');\n block.className = 'command-block';\n\n const header = document.createElement('div');\n header.className = 'command-header';\n const nameSpan = document.createElement('span');\n nameSpan.className = 'tool-name';\n nameSpan.textContent = toolName;\n header.appendChild(nameSpan);\n\n block.appendChild(header);\n\n if (Object.keys(input).length > 0) {\n const params = document.createElement('div');\n params.className = 'tool-params';\n params.style.padding = '10px 12px';\n\n for (const [key, value] of Object.entries(input)) {\n const param = document.createElement('div');\n param.className = 'tool-param';\n const paramName = document.createElement('span');\n paramName.className = 'tool-param-name';\n paramName.textContent = key;\n const paramValue = document.createElement('span');\n paramValue.className = 'tool-param-value';\n if (typeof value === 'string') {\n paramValue.textContent = value.length > 1000 ? value.slice(0, 1000) + '...' : value;\n } else {\n paramValue.textContent = JSON.stringify(value, null, 2);\n }\n param.appendChild(paramName);\n param.appendChild(paramValue);\n params.appendChild(param);\n }\n\n block.appendChild(params);\n }\n\n section.appendChild(label);\n section.appendChild(block);\n }\n\n fragment.appendChild(section);\n hasContent = true;\n }\n\n // Tool result\n if (item.type === 'tool_result') {\n const section = document.createElement('div');\n section.className = 'pretty-section';\n const label = document.createElement('div');\n label.className = 'pretty-label';\n label.textContent = item.is_error ? 'Tool Error' : 'Tool Result';\n\n const resultContent = item.content;\n let resultText = '';\n\n if (typeof resultContent === 'string') {\n resultText = resultContent;\n } else if (Array.isArray(resultContent)) {\n for (const rc of resultContent) {\n if (rc?.type === 'text' && rc.text) {\n resultText += rc.text;\n }\n }\n }\n\n if (resultText) {\n const block = document.createElement('div');\n block.className = 'pretty-content' + (item.is_error ? ' result-error' : '');\n const displayText = resultText.length > 5000 ? resultText.slice(0, 5000) + '\\n... [truncated]' : resultText;\n block.innerHTML = renderMarkdown(displayText);\n section.appendChild(label);\n section.appendChild(block);\n fragment.appendChild(section);\n hasContent = true;\n }\n }\n }\n }\n\n if (hasContent) {\n els.detailPretty.appendChild(fragment);\n els.detailPretty.hidden = false;\n } else {\n els.detailPretty.hidden = true;\n }\n }\n\n function openImageModal(src, altText = '') {\n if (!src) return;\n lastActiveElementBeforeModal = document.activeElement instanceof HTMLElement ? document.activeElement : null;\n lastBodyOverflow = document.body.style.overflow;\n document.body.style.overflow = 'hidden';\n\n els.imageModalImg.src = src;\n els.imageModalImg.alt = altText;\n els.imageModal.classList.add('open');\n els.imageModal.setAttribute('aria-hidden', 'false');\n els.imageModalClose.focus();\n }\n\n function closeImageModal() {\n if (!els.imageModal.classList.contains('open')) return;\n els.imageModal.classList.remove('open');\n els.imageModal.setAttribute('aria-hidden', 'true');\n els.imageModalImg.removeAttribute('src');\n els.imageModalImg.alt = '';\n document.body.style.overflow = lastBodyOverflow ?? '';\n\n if (lastActiveElementBeforeModal) {\n lastActiveElementBeforeModal.focus();\n }\n lastActiveElementBeforeModal = null;\n lastBodyOverflow = null;\n }\n\n function renderDetailImages(obj) {\n const urls = extractImageUrls(obj);\n els.detailImages.innerHTML = '';\n if (!urls.length) {\n els.detailImages.hidden = true;\n return;\n }\n const fragment = document.createDocumentFragment();\n urls.forEach((src, idx) => {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'thumb';\n btn.title = `Open image ${idx + 1}`;\n const img = document.createElement('img');\n img.loading = 'lazy';\n img.src = src;\n img.alt = `Image ${idx + 1}`;\n btn.appendChild(img);\n btn.addEventListener('click', () => openImageModal(src, `Image ${idx + 1}`));\n fragment.appendChild(btn);\n });\n els.detailImages.appendChild(fragment);\n els.detailImages.hidden = false;\n }\n\n function isEditableElement(el) {\n if (!(el instanceof HTMLElement)) return false;\n if (el.isContentEditable) return true;\n const tag = el.tagName;\n return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';\n }\n\n function moveSelection(delta) {\n if (!state.filtered.length) return;\n const idx = state.filtered.findIndex(e => e.id === state.selectedId);\n if (idx === -1) {\n selectEvent(state.filtered[0].id, { scroll: true });\n return;\n }\n const nextIdx = Math.max(0, Math.min(state.filtered.length - 1, idx + delta));\n if (nextIdx === idx) return;\n selectEvent(state.filtered[nextIdx].id, { scroll: true });\n }\n\n async function copyText(text) {\n if (typeof text !== 'string') return;\n try {\n await navigator.clipboard.writeText(text);\n setStatus('Copied to clipboard.', 'good');\n } catch (error) {\n const ta = document.createElement('textarea');\n ta.value = text;\n ta.style.position = 'fixed';\n ta.style.left = '-9999px';\n document.body.appendChild(ta);\n ta.select();\n document.execCommand('copy');\n document.body.removeChild(ta);\n setStatus('Copied to clipboard.', 'good');\n }\n }\n\n function extractUserPrompts() {\n const prompts = [];\n for (const e of state.events) {\n // Filter: type=user, content-type includes text, role=user\n if (e.kind !== 'user') continue;\n if (e.role !== 'user') continue;\n if (!e.contentTypes.includes('text')) continue;\n\n const message = e.obj?.message;\n if (!message) continue;\n\n const content = message.content;\n if (typeof content === 'string') {\n prompts.push(content);\n } else if (Array.isArray(content)) {\n for (const item of content) {\n if (item?.type === 'text' && item.text) {\n prompts.push(item.text);\n }\n }\n }\n }\n return prompts.join('\\n\\n');\n }\n\n async function copyUserPrompts() {\n const text = extractUserPrompts();\n if (!text) {\n setStatus('No user prompts found.', 'warn');\n return;\n }\n await copyText(text);\n const originalText = els.copyPrompts.textContent;\n els.copyPrompts.textContent = 'Copied!';\n setTimeout(() => {\n els.copyPrompts.textContent = originalText;\n }, 2000);\n }\n\n function setTimezoneMode(mode) {\n state.tzMode = mode;\n els.tzLocal.classList.toggle('active', mode === 'local');\n els.tzUtc.classList.toggle('active', mode === 'utc');\n applyFilters();\n if (state.selectedId !== null) selectEvent(state.selectedId);\n syncUrlHashFromControls();\n }\n\n els.imageModalClose.addEventListener('click', closeImageModal);\n els.imageModal.addEventListener('click', (event) => {\n if (event.target === els.imageModal) closeImageModal();\n });\n document.addEventListener('keydown', (event) => {\n if (event.key === 'Escape') closeImageModal();\n });\n\n document.addEventListener('keydown', (event) => {\n if (els.imageModal.classList.contains('open')) return;\n if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;\n if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;\n if (isEditableElement(document.activeElement)) return;\n if (document.activeElement !== els.timeline && !els.timeline.contains(document.activeElement)) return;\n if (state.selectedId === null) return;\n event.preventDefault();\n moveSelection(event.key === 'ArrowUp' ? -1 : 1);\n });\n\n if (typeof wideLayoutMq.addEventListener === 'function') {\n wideLayoutMq.addEventListener('change', updateDetailPlacement);\n } else if (typeof wideLayoutMq.addListener === 'function') {\n wideLayoutMq.addListener(updateDetailPlacement);\n }\n\n function setTab(tabName) {\n for (const tab of document.querySelectorAll('.tab')) {\n const active = tab.dataset.tab === tabName;\n tab.classList.toggle('active', active);\n tab.setAttribute('aria-selected', active ? 'true' : 'false');\n }\n for (const panel of document.querySelectorAll('[data-panel]')) {\n panel.hidden = panel.dataset.panel !== tabName;\n }\n }\n\n function setShareUrlParam(url) {\n const current = new URL(window.location.href);\n if (url) current.searchParams.set('url', url);\n else current.searchParams.delete('url');\n window.history.replaceState(null, '', current);\n }\n\n async function fetchAndRenderUrl(url, { syncUrl = true } = {}) {\n const trimmed = (url || '').trim();\n if (!trimmed) {\n setStatus('Enter a URL first.', 'error');\n return;\n }\n if (syncUrl) setShareUrlParam(trimmed);\n setStatus(`Fetching ${trimmed}...`, 'warn');\n try {\n const resp = await fetch(trimmed, { mode: 'cors' });\n if (!resp.ok) throw new Error(`HTTP ${resp.status}`);\n const text = await resp.text();\n await loadText(text, trimmed);\n } catch (error) {\n setStatus(`Failed to fetch URL: ${String(error)}`, 'error');\n }\n }\n\n async function loadText(text, sourceLabel) {\n const started = performance.now();\n state.sourceLabel = sourceLabel || null;\n setStatus(`Parsing ${sourceLabel || 'content'}...`, 'warn');\n clearSelection({ syncHash: false });\n\n const lines = text.split(/\\r?\\n/);\n const events = [];\n const errors = [];\n let idCounter = 0;\n\n for (let i = 0; i < lines.length; i++) {\n const line = lines[i];\n if (!line.trim()) continue;\n const parsed = safeJsonParse(line);\n if (!parsed.ok) {\n errors.push({ line: i + 1, error: parsed.error });\n continue;\n }\n const obj = parsed.value;\n const ts = typeof obj.timestamp === 'string' ? new Date(obj.timestamp) : null;\n const kind = obj.type || '(unknown)';\n const message = obj.message && typeof obj.message === 'object' ? obj.message : null;\n const contentTypes = message ? getContentTypes(message) : [];\n const role = message?.role ?? null;\n\n const summary = summarizeEvent(obj);\n events.push({\n id: ++idCounter,\n line: i + 1,\n raw: line,\n obj,\n date: ts,\n kind,\n contentTypes,\n role,\n summary,\n search: buildSearchText(obj, summary),\n });\n }\n\n state.events = events;\n updateFilterOptions(events);\n applyFilters();\n applySelectionFromHash();\n syncUrlHashFromControls();\n\n const durationMs = Math.round(performance.now() - started);\n if (errors.length) {\n const first = errors[0];\n setStatus(\n `Loaded ${events.length} events from ${sourceLabel || 'content'} in ${durationMs}ms.\\n` +\n `Could not parse ${errors.length} line(s). First error at line ${first.line}: ${String(first.error)}`,\n 'warn'\n );\n } else {\n setStatus(`Loaded ${events.length} events from ${sourceLabel || 'content'} in ${durationMs}ms.`, 'good');\n }\n }\n\n async function handleFile(file) {\n if (!file) return;\n setShareUrlParam(null);\n try {\n const text = await file.text();\n await loadText(text, file.name);\n } catch (error) {\n setStatus(`Failed to read file: ${String(error)}`, 'error');\n }\n }\n\n els.dropzone.addEventListener('click', () => els.fileInput.click());\n els.dropzone.addEventListener('keydown', (e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n els.fileInput.click();\n }\n });\n els.fileInput.addEventListener('change', () => handleFile(els.fileInput.files?.[0]));\n\n els.dropzone.addEventListener('dragover', (e) => {\n e.preventDefault();\n els.dropzone.classList.add('dragover');\n });\n els.dropzone.addEventListener('dragleave', () => els.dropzone.classList.remove('dragover'));\n els.dropzone.addEventListener('drop', (e) => {\n e.preventDefault();\n els.dropzone.classList.remove('dragover');\n const file = e.dataTransfer?.files?.[0];\n if (file) handleFile(file);\n });\n\n els.renderPaste.addEventListener('click', async () => {\n const text = els.pasteInput.value || '';\n if (!text.trim()) {\n setStatus('Paste some JSONL content first.', 'error');\n return;\n }\n setShareUrlParam(null);\n await loadText(text, 'pasted content');\n });\n\n els.fetchUrl.addEventListener('click', async () => {\n await fetchAndRenderUrl(els.urlInput.value, { syncUrl: true });\n });\n\n for (const tab of document.querySelectorAll('.tab')) {\n tab.addEventListener('click', () => setTab(tab.dataset.tab));\n }\n\n els.tzLocal.addEventListener('click', () => setTimezoneMode('local'));\n els.tzUtc.addEventListener('click', () => setTimezoneMode('utc'));\n\n els.search.addEventListener('input', () => {\n state.filters.query = els.search.value.trim().toLowerCase();\n applyFilters();\n syncUrlHashFromControls();\n });\n els.typeFilter.addEventListener('change', () => {\n state.filters.type = els.typeFilter.value;\n applyFilters();\n syncUrlHashFromControls();\n });\n els.contentTypeFilter.addEventListener('change', () => {\n state.filters.contentType = els.contentTypeFilter.value;\n applyFilters();\n syncUrlHashFromControls();\n });\n els.roleFilter.addEventListener('change', () => {\n state.filters.role = els.roleFilter.value;\n applyFilters();\n syncUrlHashFromControls();\n });\n els.hideThinking.addEventListener('change', () => {\n state.filters.hideThinking = els.hideThinking.checked;\n applyFilters();\n syncUrlHashFromControls();\n });\n els.truncateStrings.addEventListener('change', () => {\n if (state.selectedId !== null) selectEvent(state.selectedId);\n syncUrlHashFromControls();\n });\n\n els.clearFilters.addEventListener('click', () => {\n els.search.value = '';\n state.filters.query = '';\n state.filters.type = 'all';\n state.filters.contentType = 'all';\n state.filters.role = 'all';\n state.filters.hideThinking = false;\n els.hideThinking.checked = false;\n updateFilterOptions(state.events);\n applyFilters();\n syncUrlHashFromControls();\n });\n\n els.copyJson.addEventListener('click', () => {\n if (!state.selected) return;\n copyText(stringifyJson(state.selected.obj, false));\n });\n els.copyRaw.addEventListener('click', () => {\n if (!state.selected) return;\n copyText(state.selected.raw);\n });\n els.copyPrompts.addEventListener('click', copyUserPrompts);\n\n async function initFromQuery() {\n const url = new URLSearchParams(window.location.search).get('url');\n if (!url) return;\n setTab('url');\n els.urlInput.value = url;\n await fetchAndRenderUrl(url, { syncUrl: false });\n }\n\n // Initialize select options for the empty state.\n applyControlsFromHash();\n updateFilterOptions([]);\n syncUrlHashFromControls();\n window.addEventListener('hashchange', () => {\n applyControlsFromHash();\n updateFilterOptions(state.events);\n applyFilters();\n applySelectionFromHash();\n });\n void initFromQuery();\n </script>\n</body>\n</html>\n","numLines":2319,"startLine":1,"totalLines":2319}}}
{"parentUuid":"ebb0e3e4-cb8d-4395-9240-b5762c279458","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","slug":"glittery-skipping-rivest","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_019PPh2LPbQgLZweW6CQGYN1","type":"tool_result","content":" 1→<!DOCTYPE html>\n 2→<html lang=\"en\">\n 3→<head>\n 4→ <meta charset=\"UTF-8\" />\n 5→ <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n 6→ <title>Codex Timeline Viewer</title>\n 7→ <script src=\"https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js\" integrity=\"sha384-osZDKVu4ipZP703HmPOhWdyBajcFyjX2Psjk//TG1Rc0AdwEtuToaylrmcK3LdAl\" crossorigin=\"anonymous\"></script>\n 8→ <script src=\"https://cdnjs.cloudflare.com/ajax/libs/markdown-it/4.4.0/markdown-it.min.js\" integrity=\"sha384-cxGp+QDeMxACP73aA0Lj0Sc/h4z1X73+umcvCyI0Vbyh3OYxgrPI+0mxG9slsMTN\" crossorigin=\"anonymous\"></script>\n 9→ <style>\n 10→ :root {\n 11→ color-scheme: light dark;\n 12→ --bg: #0b0c0f;\n 13→ --panel: #12141a;\n 14→ --panel-2: #0f1116;\n 15→ --text: #e8eaf0;\n 16→ --muted: #a6adbf;\n 17→ --line: rgba(255, 255, 255, 0.10);\n 18→ --line-2: rgba(255, 255, 255, 0.16);\n 19→ --accent: #6aa9ff;\n 20→ --accent-2: #8a5cff;\n 21→ --good: #46d39a;\n 22→ --warn: #ffcc66;\n 23→ --bad: #ff6b6b;\n 24→ --shadow: 0 18px 50px rgba(0, 0, 0, 0.35);\n 25→ --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\",\n 26→ \"Courier New\", monospace;\n 27→ --sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial,\n 28→ \"Apple Color Emoji\", \"Segoe UI Emoji\";\n 29→ }\n 30→\n 31→ @media (prefers-color-scheme: light) {\n 32→ :root {\n 33→ --bg: #f7f8fc;\n 34→ --panel: #ffffff;\n 35→ --panel-2: #ffffff;\n 36→ --text: #10121a;\n 37→ --muted: #5b647a;\n 38→ --line: rgba(16, 18, 26, 0.12);\n 39→ --line-2: rgba(16, 18, 26, 0.18);\n 40→ --shadow: 0 18px 50px rgba(16, 18, 26, 0.10);\n 41→ }\n 42→ }\n 43→\n 44→ * { box-sizing: border-box; }\n 45→ html, body { height: 100%; }\n 46→ body {\n 47→ margin: 0;\n 48→ font-family: var(--sans);\n 49→ background: radial-gradient(1200px 600px at 10% -10%, rgba(106, 169, 255, 0.18), transparent 45%),\n 50→ radial-gradient(1200px 600px at 90% -20%, rgba(138, 92, 255, 0.16), transparent 50%),\n 51→ var(--bg);\n 52→ color: var(--text);\n 53→ }\n 54→\n 55→ .wrap {\n 56→ max-width: none;\n 57→ margin: 0;\n 58→ padding: 22px 16px 42px;\n 59→ }\n 60→\n 61→ header {\n 62→ display: grid;\n 63→ gap: 10px;\n 64→ margin-bottom: 14px;\n 65→ }\n 66→\n 67→ h1 {\n 68→ margin: 0;\n 69→ font-size: clamp(22px, 3vw, 32px);\n 70→ letter-spacing: 0.2px;\n 71→ }\n 72→\n 73→ .sub {\n 74→ margin: 6px 0 0;\n 75→ color: var(--muted);\n 76→ line-height: 1.35;\n 77→ max-width: 70ch;\n 78→ }\n 79→\n 80→ .controls {\n 81→ display: flex;\n 82→ flex-wrap: wrap;\n 83→ gap: 10px;\n 84→ align-items: center;\n 85→ justify-content: flex-start;\n 86→ }\n 87→\n 88→ .controls .field {\n 89→ display: grid;\n 90→ gap: 4px;\n 91→ min-width: 180px;\n 92→ }\n 93→\n 94→ .label {\n 95→ font-size: 12px;\n 96→ color: var(--muted);\n 97→ }\n 98→\n 99→ input[type=\"search\"], input[type=\"url\"], select, textarea {\n 100→ font: inherit;\n 101→ color: inherit;\n 102→ border: 1px solid var(--line);\n 103→ background: color-mix(in srgb, var(--panel) 92%, transparent);\n 104→ border-radius: 10px;\n 105→ padding: 10px 11px;\n 106→ outline: none;\n 107→ }\n 108→\n 109→ input[type=\"search\"]:focus, input[type=\"url\"]:focus, select:focus, textarea:focus {\n 110→ border-color: color-mix(in srgb, var(--accent) 60%, var(--line));\n 111→ box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);\n 112→ }\n 113→\n 114→ .segmented {\n 115→ display: inline-flex;\n 116→ border: 1px solid var(--line);\n 117→ border-radius: 999px;\n 118→ overflow: hidden;\n 119→ background: color-mix(in srgb, var(--panel) 92%, transparent);\n 120→ }\n 121→\n 122→ .segmented button {\n 123→ border: none;\n 124→ padding: 9px 12px;\n 125→ font: inherit;\n 126→ background: transparent;\n 127→ color: var(--muted);\n 128→ cursor: pointer;\n 129→ min-width: 74px;\n 130→ }\n 131→\n 132→ .segmented button.active {\n 133→ background: color-mix(in srgb, var(--accent) 16%, transparent);\n 134→ color: var(--text);\n 135→ }\n 136→\n 137→ .segmented button:focus-visible {\n 138→ outline: 2px solid color-mix(in srgb, var(--accent) 70%, transparent);\n 139→ outline-offset: -2px;\n 140→ }\n 141→\n 142→ .btn {\n 143→ border: 1px solid var(--line);\n 144→ background: color-mix(in srgb, var(--panel) 92%, transparent);\n 145→ color: var(--text);\n 146→ border-radius: 10px;\n 147→ padding: 10px 12px;\n 148→ font: inherit;\n 149→ cursor: pointer;\n 150→ transition: transform 80ms ease, border-color 120ms ease;\n 151→ }\n 152→\n 153→ .btn:hover { border-color: var(--line-2); }\n 154→ .btn:active { transform: translateY(1px); }\n 155→ .btn.primary {\n 156→ border-color: color-mix(in srgb, var(--accent) 55%, var(--line));\n 157→ background: color-mix(in srgb, var(--accent) 14%, transparent);\n 158→ }\n 159→\n 160→ .btn:disabled {\n 161→ opacity: 0.55;\n 162→ cursor: not-allowed;\n 163→ }\n 164→\n 165→ .panel {\n 166→ background: var(--panel);\n 167→ border: 1px solid var(--line);\n 168→ border-radius: 16px;\n 169→ box-shadow: var(--shadow);\n 170→ }\n 171→\n 172→ .input-panel {\n 173→ padding: 14px;\n 174→ margin-bottom: 14px;\n 175→ }\n 176→\n 177→ .filters-panel {\n 178→ padding: 14px;\n 179→ margin-bottom: 14px;\n 180→ }\n 181→\n 182→ .input-grid {\n 183→ display: grid;\n 184→ grid-template-columns: 1fr;\n 185→ gap: 14px;\n 186→ align-items: stretch;\n 187→ }\n 188→\n 189→ @media (min-width: 920px) {\n 190→ .input-grid {\n 191→ grid-template-columns: 380px 1fr;\n 192→ }\n 193→ }\n 194→\n 195→ .dropzone {\n 196→ border: 1px dashed color-mix(in srgb, var(--accent) 40%, var(--line));\n 197→ border-radius: 14px;\n 198→ background: linear-gradient(\n 199→ 135deg,\n 200→ color-mix(in srgb, var(--accent) 12%, transparent),\n 201→ transparent 45%,\n 202→ color-mix(in srgb, var(--accent-2) 10%, transparent)\n 203→ );\n 204→ padding: 16px;\n 205→ display: grid;\n 206→ gap: 6px;\n 207→ cursor: pointer;\n 208→ min-height: 124px;\n 209→ align-content: center;\n 210→ }\n 211→\n 212→ .dropzone.dragover {\n 213→ border-style: solid;\n 214→ border-color: color-mix(in srgb, var(--accent) 70%, var(--line));\n 215→ box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);\n 216→ }\n 217→\n 218→ .dropzone strong { font-size: 15px; }\n 219→ .muted { color: var(--muted); }\n 220→ .tiny { font-size: 12px; color: var(--muted); }\n 221→\n 222→ .tabs {\n 223→ display: flex;\n 224→ gap: 8px;\n 225→ margin-bottom: 10px;\n 226→ flex-wrap: wrap;\n 227→ align-items: center;\n 228→ }\n 229→\n 230→ .tab {\n 231→ border: 1px solid var(--line);\n 232→ background: color-mix(in srgb, var(--panel-2) 94%, transparent);\n 233→ color: var(--muted);\n 234→ border-radius: 999px;\n 235→ padding: 7px 10px;\n 236→ cursor: pointer;\n 237→ font: inherit;\n 238→ }\n 239→\n 240→ .tab.active {\n 241→ color: var(--text);\n 242→ border-color: color-mix(in srgb, var(--accent) 55%, var(--line));\n 243→ background: color-mix(in srgb, var(--accent) 12%, transparent);\n 244→ }\n 245→\n 246→ textarea {\n 247→ width: 100%;\n 248→ min-height: 190px;\n 249→ resize: vertical;\n 250→ font-family: var(--mono);\n 251→ font-size: 12px;\n 252→ line-height: 1.4;\n 253→ }\n 254→\n 255→ .row {\n 256→ display: flex;\n 257→ gap: 10px;\n 258→ flex-wrap: wrap;\n 259→ align-items: center;\n 260→ margin-top: 10px;\n 261→ }\n 262→\n 263→ .status {\n 264→ margin-top: 12px;\n 265→ padding: 10px 12px;\n 266→ border-radius: 12px;\n 267→ border: 1px solid var(--line);\n 268→ background: color-mix(in srgb, var(--panel-2) 94%, transparent);\n 269→ display: none;\n 270→ white-space: pre-wrap;\n 271→ word-break: break-word;\n 272→ }\n 273→ .status.visible { display: block; }\n 274→ .status.error { border-color: color-mix(in srgb, var(--bad) 60%, var(--line)); }\n 275→ .status.good { border-color: color-mix(in srgb, var(--good) 55%, var(--line)); }\n 276→ .status.warn { border-color: color-mix(in srgb, var(--warn) 55%, var(--line)); }\n 277→\n 278→ main {\n 279→ display: grid;\n 280→ gap: 14px;\n 281→ grid-template-columns: 1fr;\n 282→ min-height: 560px;\n 283→ }\n 284→\n 285→ @media (min-width: 1060px) {\n 286→ main {\n 287→ grid-template-columns: 1fr 520px;\n 288→ align-items: stretch;\n 289→ }\n 290→ }\n 291→\n 292→ .timeline-panel {\n 293→ display: grid;\n 294→ grid-template-rows: auto 1fr;\n 295→ min-height: 560px;\n 296→ }\n 297→\n 298→ .stats {\n 299→ display: flex;\n 300→ gap: 12px;\n 301→ flex-wrap: wrap;\n 302→ align-items: baseline;\n 303→ justify-content: space-between;\n 304→ padding: 12px 14px;\n 305→ border-bottom: 1px solid var(--line);\n 306→ }\n 307→\n 308→ .stats .big {\n 309→ font-size: 14px;\n 310→ font-weight: 600;\n 311→ }\n 312→\n 313→ .timeline {\n 314→ overflow: auto;\n 315→ padding: 8px 0;\n 316→ }\n 317→\n 318→ .timeline:focus-visible {\n 319→ outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent);\n 320→ outline-offset: -2px;\n 321→ border-radius: 14px;\n 322→ }\n 323→\n 324→ .day-sep {\n 325→ position: sticky;\n 326→ top: 0;\n 327→ z-index: 2;\n 328→ padding: 8px 14px;\n 329→ background: color-mix(in srgb, var(--panel) 92%, transparent);\n 330→ border-top: 1px solid var(--line);\n 331→ border-bottom: 1px solid var(--line);\n 332→ font-size: 12px;\n 333→ color: var(--muted);\n 334→ backdrop-filter: blur(6px);\n 335→ }\n 336→\n 337→ .event {\n 338→ display: grid;\n 339→ grid-template-columns: 176px 1fr;\n 340→ gap: 12px;\n 341→ padding: 10px 14px;\n 342→ border-bottom: 1px solid color-mix(in srgb, var(--line) 85%, transparent);\n 343→ cursor: pointer;\n 344→ }\n 345→\n 346→ .event:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); }\n 347→ .event.selected {\n 348→ background: color-mix(in srgb, var(--accent) 12%, transparent);\n 349→ outline: 2px solid color-mix(in srgb, var(--accent) 35%, transparent);\n 350→ outline-offset: -2px;\n 351→ }\n 352→\n 353→ .time {\n 354→ font-family: var(--mono);\n 355→ font-size: 12px;\n 356→ line-height: 1.35;\n 357→ color: var(--muted);\n 358→ display: grid;\n 359→ gap: 3px;\n 360→ }\n 361→\n 362→ .time .ts {\n 363→ color: var(--text);\n 364→ font-weight: 600;\n 365→ }\n 366→\n 367→ .badges {\n 368→ display: flex;\n 369→ gap: 6px;\n 370→ flex-wrap: wrap;\n 371→ align-items: center;\n 372→ margin-bottom: 6px;\n 373→ }\n 374→\n 375→ .badge {\n 376→ font-size: 11px;\n 377→ font-family: var(--mono);\n 378→ padding: 2px 7px;\n 379→ border-radius: 999px;\n 380→ border: 1px solid var(--line);\n 381→ color: var(--muted);\n 382→ background: color-mix(in srgb, var(--panel-2) 90%, transparent);\n 383→ }\n 384→\n 385→ .badge.k-event_msg { border-color: color-mix(in srgb, #8690a8 55%, var(--line)); color: color-mix(in srgb, #8690a8 70%, var(--text)); }\n 386→ .badge.k-response_item { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); color: color-mix(in srgb, var(--accent) 75%, var(--text)); }\n 387→ .badge.k-turn_context { border-color: color-mix(in srgb, var(--accent-2) 55%, var(--line)); color: color-mix(in srgb, var(--accent-2) 75%, var(--text)); }\n 388→ .badge.k-session_meta { border-color: color-mix(in srgb, var(--good) 55%, var(--line)); color: color-mix(in srgb, var(--good) 75%, var(--text)); }\n 389→ .badge.k-compacted { border-color: color-mix(in srgb, var(--warn) 65%, var(--line)); color: color-mix(in srgb, var(--warn) 85%, var(--text)); }\n 390→\n 391→ .badge.role-user { border-color: color-mix(in srgb, #00c8ff 55%, var(--line)); color: color-mix(in srgb, #00c8ff 78%, var(--text)); }\n 392→ .badge.role-assistant { border-color: color-mix(in srgb, #ff73c4 55%, var(--line)); color: color-mix(in srgb, #ff73c4 78%, var(--text)); }\n 393→\n 394→ .summary {\n 395→ font-size: 13px;\n 396→ line-height: 1.35;\n 397→ color: var(--text);\n 398→ word-break: break-word;\n 399→ }\n 400→\n 401→ .summary code {\n 402→ font-family: var(--mono);\n 403→ font-size: 12px;\n 404→ background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n 405→ border: 1px solid var(--line);\n 406→ padding: 2px 5px;\n 407→ border-radius: 7px;\n 408→ }\n 409→\n 410→\t .detail {\n 411→\t display: flex;\n 412→\t flex-direction: column;\n 413→\t min-height: 560px;\n 414→\t }\n 415→\n 416→\t @media (min-width: 1060px) {\n 417→\t .detail {\n 418→\t position: sticky;\n 419→\t top: 16px;\n 420→\t align-self: start;\n 421→\t max-height: calc(100vh - 32px);\n 422→\t }\n 423→\t }\n 424→\n 425→\t .detail.inline {\n 426→\t position: static;\n 427→\t top: auto;\n 428→\t align-self: auto;\n 429→\t max-height: none;\n 430→\t min-height: 0;\n 431→\t margin: 8px 14px 12px;\n 432→\t box-shadow: none;\n 433→\t }\n 434→\n 435→\t .detail.inline pre {\n 436→\t max-height: 52vh;\n 437→\t }\n 438→\n 439→\t .detail-head {\n 440→\t padding: 12px 14px;\n 441→\t border-bottom: 1px solid var(--line);\n 442→\t display: grid;\n 443→\t gap: 8px;\n 444→ }\n 445→\n 446→ .detail-title {\n 447→ font-size: 14px;\n 448→ font-weight: 700;\n 449→ line-height: 1.25;\n 450→ margin: 0;\n 451→ overflow: hidden;\n 452→ text-overflow: ellipsis;\n 453→ white-space: nowrap;\n 454→ }\n 455→\n 456→ .detail-actions {\n 457→ display: flex;\n 458→ gap: 8px;\n 459→ flex-wrap: wrap;\n 460→ align-items: center;\n 461→ justify-content: flex-start;\n 462→ }\n 463→\n 464→ .check {\n 465→ display: inline-flex;\n 466→ align-items: center;\n 467→ gap: 6px;\n 468→ color: var(--muted);\n 469→ font-size: 12px;\n 470→ user-select: none;\n 471→ }\n 472→\n 473→ pre {\n 474→ margin: 0;\n 475→ padding: 12px 14px 16px;\n 476→ overflow: auto;\n 477→ font-family: var(--mono);\n 478→ font-size: 12px;\n 479→ line-height: 1.45;\n 480→ color: color-mix(in srgb, var(--text) 92%, transparent);\n 481→ background: transparent;\n 482→ white-space: pre-wrap;\n 483→ word-break: break-word;\n 484→ }\n 485→\n 486→ #detail-json {\n 487→ flex: 1;\n 488→ min-height: 0;\n 489→ }\n 490→\n 491→ .detail-images {\n 492→ display: flex;\n 493→ gap: 10px;\n 494→ padding: 12px 14px;\n 495→ border-bottom: 1px solid var(--line);\n 496→ overflow-x: auto;\n 497→ overscroll-behavior-x: contain;\n 498→ }\n 499→\n 500→ .thumb {\n 501→ border: 1px solid var(--line);\n 502→ background: color-mix(in srgb, var(--panel-2) 92%, transparent);\n 503→ border-radius: 12px;\n 504→ padding: 0;\n 505→ cursor: pointer;\n 506→ width: 78px;\n 507→ height: 58px;\n 508→ overflow: hidden;\n 509→ flex: 0 0 auto;\n 510→ display: grid;\n 511→ place-items: stretch;\n 512→ }\n 513→\n 514→ .thumb:hover { border-color: var(--line-2); }\n 515→\n 516→ .thumb img {\n 517→ width: 100%;\n 518→ height: 100%;\n 519→ display: block;\n 520→ object-fit: cover;\n 521→ }\n 522→\n 523→ .image-modal {\n 524→ position: fixed;\n 525→ inset: 0;\n 526→ z-index: 9999;\n 527→ background: rgba(0, 0, 0, 0.68);\n 528→ display: none;\n 529→ padding: 18px;\n 530→ place-items: center;\n 531→ }\n 532→\n 533→ .image-modal.open { display: grid; }\n 534→\n 535→ .image-modal .dialog {\n 536→ position: relative;\n 537→ border-radius: 16px;\n 538→ border: 1px solid var(--line);\n 539→ background: color-mix(in srgb, var(--panel) 98%, transparent);\n 540→ box-shadow: var(--shadow);\n 541→ padding: 14px;\n 542→ max-width: min(1200px, 96vw);\n 543→ max-height: 92vh;\n 544→ display: grid;\n 545→ place-items: center;\n 546→ }\n 547→\n 548→ .image-modal img {\n 549→ display: block;\n 550→ max-width: min(1140px, 92vw);\n 551→ max-height: 84vh;\n 552→ width: auto;\n 553→ height: auto;\n 554→ object-fit: contain;\n 555→ border-radius: 10px;\n 556→ background: color-mix(in srgb, var(--panel-2) 92%, transparent);\n 557→ }\n 558→\n 559→ .image-modal .close {\n 560→ position: absolute;\n 561→ top: 10px;\n 562→ right: 10px;\n 563→ border: 1px solid var(--line);\n 564→ background: color-mix(in srgb, var(--panel) 92%, transparent);\n 565→ color: var(--text);\n 566→ border-radius: 999px;\n 567→ padding: 8px 10px;\n 568→ font: inherit;\n 569→ cursor: pointer;\n 570→ }\n 571→\n 572→ .image-modal .close:hover { border-color: var(--line-2); }\n 573→\n 574→ .empty {\n 575→ padding: 14px;\n 576→ color: var(--muted);\n 577→ font-size: 13px;\n 578→ line-height: 1.35;\n 579→ }\n 580→\n 581→ .detail-pretty {\n 582→ padding: 14px;\n 583→ border-bottom: 1px solid var(--line);\n 584→ overflow: auto;\n 585→ max-height: 50vh;\n 586→ }\n 587→\n 588→ .detail.inline .detail-pretty {\n 589→ max-height: 40vh;\n 590→ }\n 591→\n 592→ .pretty-section {\n 593→ margin-bottom: 14px;\n 594→ }\n 595→\n 596→ .pretty-section:last-child {\n 597→ margin-bottom: 0;\n 598→ }\n 599→\n 600→ .pretty-label {\n 601→ font-size: 12px;\n 602→ color: var(--muted);\n 603→ margin-bottom: 6px;\n 604→ font-weight: 600;\n 605→ letter-spacing: 0.2px;\n 606→ text-transform: uppercase;\n 607→ }\n 608→\n 609→ .pretty-content {\n 610→ background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n 611→ border: 1px solid var(--line);\n 612→ border-radius: 10px;\n 613→ padding: 12px;\n 614→ font-size: 13px;\n 615→ line-height: 1.55;\n 616→ }\n 617→\n 618→ .pretty-content code {\n 619→ font-family: var(--mono);\n 620→ font-size: 0.95em;\n 621→ }\n 622→\n 623→ .pretty-content pre {\n 624→ font-family: var(--mono);\n 625→ font-size: 12px;\n 626→ line-height: 1.4;\n 627→ white-space: pre;\n 628→ overflow: auto;\n 629→ padding: 10px 12px;\n 630→ border-radius: 10px;\n 631→ border: 1px solid var(--line);\n 632→ background: color-mix(in srgb, var(--panel) 92%, transparent);\n 633→ max-height: 520px;\n 634→ }\n 635→\n 636→ .thinking-block {\n 637→ background: color-mix(in srgb, var(--accent-2) 10%, transparent);\n 638→ border: 1px solid color-mix(in srgb, var(--accent-2) 30%, var(--line));\n 639→ border-radius: 10px;\n 640→ padding: 12px 16px;\n 641→ font-size: 13px;\n 642→ line-height: 1.5;\n 643→ }\n 644→\n 645→ .command-block {\n 646→ background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n 647→ border: 1px solid var(--line);\n 648→ border-radius: 10px;\n 649→ overflow: hidden;\n 650→ }\n 651→\n 652→ .command-header {\n 653→ display: flex;\n 654→ justify-content: space-between;\n 655→ align-items: center;\n 656→ padding: 10px 12px;\n 657→ border-bottom: 1px solid var(--line);\n 658→ background: color-mix(in srgb, var(--panel-2) 50%, transparent);\n 659→ gap: 10px;\n 660→ }\n 661→\n 662→ .tool-name {\n 663→ font-family: var(--mono);\n 664→ font-size: 12px;\n 665→ color: var(--accent);\n 666→ font-weight: 600;\n 667→ }\n 668→\n 669→ .tool-desc {\n 670→ font-size: 12px;\n 671→ color: var(--muted);\n 672→ margin-left: auto;\n 673→ }\n 674→\n 675→ .command-input {\n 676→ padding: 10px 12px;\n 677→ font-family: var(--mono);\n 678→ font-size: 12px;\n 679→ line-height: 1.4;\n 680→ white-space: pre-wrap;\n 681→ word-break: break-word;\n 682→ }\n 683→\n 684→ .tool-params {\n 685→ display: grid;\n 686→ gap: 8px;\n 687→ margin-top: 8px;\n 688→ padding: 10px 12px;\n 689→ }\n 690→\n 691→ .tool-param {\n 692→ display: grid;\n 693→ grid-template-columns: auto 1fr;\n 694→ gap: 8px;\n 695→ align-items: start;\n 696→ font-size: 12px;\n 697→ }\n 698→\n 699→ .tool-param-name {\n 700→ font-family: var(--mono);\n 701→ font-weight: 600;\n 702→ color: var(--accent);\n 703→ padding: 2px 6px;\n 704→ background: color-mix(in srgb, var(--accent) 12%, transparent);\n 705→ border-radius: 4px;\n 706→ white-space: nowrap;\n 707→ }\n 708→\n 709→ .tool-param-value {\n 710→ font-family: var(--mono);\n 711→ white-space: pre-wrap;\n 712→ word-break: break-word;\n 713→ padding: 2px 0;\n 714→ }\n 715→\n 716→ .prompts-modal {\n 717→ position: fixed;\n 718→ inset: 0;\n 719→ z-index: 80;\n 720→ background: rgba(0, 0, 0, 0.68);\n 721→ display: none;\n 722→ padding: 18px;\n 723→ place-items: center;\n 724→ }\n 725→\n 726→ .prompts-modal.open { display: grid; }\n 727→\n 728→ .prompts-modal .dialog {\n 729→ position: relative;\n 730→ border-radius: 16px;\n 731→ border: 1px solid var(--line);\n 732→ background: color-mix(in srgb, var(--panel) 98%, transparent);\n 733→ box-shadow: var(--shadow);\n 734→ padding: 14px;\n 735→ width: min(1100px, 96vw);\n 736→ height: min(86vh, 780px);\n 737→ display: grid;\n 738→ grid-template-rows: auto 1fr auto;\n 739→ gap: 10px;\n 740→ }\n 741→\n 742→ .prompts-head {\n 743→ display: flex;\n 744→ gap: 10px;\n 745→ align-items: center;\n 746→ justify-content: space-between;\n 747→ }\n 748→\n 749→ .prompts-title {\n 750→ font-weight: 700;\n 751→ letter-spacing: 0.2px;\n 752→ }\n 753→\n 754→ .prompts-modal textarea {\n 755→ width: 100%;\n 756→ height: 100%;\n 757→ resize: none;\n 758→ border-radius: 12px;\n 759→ font-family: var(--mono);\n 760→ font-size: 12px;\n 761→ line-height: 1.45;\n 762→ padding: 12px;\n 763→ }\n 764→\n 765→ .prompts-actions {\n 766→ display: flex;\n 767→ gap: 10px;\n 768→ align-items: center;\n 769→ justify-content: flex-end;\n 770→ flex-wrap: wrap;\n 771→ }\n 772→ </style>\n 773→</head>\n 774→ <body>\n 775→ <div class=\"wrap\">\n 776→ <header>\n 777→ <div>\n 778→ <h1>Codex Timeline Viewer</h1>\n 779→ <p class=\"sub\">Open a Codex rollout <code>.jsonl</code> file and explore it as an interactive timeline. Times are shown in your local timezone by default.</p>\n 780→ </div>\n 781→ </header>\n 782→\n 783→ <section class=\"panel input-panel\" aria-label=\"Input\">\n 784→ <div class=\"input-grid\">\n 785→ <div>\n 786→ <div id=\"dropzone\" class=\"dropzone\" tabindex=\"0\">\n 787→ <strong>Drop a <code>.jsonl</code> file here</strong>\n 788→ <div class=\"muted\">or click to choose a file</div>\n 789→ <div class=\"tiny\">Supported: file picker, drag-and-drop, paste, or URL fetch (CORS-enabled).</div>\n 790→ <input id=\"file-input\" type=\"file\" accept=\".jsonl,application/json,text/plain\" hidden />\n 791→ </div>\n 792→ </div>\n 793→ <div>\n 794→ <div class=\"tabs\" role=\"tablist\" aria-label=\"Input tabs\">\n 795→ <button type=\"button\" class=\"tab active\" data-tab=\"paste\" aria-selected=\"true\">Paste</button>\n 796→ <button type=\"button\" class=\"tab\" data-tab=\"url\" aria-selected=\"false\">URL</button>\n 797→ </div>\n 798→\n 799→ <div id=\"panel-paste\" data-panel=\"paste\">\n 800→ <textarea id=\"paste-input\" placeholder=\"Paste JSONL content here…\"></textarea>\n 801→ <div class=\"row\">\n 802→ <button type=\"button\" class=\"btn primary\" id=\"render-paste\">Render pasted content</button>\n 803→ <div class=\"tiny\">Tip: the timeline uses one JSON object per line.</div>\n 804→ </div>\n 805→ </div>\n 806→\n 807→ <div id=\"panel-url\" data-panel=\"url\" hidden>\n 808→ <input id=\"url-input\" type=\"url\" placeholder=\"https://example.com/rollout.jsonl\" />\n 809→ <div class=\"row\">\n 810→ <button type=\"button\" class=\"btn primary\" id=\"fetch-url\">Fetch and render</button>\n 811→ <div class=\"tiny\">The URL must allow cross-origin requests (CORS).</div>\n 812→ </div>\n 813→ </div>\n 814→ </div>\n 815→ </div>\n 816→\n 817→ <div id=\"status\" class=\"status\" role=\"status\" aria-live=\"polite\"></div>\n 818→ </section>\n 819→\n 820→ <section class=\"panel filters-panel\" aria-label=\"Filters\">\n 821→ <div class=\"controls\" aria-label=\"Timeline controls\">\n 822→ <div class=\"field\" style=\"min-width: 170px\">\n 823→ <div class=\"label\">Timezone</div>\n 824→ <div class=\"segmented\" role=\"group\" aria-label=\"Timezone toggle\">\n 825→ <button type=\"button\" id=\"tz-local\" class=\"active\">Local</button>\n 826→ <button type=\"button\" id=\"tz-utc\">UTC</button>\n 827→ </div>\n 828→ </div>\n 829→ <div class=\"field\" style=\"min-width: 220px\">\n 830→ <div class=\"label\">Search</div>\n 831→ <input id=\"search\" type=\"search\" placeholder=\"message, function name, cwd…\" autocomplete=\"off\" />\n 832→ </div>\n 833→ <div class=\"field\" style=\"min-width: 190px\">\n 834→ <div class=\"label\">Type</div>\n 835→ <select id=\"type-filter\"></select>\n 836→ </div>\n 837→ <div class=\"field\" style=\"min-width: 210px\">\n 838→ <div class=\"label\">Payload</div>\n 839→ <select id=\"payload-filter\"></select>\n 840→ </div>\n 841→ <div class=\"field\" style=\"min-width: 160px\">\n 842→ <div class=\"label\">Role</div>\n 843→ <select id=\"role-filter\"></select>\n 844→ </div>\n 845→ <label class=\"check\" title=\"Hide token_count events (often very noisy)\">\n 846→ <input type=\"checkbox\" id=\"hide-token-count\" checked />\n 847→ Hide token_count\n 848→ </label>\n 849→ <button type=\"button\" class=\"btn\" id=\"clear-filters\">Clear</button>\n 850→ </div>\n 851→ </section>\n 852→\n 853→ <div class=\"controls\" style=\"margin-bottom: 14px;\">\n 854→ <button type=\"button\" class=\"btn\" id=\"show-prompts\" disabled>Show prompts</button>\n 855→ </div>\n 856→\n 857→ <main>\n 858→ <section class=\"panel timeline-panel\" aria-label=\"Timeline\">\n 859→ <div class=\"stats\">\n 860→ <div class=\"big\"><span id=\"stats-showing\">0</span> <span class=\"muted\">showing</span> <span class=\"muted\">/</span> <span id=\"stats-total\">0</span> <span class=\"muted\">events</span></div>\n 861→ <div class=\"muted\" id=\"stats-range\">—</div>\n 862→ </div>\n 863→ <div id=\"timeline\" class=\"timeline\" role=\"list\" tabindex=\"0\" aria-label=\"Events list\"></div>\n 864→ </section>\n 865→\n 866→ <aside class=\"panel detail\" aria-label=\"Details\">\n 867→ <div class=\"detail-head\">\n 868→ <div class=\"detail-title\" id=\"detail-title\">Select an event</div>\n 869→ <div class=\"detail-actions\">\n 870→ <button type=\"button\" class=\"btn\" id=\"copy-json\" disabled>Copy JSON</button>\n 871→ <button type=\"button\" class=\"btn\" id=\"copy-raw\" disabled>Copy raw line</button>\n 872→ <label class=\"check\" title=\"Truncate very long strings in the JSON preview\">\n 873→ <input type=\"checkbox\" id=\"truncate-strings\" checked />\n 874→ Truncate long strings\n 875→ </label>\n 876→ </div>\n 877→ </div>\n 878→ <div id=\"detail-images\" class=\"detail-images\" hidden aria-label=\"Event images\"></div>\n 879→ <div id=\"detail-pretty\" class=\"detail-pretty\" hidden aria-label=\"Formatted event view\"></div>\n 880→ <pre id=\"detail-json\" aria-label=\"Selected event JSON\" class=\"empty\">Load a file to get started.</pre>\n 881→ </aside>\n 882→ </main>\n 883→ </div>\n 884→\n 885→ <div id=\"image-modal\" class=\"image-modal\" role=\"dialog\" aria-modal=\"true\" aria-hidden=\"true\">\n 886→ <div class=\"dialog\">\n 887→ <button type=\"button\" class=\"close\" id=\"image-modal-close\" aria-label=\"Close image\">Close</button>\n 888→ <img id=\"image-modal-img\" alt=\"\" />\n 889→ </div>\n 890→ </div>\n 891→\n 892→ <div id=\"prompts-modal\" class=\"prompts-modal\" role=\"dialog\" aria-modal=\"true\" aria-hidden=\"true\" aria-label=\"User prompts\">\n 893→ <div class=\"dialog\">\n 894→ <div class=\"prompts-head\">\n 895→ <div class=\"prompts-title\">User prompts</div>\n 896→ <button type=\"button\" class=\"btn\" id=\"prompts-close\" aria-label=\"Close prompts\">Close</button>\n 897→ </div>\n 898→ <textarea id=\"prompts-text\" readonly spellcheck=\"false\"></textarea>\n 899→ <div class=\"prompts-actions\">\n 900→ <button type=\"button\" class=\"btn primary\" id=\"prompts-copy\">Copy</button>\n 901→ </div>\n 902→ </div>\n 903→ </div>\n 904→\n 905→ <script>\n 906→\t const els = {\n 907→\t dropzone: document.getElementById('dropzone'),\n 908→\t fileInput: document.getElementById('file-input'),\n 909→\t pasteInput: document.getElementById('paste-input'),\n 910→\t renderPaste: document.getElementById('render-paste'),\n 911→\t urlInput: document.getElementById('url-input'),\n 912→\t fetchUrl: document.getElementById('fetch-url'),\n 913→\t status: document.getElementById('status'),\n 914→\t timeline: document.getElementById('timeline'),\n 915→\t detailPanel: document.querySelector('aside.detail'),\n 916→\t detailTitle: document.getElementById('detail-title'),\n 917→\t detailImages: document.getElementById('detail-images'),\n 918→\t detailPretty: document.getElementById('detail-pretty'),\n 919→\t detailJson: document.getElementById('detail-json'),\n 920→\t copyJson: document.getElementById('copy-json'),\n 921→\t copyRaw: document.getElementById('copy-raw'),\n 922→ truncateStrings: document.getElementById('truncate-strings'),\n 923→ tzLocal: document.getElementById('tz-local'),\n 924→ tzUtc: document.getElementById('tz-utc'),\n 925→ search: document.getElementById('search'),\n 926→ typeFilter: document.getElementById('type-filter'),\n 927→ payloadFilter: document.getElementById('payload-filter'),\n 928→ roleFilter: document.getElementById('role-filter'),\n 929→ hideTokenCount: document.getElementById('hide-token-count'),\n 930→ statsShowing: document.getElementById('stats-showing'),\n 931→ statsTotal: document.getElementById('stats-total'),\n 932→ statsRange: document.getElementById('stats-range'),\n 933→ clearFilters: document.getElementById('clear-filters'),\n 934→ imageModal: document.getElementById('image-modal'),\n 935→ imageModalImg: document.getElementById('image-modal-img'),\n 936→ imageModalClose: document.getElementById('image-modal-close'),\n 937→ showPrompts: document.getElementById('show-prompts'),\n 938→ promptsModal: document.getElementById('prompts-modal'),\n 939→ promptsClose: document.getElementById('prompts-close'),\n 940→ promptsText: document.getElementById('prompts-text'),\n 941→ promptsCopy: document.getElementById('prompts-copy'),\n 942→\t };\n 943→\n 944→ const state = {\n 945→ sourceLabel: null,\n 946→ tzMode: 'local',\n 947→ events: [],\n 948→ filtered: [],\n 949→ selectedId: null,\n 950→ selected: null,\n 951→ filters: {\n 952→ query: '',\n 953→ type: 'all',\n 954→ payload: 'all',\n 955→ role: 'all',\n 956→ hideTokenCount: true,\n 957→ },\n 958→ };\n 959→\n 960→\t let lastActiveElementBeforeModal = null;\n 961→\t let lastBodyOverflow = null;\n 962→ let lastActiveElementBeforePrompts = null;\n 963→ let lastBodyOverflowBeforePrompts = null;\n 964→ let promptsCopiedTimer = null;\n 965→\t let suppressUrlStateSync = false;\n 966→\t const wideLayoutMq = window.matchMedia('(min-width: 1060px)');\n 967→\t const detailDock = document.createElement('div');\n 968→\t detailDock.id = 'detail-dock';\n 969→\t detailDock.style.display = 'none';\n 970→\t els.detailPanel.parentElement.insertBefore(detailDock, els.detailPanel);\n 971→\n 972→ const md = typeof window.markdownit === 'function'\n 973→ ? window.markdownit({\n 974→ html: false,\n 975→ linkify: true,\n 976→ typographer: true,\n 977→ breaks: true,\n 978→ })\n 979→ : null;\n 980→\n 981→ function escapeHtml(text) {\n 982→ const div = document.createElement('div');\n 983→ div.textContent = String(text ?? '');\n 984→ return div.innerHTML;\n 985→ }\n 986→\n 987→ function renderMarkdown(text) {\n 988→ if (typeof text !== 'string') return '';\n 989→ if (!md) return escapeHtml(text).replace(/\\n/g, '<br>');\n 990→ const html = md.render(text);\n 991→ if (window.DOMPurify && typeof window.DOMPurify.sanitize === 'function') {\n 992→ return window.DOMPurify.sanitize(html);\n 993→ }\n 994→ return html;\n 995→ }\n 996→\n 997→\t function updateDetailPlacement() {\n 998→\t const inline = !wideLayoutMq.matches;\n 999→\n 1000→\t if (!inline) {\n 1001→\t if (els.detailPanel.previousSibling !== detailDock) detailDock.after(els.detailPanel);\n 1002→\t els.detailPanel.classList.remove('inline');\n 1003→\t return;\n 1004→\t }\n 1005→\n 1006→\t const selectedNode = els.timeline.querySelector('.event.selected');\n 1007→\t if (!selectedNode) {\n 1008→\t if (els.detailPanel.previousSibling !== detailDock) detailDock.after(els.detailPanel);\n 1009→\t els.detailPanel.classList.remove('inline');\n 1010→\t return;\n 1011→\t }\n 1012→\n 1013→\t if (selectedNode.nextSibling !== els.detailPanel) selectedNode.after(els.detailPanel);\n 1014→\t els.detailPanel.classList.add('inline');\n 1015→\t }\n 1016→\n 1017→\t function setStatus(message, kind = 'good') {\n 1018→\t if (!message) {\n 1019→\t els.status.className = 'status';\n 1020→\t els.status.textContent = '';\n 1021→ return;\n 1022→ }\n 1023→ els.status.className = `status visible ${kind}`;\n 1024→ els.status.textContent = message;\n 1025→ }\n 1026→\n 1027→ function pad2(number) {\n 1028→ return String(number).padStart(2, '0');\n 1029→ }\n 1030→\n 1031→ function pad3(number) {\n 1032→ return String(number).padStart(3, '0');\n 1033→ }\n 1034→\n 1035→ function getOffsetString(date) {\n 1036→ const offsetMinutes = -date.getTimezoneOffset(); // minutes east of UTC\n 1037→ const sign = offsetMinutes >= 0 ? '+' : '-';\n 1038→ const abs = Math.abs(offsetMinutes);\n 1039→ const hours = Math.floor(abs / 60);\n 1040→ const minutes = abs % 60;\n 1041→ return `${sign}${pad2(hours)}:${pad2(minutes)}`;\n 1042→ }\n 1043→\n 1044→ function formatTimestamp(date, tzMode) {\n 1045→ if (!(date instanceof Date) || Number.isNaN(date.getTime())) return '—';\n 1046→ if (tzMode === 'utc') {\n 1047→ return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ` +\n 1048→ `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}:${pad2(date.getUTCSeconds())}.` +\n 1049→ `${pad3(date.getUTCMilliseconds())}Z`;\n 1050→ }\n 1051→ return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ` +\n 1052→ `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}.` +\n 1053→ `${pad3(date.getMilliseconds())} ${getOffsetString(date)}`;\n 1054→ }\n 1055→\n 1056→ function dayKey(date, tzMode) {\n 1057→ if (!(date instanceof Date) || Number.isNaN(date.getTime())) return '—';\n 1058→ if (tzMode === 'utc') {\n 1059→ return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())}`;\n 1060→ }\n 1061→ return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;\n 1062→ }\n 1063→\n 1064→ function formatDelta(ms) {\n 1065→ if (typeof ms !== 'number' || !Number.isFinite(ms)) return '';\n 1066→ const abs = Math.abs(ms);\n 1067→ const sign = ms < 0 ? '−' : '+';\n 1068→ if (abs < 1000) return `${sign}${Math.round(abs)}ms`;\n 1069→ if (abs < 60_000) return `${sign}${(abs / 1000).toFixed(abs < 10_000 ? 2 : 1)}s`;\n 1070→ if (abs < 3_600_000) {\n 1071→ const m = Math.floor(abs / 60_000);\n 1072→ const s = Math.floor((abs % 60_000) / 1000);\n 1073→ return `${sign}${m}m ${pad2(s)}s`;\n 1074→ }\n 1075→ if (abs < 86_400_000) {\n 1076→ const h = Math.floor(abs / 3_600_000);\n 1077→ const m = Math.floor((abs % 3_600_000) / 60_000);\n 1078→ return `${sign}${h}h ${m}m`;\n 1079→ }\n 1080→ const d = Math.floor(abs / 86_400_000);\n 1081→ const h = Math.floor((abs % 86_400_000) / 3_600_000);\n 1082→ return `${sign}${d}d ${h}h`;\n 1083→ }\n 1084→\n 1085→ function safeJsonParse(text) {\n 1086→ try {\n 1087→ return { ok: true, value: JSON.parse(text) };\n 1088→ } catch (error) {\n 1089→ return { ok: false, error };\n 1090→ }\n 1091→ }\n 1092→\n 1093→ function extractMessageText(payload) {\n 1094→ if (!payload || typeof payload !== 'object') return null;\n 1095→ const content = payload.content;\n 1096→ if (!Array.isArray(content)) return null;\n 1097→ const parts = [];\n 1098→ for (const item of content) {\n 1099→ if (!item || typeof item !== 'object') continue;\n 1100→ if (typeof item.text === 'string') parts.push(item.text);\n 1101→ else if (typeof item.input_text === 'string') parts.push(item.input_text);\n 1102→ else if (typeof item.output_text === 'string') parts.push(item.output_text);\n 1103→ }\n 1104→ const joined = parts.join('');\n 1105→ return joined || null;\n 1106→ }\n 1107→\n 1108→ function firstLine(text) {\n 1109→ if (typeof text !== 'string') return null;\n 1110→ const trimmed = text.trim();\n 1111→ if (!trimmed) return null;\n 1112→ const idx = trimmed.indexOf('\\n');\n 1113→ return idx === -1 ? trimmed : trimmed.slice(0, idx).trim();\n 1114→ }\n 1115→\n 1116→ function normalizeSummaryText(text, maxLen = 260) {\n 1117→ if (typeof text !== 'string') return '';\n 1118→ const singleLine = text.replace(/\\s+/g, ' ').trim();\n 1119→ if (singleLine.length <= maxLen) return singleLine;\n 1120→ return singleLine.slice(0, maxLen - 1) + '…';\n 1121→ }\n 1122→\n 1123→ function summarizeEvent(obj) {\n 1124→ const kind = obj?.type || '';\n 1125→ const payload = obj?.payload;\n 1126→ const payloadType = payload && typeof payload === 'object' ? payload.type : null;\n 1127→\n 1128→ if (kind === 'session_meta') {\n 1129→ const id = payload?.id ? `id=${payload.id}` : null;\n 1130→ const cwd = payload?.cwd ? `cwd=${payload.cwd}` : null;\n 1131→ const model = payload?.model_provider ? `model_provider=${payload.model_provider}` : null;\n 1132→ return normalizeSummaryText([id, cwd, model].filter(Boolean).join(' · ')) || 'session_meta';\n 1133→ }\n 1134→\n 1135→ if (kind === 'turn_context') {\n 1136→ const cwd = payload?.cwd ? `cwd=${payload.cwd}` : null;\n 1137→ const model = payload?.model ? `model=${payload.model}` : null;\n 1138→ const effort = payload?.effort ? `effort=${payload.effort}` : null;\n 1139→ return normalizeSummaryText([cwd, model, effort].filter(Boolean).join(' · ')) || 'turn_context';\n 1140→ }\n 1141→\n 1142→ if (payloadType === 'message') {\n 1143→ const role = payload?.role ? `${payload.role}: ` : '';\n 1144→ const msg = extractMessageText(payload) || '';\n 1145→ return normalizeSummaryText(role + msg) || 'message';\n 1146→ }\n 1147→\n 1148→ if (payloadType === 'user_message' || payloadType === 'agent_message') {\n 1149→ const msg = payload?.message || '';\n 1150→ return normalizeSummaryText(msg) || payloadType;\n 1151→ }\n 1152→\n 1153→ if (payloadType === 'function_call') {\n 1154→ const name = payload?.name || 'function_call';\n 1155→ const argsText = typeof payload?.arguments === 'string' ? payload.arguments : null;\n 1156→ const args = argsText ? safeJsonParse(argsText) : null;\n 1157→ if (name === 'shell_command' && args?.ok && typeof args.value?.command === 'string') {\n 1158→ return normalizeSummaryText(`${name}: ${args.value.command}`);\n 1159→ }\n 1160→ if (name === 'update_plan' && args?.ok && typeof args.value?.explanation === 'string') {\n 1161→ return normalizeSummaryText(`${name}: ${args.value.explanation}`);\n 1162→ }\n 1163→ if (name === 'apply_patch' && args?.ok && typeof args.value?.patch === 'string') {\n 1164→ return normalizeSummaryText(`${name}: ${firstLine(args.value.patch) || 'patch'}`);\n 1165→ }\n 1166→ if (args?.ok && args.value && typeof args.value === 'object') {\n 1167→ const keys = Object.keys(args.value).slice(0, 5);\n 1168→ return normalizeSummaryText(`${name}(${keys.join(', ')}${Object.keys(args.value).length > 5 ? ', …' : ''})`);\n 1169→ }\n 1170→ return normalizeSummaryText(`${name}(${argsText ? '…' : ''})`);\n 1171→ }\n 1172→\n 1173→ if (payloadType === 'function_call_output') {\n 1174→ const out = typeof payload?.output === 'string' ? payload.output : '';\n 1175→ const line = firstLine(out) || '';\n 1176→ return normalizeSummaryText(`output: ${line}`) || 'function_call_output';\n 1177→ }\n 1178→\n 1179→ if (payloadType === 'custom_tool_call') {\n 1180→ const name = payload?.name || 'custom_tool_call';\n 1181→ const status = payload?.status ? ` (${payload.status})` : '';\n 1182→ const inputLine = firstLine(payload?.input) || '';\n 1183→ return normalizeSummaryText(`${name}${status}: ${inputLine}`) || `${name}${status}`;\n 1184→ }\n 1185→\n 1186→ if (payloadType === 'custom_tool_call_output') {\n 1187→ const out = typeof payload?.output === 'string' ? payload.output : '';\n 1188→ return normalizeSummaryText(`tool output: ${firstLine(out) || ''}`) || 'custom_tool_call_output';\n 1189→ }\n 1190→\n 1191→ if (payloadType === 'token_count') {\n 1192→ const used = payload?.rate_limits?.primary?.used_percent;\n 1193→ const windowMinutes = payload?.rate_limits?.primary?.window_minutes;\n 1194→ if (typeof used === 'number' && typeof windowMinutes === 'number') {\n 1195→ return normalizeSummaryText(`token_count: primary ${used}% / ${windowMinutes}m window`);\n 1196→ }\n 1197→ return 'token_count';\n 1198→ }\n 1199→\n 1200→ if (payloadType === 'agent_reasoning') {\n 1201→ return normalizeSummaryText(firstLine(payload?.text) || payload?.text || '') || 'agent_reasoning';\n 1202→ }\n 1203→\n 1204→ if (payloadType === 'reasoning') {\n 1205→ const summaryText = payload?.summary?.[0]?.text;\n 1206→ return normalizeSummaryText(firstLine(summaryText) || summaryText || '') || 'reasoning';\n 1207→ }\n 1208→\n 1209→ if (kind && payloadType) return `${kind} · ${payloadType}`;\n 1210→ if (kind) return kind;\n 1211→ return 'event';\n 1212→ }\n 1213→\n 1214→ function buildSearchText(obj, summary) {\n 1215→ const pieces = [];\n 1216→ pieces.push(obj?.type || '');\n 1217→ const payload = obj?.payload;\n 1218→ if (payload && typeof payload === 'object') {\n 1219→ pieces.push(payload.type || '');\n 1220→ pieces.push(payload.role || '');\n 1221→ pieces.push(payload.name || '');\n 1222→ if (typeof payload.message === 'string') pieces.push(payload.message);\n 1223→ if (typeof payload.text === 'string') pieces.push(payload.text);\n 1224→ const msg = extractMessageText(payload);\n 1225→ if (msg) pieces.push(msg);\n 1226→ if (typeof payload.arguments === 'string') pieces.push(payload.arguments);\n 1227→ if (typeof payload.output === 'string') pieces.push(payload.output.slice(0, 500));\n 1228→ if (typeof payload.cwd === 'string') pieces.push(payload.cwd);\n 1229→ if (typeof payload.model === 'string') pieces.push(payload.model);\n 1230→ }\n 1231→ if (typeof summary === 'string') pieces.push(summary);\n 1232→ return pieces.join('\\n').toLowerCase();\n 1233→ }\n 1234→\n 1235→ function normalizeShellCommand(args) {\n 1236→ if (!args || typeof args !== 'object') return null;\n 1237→ const cmd = args.command;\n 1238→ if (typeof cmd === 'string') return cmd;\n 1239→ if (Array.isArray(cmd)) return cmd.map(v => String(v)).join(' ');\n 1240→ if (Array.isArray(args.cmd)) return args.cmd.map(v => String(v)).join(' ');\n 1241→ if (typeof args.cmd === 'string') return args.cmd;\n 1242→ return null;\n 1243→ }\n 1244→\n 1245→ function renderPrettyView(obj) {\n 1246→ els.detailPretty.innerHTML = '';\n 1247→\n 1248→ if (!obj || typeof obj !== 'object') {\n 1249→ els.detailPretty.hidden = true;\n 1250→ return;\n 1251→ }\n 1252→\n 1253→ const payload = obj.payload && typeof obj.payload === 'object' ? obj.payload : null;\n 1254→ const payloadType = payload?.type;\n 1255→ const fragment = document.createDocumentFragment();\n 1256→ let hasContent = false;\n 1257→\n 1258→ function addSection(labelText, bodyEl) {\n 1259→ const section = document.createElement('div');\n 1260→ section.className = 'pretty-section';\n 1261→ const label = document.createElement('div');\n 1262→ label.className = 'pretty-label';\n 1263→ label.textContent = labelText;\n 1264→ section.appendChild(label);\n 1265→ section.appendChild(bodyEl);\n 1266→ fragment.appendChild(section);\n 1267→ hasContent = true;\n 1268→ }\n 1269→\n 1270→ if (payloadType === 'message') {\n 1271→ const role = payload?.role || 'message';\n 1272→ const content = payload?.content;\n 1273→\n 1274→ if (Array.isArray(content)) {\n 1275→ const textParts = [];\n 1276→ const thinkingParts = [];\n 1277→ for (const item of content) {\n 1278→ if (!item || typeof item !== 'object') continue;\n 1279→\n 1280→ const kind = item.type || '';\n 1281→ if ((kind === 'input_text' || kind === 'output_text' || kind === 'text') && typeof item.text === 'string') {\n 1282→ textParts.push(item.text);\n 1283→ } else if (kind === 'thinking' && typeof item.thinking === 'string') {\n 1284→ thinkingParts.push(item.thinking);\n 1285→ } else if (kind === 'tool_use' && item.name) {\n 1286→ const toolName = String(item.name);\n 1287→ const input = item.input && typeof item.input === 'object' ? item.input : {};\n 1288→\n 1289→ const block = document.createElement('div');\n 1290→ block.className = 'command-block';\n 1291→\n 1292→ const header = document.createElement('div');\n 1293→ header.className = 'command-header';\n 1294→ const nameSpan = document.createElement('span');\n 1295→ nameSpan.className = 'tool-name';\n 1296→ nameSpan.textContent = toolName;\n 1297→ header.appendChild(nameSpan);\n 1298→ block.appendChild(header);\n 1299→\n 1300→ if (Object.keys(input).length) {\n 1301→ const params = document.createElement('div');\n 1302→ params.className = 'tool-params';\n 1303→ for (const [key, value] of Object.entries(input)) {\n 1304→ const param = document.createElement('div');\n 1305→ param.className = 'tool-param';\n 1306→ const paramName = document.createElement('span');\n 1307→ paramName.className = 'tool-param-name';\n 1308→ paramName.textContent = key;\n 1309→ const paramValue = document.createElement('span');\n 1310→ paramValue.className = 'tool-param-value';\n 1311→ if (typeof value === 'string') paramValue.textContent = value.length > 2000 ? value.slice(0, 2000) + '…' : value;\n 1312→ else paramValue.textContent = JSON.stringify(value, null, 2);\n 1313→ param.appendChild(paramName);\n 1314→ param.appendChild(paramValue);\n 1315→ params.appendChild(param);\n 1316→ }\n 1317→ block.appendChild(params);\n 1318→ }\n 1319→\n 1320→ addSection('Tool', block);\n 1321→ }\n 1322→ }\n 1323→\n 1324→ const joinedText = textParts.map(v => String(v ?? '').trim()).filter(Boolean).join('\\n\\n').trim();\n 1325→ if (joinedText) {\n 1326→ const body = document.createElement('div');\n 1327→ body.className = 'pretty-content';\n 1328→ body.innerHTML = renderMarkdown(joinedText);\n 1329→ addSection(role === 'assistant' ? 'Response' : 'Prompt', body);\n 1330→ }\n 1331→\n 1332→ const joinedThinking = thinkingParts.map(v => String(v ?? '').trim()).filter(Boolean).join('\\n\\n').trim();\n 1333→ if (joinedThinking) {\n 1334→ const body = document.createElement('div');\n 1335→ body.className = 'thinking-block';\n 1336→ body.innerHTML = renderMarkdown(joinedThinking);\n 1337→ addSection('Thinking', body);\n 1338→ }\n 1339→ }\n 1340→ }\n 1341→\n 1342→ if (payloadType === 'user_message' || payloadType === 'agent_message') {\n 1343→ const msg = typeof payload?.message === 'string' ? payload.message : '';\n 1344→ if (msg) {\n 1345→ const body = document.createElement('div');\n 1346→ body.className = 'pretty-content';\n 1347→ body.innerHTML = renderMarkdown(msg);\n 1348→ addSection(payloadType === 'user_message' ? 'Prompt' : 'Message', body);\n 1349→ }\n 1350→ }\n 1351→\n 1352→ if (payloadType === 'function_call') {\n 1353→ const toolName = payload?.name || 'function_call';\n 1354→ const argsText = typeof payload?.arguments === 'string' ? payload.arguments : '';\n 1355→ const parsed = argsText ? safeJsonParse(argsText) : null;\n 1356→ const args = parsed?.ok ? parsed.value : null;\n 1357→\n 1358→ const block = document.createElement('div');\n 1359→ block.className = 'command-block';\n 1360→\n 1361→ const header = document.createElement('div');\n 1362→ header.className = 'command-header';\n 1363→ const nameSpan = document.createElement('span');\n 1364→ nameSpan.className = 'tool-name';\n 1365→ nameSpan.textContent = toolName;\n 1366→ header.appendChild(nameSpan);\n 1367→ block.appendChild(header);\n 1368→\n 1369→ const maybeCmd = normalizeShellCommand(args);\n 1370→ if (maybeCmd) {\n 1371→ const cmd = document.createElement('div');\n 1372→ cmd.className = 'command-input';\n 1373→ cmd.textContent = maybeCmd;\n 1374→ block.appendChild(cmd);\n 1375→ }\n 1376→\n 1377→ const argObj = args && typeof args === 'object' ? args : null;\n 1378→ if (argObj && Object.keys(argObj).length) {\n 1379→ const params = document.createElement('div');\n 1380→ params.className = 'tool-params';\n 1381→ for (const [key, value] of Object.entries(argObj)) {\n 1382→ if (key === 'command' && maybeCmd) continue;\n 1383→ const param = document.createElement('div');\n 1384→ param.className = 'tool-param';\n 1385→ const paramName = document.createElement('span');\n 1386→ paramName.className = 'tool-param-name';\n 1387→ paramName.textContent = key;\n 1388→ const paramValue = document.createElement('span');\n 1389→ paramValue.className = 'tool-param-value';\n 1390→ if (typeof value === 'string') paramValue.textContent = value.length > 4000 ? value.slice(0, 4000) + '…' : value;\n 1391→ else paramValue.textContent = JSON.stringify(value, null, 2);\n 1392→ param.appendChild(paramName);\n 1393→ param.appendChild(paramValue);\n 1394→ params.appendChild(param);\n 1395→ }\n 1396→ block.appendChild(params);\n 1397→ } else if (argsText) {\n 1398→ const raw = document.createElement('div');\n 1399→ raw.className = 'command-input';\n 1400→ raw.textContent = argsText.length > 8000 ? argsText.slice(0, 8000) + '…' : argsText;\n 1401→ block.appendChild(raw);\n 1402→ }\n 1403→\n 1404→ addSection('Tool call', block);\n 1405→ }\n 1406→\n 1407→ if (payloadType === 'function_call_output') {\n 1408→ const outText = typeof payload?.output === 'string' ? payload.output : '';\n 1409→ let display = outText;\n 1410→ const parsed = outText ? safeJsonParse(outText) : null;\n 1411→ if (parsed?.ok && parsed.value && typeof parsed.value === 'object' && typeof parsed.value.output === 'string') {\n 1412→ display = parsed.value.output;\n 1413→ }\n 1414→ if (display) {\n 1415→ const body = document.createElement('div');\n 1416→ body.className = 'pretty-content';\n 1417→ const pre = document.createElement('pre');\n 1418→ pre.textContent = display.length > 60_000 ? display.slice(0, 60_000) + '\\n… [truncated]' : display;\n 1419→ body.appendChild(pre);\n 1420→ addSection('Tool output', body);\n 1421→ }\n 1422→ }\n 1423→\n 1424→ if (payloadType === 'agent_reasoning') {\n 1425→ const text = typeof payload?.text === 'string' ? payload.text : '';\n 1426→ if (text) {\n 1427→ const body = document.createElement('div');\n 1428→ body.className = 'thinking-block';\n 1429→ body.innerHTML = renderMarkdown(text);\n 1430→ addSection('Reasoning', body);\n 1431→ }\n 1432→ }\n 1433→\n 1434→ if (payloadType === 'reasoning') {\n 1435→ const parts = Array.isArray(payload?.summary)\n 1436→ ? payload.summary.map(item => (item && typeof item === 'object' ? item.text : null)).filter(v => typeof v === 'string')\n 1437→ : [];\n 1438→ const summaryText = parts.map(v => v.trim()).filter(Boolean).join('\\n\\n').trim();\n 1439→ if (summaryText) {\n 1440→ const body = document.createElement('div');\n 1441→ body.className = 'thinking-block';\n 1442→ body.innerHTML = renderMarkdown(summaryText);\n 1443→ addSection('Reasoning', body);\n 1444→ }\n 1445→ }\n 1446→\n 1447→ if (payloadType === 'custom_tool_call') {\n 1448→ const name = payload?.name || 'custom_tool_call';\n 1449→ const status = payload?.status ? ` (${payload.status})` : '';\n 1450→ const input = typeof payload?.input === 'string' ? payload.input : '';\n 1451→ if (name || input) {\n 1452→ const block = document.createElement('div');\n 1453→ block.className = 'command-block';\n 1454→ const header = document.createElement('div');\n 1455→ header.className = 'command-header';\n 1456→ const nameSpan = document.createElement('span');\n 1457→ nameSpan.className = 'tool-name';\n 1458→ nameSpan.textContent = `${name}${status}`;\n 1459→ header.appendChild(nameSpan);\n 1460→ block.appendChild(header);\n 1461→ if (input) {\n 1462→ const pre = document.createElement('div');\n 1463→ pre.className = 'command-input';\n 1464→ pre.textContent = input.length > 20_000 ? input.slice(0, 20_000) + '\\n… [truncated]' : input;\n 1465→ block.appendChild(pre);\n 1466→ }\n 1467→ addSection('Tool call', block);\n 1468→ }\n 1469→ }\n 1470→\n 1471→ if (payloadType === 'custom_tool_call_output') {\n 1472→ const out = typeof payload?.output === 'string' ? payload.output : '';\n 1473→ if (out) {\n 1474→ const body = document.createElement('div');\n 1475→ body.className = 'pretty-content';\n 1476→ const pre = document.createElement('pre');\n 1477→ pre.textContent = out.length > 60_000 ? out.slice(0, 60_000) + '\\n… [truncated]' : out;\n 1478→ body.appendChild(pre);\n 1479→ addSection('Tool output', body);\n 1480→ }\n 1481→ }\n 1482→\n 1483→ if (hasContent) {\n 1484→ els.detailPretty.appendChild(fragment);\n 1485→ els.detailPretty.hidden = false;\n 1486→ } else {\n 1487→ els.detailPretty.hidden = true;\n 1488→ }\n 1489→ }\n 1490→\n 1491→ function stringifyJson(value, truncateLongStrings) {\n 1492→ const maxLen = 8000;\n 1493→ return JSON.stringify(value, (key, v) => {\n 1494→ if (!truncateLongStrings) return v;\n 1495→ if (typeof v === 'string' && v.length > maxLen) {\n 1496→ return v.slice(0, maxLen) + `… [truncated ${v.length - maxLen} chars]`;\n 1497→ }\n 1498→ return v;\n 1499→ }, 2);\n 1500→ }\n 1501→\n 1502→ function updateFilterOptions(events) {\n 1503→ const typeSet = new Set();\n 1504→ const payloadSet = new Set();\n 1505→ const roleSet = new Set();\n 1506→ for (const e of events) {\n 1507→ if (e.kind) typeSet.add(e.kind);\n 1508→ if (e.payloadType !== null && e.payloadType !== undefined) payloadSet.add(e.payloadType);\n 1509→ else payloadSet.add('(none)');\n 1510→ if (e.role) roleSet.add(e.role);\n 1511→ }\n 1512→\n 1513→ function setOptions(select, items, current) {\n 1514→ select.innerHTML = '';\n 1515→ const optAll = document.createElement('option');\n 1516→ optAll.value = 'all';\n 1517→ optAll.textContent = 'All';\n 1518→ select.appendChild(optAll);\n 1519→ for (const item of items) {\n 1520→ const opt = document.createElement('option');\n 1521→ opt.value = item;\n 1522→ opt.textContent = item;\n 1523→ select.appendChild(opt);\n 1524→ }\n 1525→ select.value = current;\n 1526→ }\n 1527→\n 1528→ setOptions(els.typeFilter, Array.from(typeSet).sort(), state.filters.type);\n 1529→ setOptions(els.payloadFilter, Array.from(payloadSet).sort(), state.filters.payload);\n 1530→ setOptions(els.roleFilter, Array.from(roleSet).sort(), state.filters.role);\n 1531→\n 1532→ // Only clamp unknown filter values once we have real options (i.e. after loading a file).\n 1533→ if (events.length) {\n 1534→ state.filters.type = els.typeFilter.value || 'all';\n 1535→ state.filters.payload = els.payloadFilter.value || 'all';\n 1536→ state.filters.role = els.roleFilter.value || 'all';\n 1537→ }\n 1538→ }\n 1539→\n 1540→ function readHashParams() {\n 1541→ const raw = window.location.hash && window.location.hash.startsWith('#')\n 1542→ ? window.location.hash.slice(1)\n 1543→ : '';\n 1544→ return new URLSearchParams(raw);\n 1545→ }\n 1546→\n 1547→ function writeHashParams(params) {\n 1548→ const base = window.location.href.split('#')[0];\n 1549→ const hash = params.toString();\n 1550→ const next = base + (hash ? `#${hash}` : '');\n 1551→ window.history.replaceState(null, '', next);\n 1552→ }\n 1553→\n 1554→ function syncUrlHashFromControls({ clearSel = false } = {}) {\n 1555→ if (suppressUrlStateSync) return;\n 1556→ const params = readHashParams();\n 1557→ params.set('tz', state.tzMode);\n 1558→ params.set('q', (els.search.value || '').trim());\n 1559→ params.set('type', state.filters.type || 'all');\n 1560→ params.set('payload', state.filters.payload || 'all');\n 1561→ params.set('role', state.filters.role || 'all');\n 1562→ params.set('hide', state.filters.hideTokenCount ? '1' : '0');\n 1563→ params.set('truncate', els.truncateStrings.checked ? '1' : '0');\n 1564→ const selectedLine = state.selected?.line;\n 1565→ if (typeof selectedLine === 'number' && Number.isFinite(selectedLine)) {\n 1566→ params.set('sel', String(selectedLine));\n 1567→ } else if (clearSel) {\n 1568→ params.delete('sel');\n 1569→ }\n 1570→ writeHashParams(params);\n 1571→ }\n 1572→\n 1573→ function applySelectionFromHash() {\n 1574→ if (!state.events.length) return;\n 1575→ const params = readHashParams();\n 1576→ const raw = params.get('sel');\n 1577→ if (!raw) return;\n 1578→ const line = Number(raw);\n 1579→ if (!Number.isFinite(line)) return;\n 1580→ const found = state.events.find(e => e.line === line);\n 1581→ if (!found) return;\n 1582→ if (state.selectedId === found.id) return;\n 1583→ suppressUrlStateSync = true;\n 1584→ try {\n 1585→ selectEvent(found.id, { scroll: true });\n 1586→ } finally {\n 1587→ suppressUrlStateSync = false;\n 1588→ }\n 1589→ }\n 1590→\n 1591→ function applyControlsFromHash() {\n 1592→ const params = readHashParams();\n 1593→ suppressUrlStateSync = true;\n 1594→ try {\n 1595→ const q = params.get('q');\n 1596→ if (q !== null) {\n 1597→ els.search.value = q;\n 1598→ state.filters.query = q.trim().toLowerCase();\n 1599→ }\n 1600→\n 1601→ const type = params.get('type');\n 1602→ if (type !== null) state.filters.type = type;\n 1603→ const payload = params.get('payload');\n 1604→ if (payload !== null) state.filters.payload = payload;\n 1605→ const role = params.get('role');\n 1606→ if (role !== null) state.filters.role = role;\n 1607→\n 1608→ const hide = params.get('hide');\n 1609→ if (hide !== null) {\n 1610→ els.hideTokenCount.checked = hide === '1' || hide === 'true';\n 1611→ state.filters.hideTokenCount = els.hideTokenCount.checked;\n 1612→ }\n 1613→\n 1614→ const truncate = params.get('truncate');\n 1615→ if (truncate !== null) {\n 1616→ els.truncateStrings.checked = truncate === '1' || truncate === 'true';\n 1617→ }\n 1618→\n 1619→ const tz = params.get('tz');\n 1620→ if (tz === 'utc' || tz === 'local') {\n 1621→ state.tzMode = tz;\n 1622→ els.tzLocal.classList.toggle('active', tz === 'local');\n 1623→ els.tzUtc.classList.toggle('active', tz === 'utc');\n 1624→ }\n 1625→ } finally {\n 1626→ suppressUrlStateSync = false;\n 1627→ }\n 1628→ }\n 1629→\n 1630→ function applyFilters() {\n 1631→ const query = state.filters.query;\n 1632→ const type = state.filters.type;\n 1633→ const payload = state.filters.payload;\n 1634→ const role = state.filters.role;\n 1635→ const hideTokenCount = state.filters.hideTokenCount;\n 1636→\n 1637→ const out = [];\n 1638→ for (const e of state.events) {\n 1639→ if (hideTokenCount && e.payloadType === 'token_count') continue;\n 1640→ if (type !== 'all' && e.kind !== type) continue;\n 1641→ if (payload !== 'all') {\n 1642→ const pt = e.payloadType ?? '(none)';\n 1643→ if (pt !== payload) continue;\n 1644→ }\n 1645→ if (role !== 'all' && e.role !== role) continue;\n 1646→ if (query && !e.search.includes(query)) continue;\n 1647→ out.push(e);\n 1648→ }\n 1649→\n 1650→ state.filtered = out;\n 1651→ els.statsShowing.textContent = String(out.length);\n 1652→ els.statsTotal.textContent = String(state.events.length);\n 1653→\n 1654→ const start = out.find(e => e.date instanceof Date && !Number.isNaN(e.date.getTime()))?.date;\n 1655→ const end = [...out].reverse().find(e => e.date instanceof Date && !Number.isNaN(e.date.getTime()))?.date;\n 1656→ if (start && end) {\n 1657→ els.statsRange.textContent = `${formatTimestamp(start, state.tzMode)} → ${formatTimestamp(end, state.tzMode)}`;\n 1658→ } else {\n 1659→ els.statsRange.textContent = '—';\n 1660→ }\n 1661→\n 1662→ renderTimeline();\n 1663→ }\n 1664→\n 1665→\t function clearSelection({ syncHash = true } = {}) {\n 1666→\t state.selectedId = null;\n 1667→\t state.selected = null;\n 1668→\t els.detailTitle.textContent = 'Select an event';\n 1669→\t renderDetailImages(null);\n 1670→ els.detailPretty.innerHTML = '';\n 1671→ els.detailPretty.hidden = true;\n 1672→\t els.detailJson.textContent = state.events.length ? 'Select an event from the timeline.' : 'Load a file to get started.';\n 1673→\t els.detailJson.classList.add('empty');\n 1674→\t els.copyJson.disabled = true;\n 1675→\t els.copyRaw.disabled = true;\n 1676→\t for (const node of els.timeline.querySelectorAll('.event.selected')) {\n 1677→\t node.classList.remove('selected');\n 1678→\t }\n 1679→\t updateDetailPlacement();\n 1680→\t if (syncHash) syncUrlHashFromControls({ clearSel: true });\n 1681→\t }\n 1682→\n 1683→ function renderTimeline() {\n 1684→ els.timeline.innerHTML = '';\n 1685→\n 1686→ if (!state.filtered.length) {\n 1687→ const empty = document.createElement('div');\n 1688→ empty.className = 'empty';\n 1689→ empty.textContent = state.events.length ? 'No events match your filters.' : 'Load a file to see a timeline.';\n 1690→ els.timeline.appendChild(empty);\n 1691→ clearSelection();\n 1692→ return;\n 1693→ }\n 1694→\n 1695→ const fragment = document.createDocumentFragment();\n 1696→ let lastDay = null;\n 1697→ let prevDate = null;\n 1698→\n 1699→ for (const e of state.filtered) {\n 1700→ const day = dayKey(e.date, state.tzMode);\n 1701→ if (day !== lastDay) {\n 1702→ lastDay = day;\n 1703→ const sep = document.createElement('div');\n 1704→ sep.className = 'day-sep';\n 1705→ sep.textContent = day;\n 1706→ fragment.appendChild(sep);\n 1707→ }\n 1708→\n 1709→ const row = document.createElement('div');\n 1710→ row.className = 'event';\n 1711→ row.setAttribute('role', 'listitem');\n 1712→ row.dataset.eventId = String(e.id);\n 1713→\n 1714→ const time = document.createElement('div');\n 1715→ time.className = 'time';\n 1716→\n 1717→ const ts = document.createElement('div');\n 1718→ ts.className = 'ts';\n 1719→ ts.textContent = formatTimestamp(e.date, state.tzMode);\n 1720→ ts.dataset.kind = 'timestamp';\n 1721→\n 1722→ const meta = document.createElement('div');\n 1723→ meta.textContent = [\n 1724→ `#${e.line}`,\n 1725→ (prevDate && e.date) ? formatDelta(e.date.getTime() - prevDate.getTime()) : null,\n 1726→ ].filter(Boolean).join(' · ');\n 1727→\n 1728→ time.appendChild(ts);\n 1729→ time.appendChild(meta);\n 1730→\n 1731→ const body = document.createElement('div');\n 1732→\n 1733→ const badges = document.createElement('div');\n 1734→ badges.className = 'badges';\n 1735→ const badgeKind = document.createElement('span');\n 1736→ badgeKind.className = `badge k-${e.kind}`;\n 1737→ badgeKind.textContent = e.kind;\n 1738→ badges.appendChild(badgeKind);\n 1739→\n 1740→ if (e.payloadType) {\n 1741→ const badgePayload = document.createElement('span');\n 1742→ badgePayload.className = 'badge';\n 1743→ badgePayload.textContent = e.payloadType;\n 1744→ badges.appendChild(badgePayload);\n 1745→ }\n 1746→\n 1747→ if (e.role) {\n 1748→ const badgeRole = document.createElement('span');\n 1749→ badgeRole.className = `badge role-${e.role}`;\n 1750→ badgeRole.textContent = e.role;\n 1751→ badges.appendChild(badgeRole);\n 1752→ }\n 1753→\n 1754→ const summary = document.createElement('div');\n 1755→ summary.className = 'summary';\n 1756→ summary.textContent = e.summary;\n 1757→\n 1758→ body.appendChild(badges);\n 1759→ body.appendChild(summary);\n 1760→\n 1761→ row.appendChild(time);\n 1762→ row.appendChild(body);\n 1763→\n 1764→ row.addEventListener('click', () => selectEvent(e.id, { focusTimeline: true }));\n 1765→ fragment.appendChild(row);\n 1766→\n 1767→ if (e.date instanceof Date && !Number.isNaN(e.date.getTime())) prevDate = e.date;\n 1768→ }\n 1769→\n 1770→ els.timeline.appendChild(fragment);\n 1771→\n 1772→ if (state.selectedId !== null) {\n 1773→ const stillThere = state.filtered.some(e => e.id === state.selectedId);\n 1774→ if (!stillThere) clearSelection();\n 1775→ else selectEvent(state.selectedId, { scroll: false });\n 1776→ } else {\n 1777→ clearSelection({ syncHash: false });\n 1778→ }\n 1779→ }\n 1780→\n 1781→\t function selectEvent(id, { scroll = false, focusTimeline = false } = {}) {\n 1782→\t const e = state.filtered.find(item => item.id === id) || state.events.find(item => item.id === id);\n 1783→\t if (!e) return;\n 1784→\t state.selectedId = id;\n 1785→\t state.selected = e;\n 1786→\n 1787→ if (focusTimeline) {\n 1788→ els.timeline.focus({ preventScroll: true });\n 1789→ }\n 1790→\n 1791→ for (const node of els.timeline.querySelectorAll('.event.selected')) {\n 1792→ node.classList.remove('selected');\n 1793→ }\n 1794→\t const node = els.timeline.querySelector(`.event[data-event-id=\"${CSS.escape(String(id))}\"]`);\n 1795→\t if (node) {\n 1796→\t node.classList.add('selected');\n 1797→\t updateDetailPlacement();\n 1798→\t if (scroll) node.scrollIntoView({ block: 'center' });\n 1799→\t }\n 1800→\n 1801→\t els.detailJson.classList.remove('empty');\n 1802→\t const titleParts = [\n 1803→ formatTimestamp(e.date, state.tzMode),\n 1804→ `#${e.line}`,\n 1805→ e.kind,\n 1806→ e.payloadType ? `· ${e.payloadType}` : null,\n 1807→ ].filter(Boolean);\n 1808→ els.detailTitle.textContent = titleParts.join(' ');\n 1809→\n 1810→ const shouldTruncate = els.truncateStrings.checked;\n 1811→ renderDetailImages(e.obj);\n 1812→ renderPrettyView(e.obj);\n 1813→\t els.detailJson.textContent = stringifyJson(e.obj, shouldTruncate) || '';\n 1814→\t els.copyJson.disabled = false;\n 1815→\t els.copyRaw.disabled = false;\n 1816→\t updateDetailPlacement();\n 1817→\t syncUrlHashFromControls();\n 1818→\t }\n 1819→\n 1820→ function extractImageUrls(obj) {\n 1821→ const payloadImages = obj?.payload?.images;\n 1822→ if (Array.isArray(payloadImages)) return payloadImages.filter(v => typeof v === 'string' && v.trim());\n 1823→ const rootImages = obj?.images;\n 1824→ if (Array.isArray(rootImages)) return rootImages.filter(v => typeof v === 'string' && v.trim());\n 1825→ return [];\n 1826→ }\n 1827→\n 1828→ function extractUserPrompts() {\n 1829→ const prompts = [];\n 1830→\n 1831→ const canonicalize = (text) => String(text ?? '')\n 1832→ .replace(/\\r\\n/g, '\\n')\n 1833→ .replace(/[ \\t]+\\n/g, '\\n')\n 1834→ .trim();\n 1835→\n 1836→ const isEnvContextBlock = (text) => {\n 1837→ const t = canonicalize(text);\n 1838→ return t.startsWith('<environment_context>') && t.includes('</environment_context>');\n 1839→ };\n 1840→\n 1841→ const hasUserMessageEvents = state.events.some((e) => e?.obj?.payload?.type === 'user_message');\n 1842→\n 1843→ let lastCanonical = null;\n 1844→ const pushPrompt = (text) => {\n 1845→ const canon = canonicalize(text);\n 1846→ if (!canon) return;\n 1847→ if (canon === lastCanonical) return; // adjacent dedupe\n 1848→ prompts.push(canon);\n 1849→ lastCanonical = canon;\n 1850→ };\n 1851→\n 1852→ for (const e of state.events) {\n 1853→ const payload = e?.obj?.payload;\n 1854→ if (!payload || typeof payload !== 'object') continue;\n 1855→ const payloadType = payload.type;\n 1856→\n 1857→ if (payloadType === 'user_message' && typeof payload.message === 'string') {\n 1858→ pushPrompt(payload.message);\n 1859→ continue;\n 1860→ }\n 1861→\n 1862→ if (payloadType === 'message' && payload.role === 'user') {\n 1863→ const msg = extractMessageText(payload);\n 1864→ if (!msg) continue;\n 1865→ if (!hasUserMessageEvents) {\n 1866→ pushPrompt(msg);\n 1867→ continue;\n 1868→ }\n 1869→ // When user_message events exist, only include environment context blocks from message events.\n 1870→ if (isEnvContextBlock(msg)) pushPrompt(msg);\n 1871→ }\n 1872→ }\n 1873→\n 1874→ return prompts.join('\\n\\n').trim();\n 1875→ }\n 1876→\n 1877→ function openPromptsModal(text) {\n 1878→ lastActiveElementBeforePrompts = document.activeElement instanceof HTMLElement ? document.activeElement : null;\n 1879→ lastBodyOverflowBeforePrompts = document.body.style.overflow;\n 1880→ document.body.style.overflow = 'hidden';\n 1881→\n 1882→ els.promptsText.value = text || '';\n 1883→ els.promptsModal.classList.add('open');\n 1884→ els.promptsModal.setAttribute('aria-hidden', 'false');\n 1885→ els.promptsClose.focus();\n 1886→ els.promptsText.scrollTop = 0;\n 1887→ }\n 1888→\n 1889→ function closePromptsModal() {\n 1890→ if (!els.promptsModal.classList.contains('open')) return;\n 1891→ els.promptsModal.classList.remove('open');\n 1892→ els.promptsModal.setAttribute('aria-hidden', 'true');\n 1893→ els.promptsText.value = '';\n 1894→ document.body.style.overflow = lastBodyOverflowBeforePrompts ?? '';\n 1895→\n 1896→ if (lastActiveElementBeforePrompts) lastActiveElementBeforePrompts.focus();\n 1897→ lastActiveElementBeforePrompts = null;\n 1898→ lastBodyOverflowBeforePrompts = null;\n 1899→ }\n 1900→\n 1901→ function openImageModal(src, altText = '') {\n 1902→ if (!src) return;\n 1903→ lastActiveElementBeforeModal = document.activeElement instanceof HTMLElement ? document.activeElement : null;\n 1904→ lastBodyOverflow = document.body.style.overflow;\n 1905→ document.body.style.overflow = 'hidden';\n 1906→\n 1907→ els.imageModalImg.src = src;\n 1908→ els.imageModalImg.alt = altText;\n 1909→ els.imageModal.classList.add('open');\n 1910→ els.imageModal.setAttribute('aria-hidden', 'false');\n 1911→ els.imageModalClose.focus();\n 1912→ }\n 1913→\n 1914→ function closeImageModal() {\n 1915→ if (!els.imageModal.classList.contains('open')) return;\n 1916→ els.imageModal.classList.remove('open');\n 1917→ els.imageModal.setAttribute('aria-hidden', 'true');\n 1918→ els.imageModalImg.removeAttribute('src');\n 1919→ els.imageModalImg.alt = '';\n 1920→ document.body.style.overflow = lastBodyOverflow ?? '';\n 1921→\n 1922→ if (lastActiveElementBeforeModal) {\n 1923→ lastActiveElementBeforeModal.focus();\n 1924→ }\n 1925→ lastActiveElementBeforeModal = null;\n 1926→ lastBodyOverflow = null;\n 1927→ }\n 1928→\n 1929→ function renderDetailImages(obj) {\n 1930→ const urls = extractImageUrls(obj);\n 1931→ els.detailImages.innerHTML = '';\n 1932→ if (!urls.length) {\n 1933→ els.detailImages.hidden = true;\n 1934→ return;\n 1935→ }\n 1936→ const fragment = document.createDocumentFragment();\n 1937→ urls.forEach((src, idx) => {\n 1938→ const btn = document.createElement('button');\n 1939→ btn.type = 'button';\n 1940→ btn.className = 'thumb';\n 1941→ btn.title = `Open image ${idx + 1}`;\n 1942→ const img = document.createElement('img');\n 1943→ img.loading = 'lazy';\n 1944→ img.src = src;\n 1945→ img.alt = `Image ${idx + 1}`;\n 1946→ btn.appendChild(img);\n 1947→ btn.addEventListener('click', () => openImageModal(src, `Image ${idx + 1}`));\n 1948→ fragment.appendChild(btn);\n 1949→ });\n 1950→ els.detailImages.appendChild(fragment);\n 1951→ els.detailImages.hidden = false;\n 1952→ }\n 1953→\n 1954→ function isEditableElement(el) {\n 1955→ if (!(el instanceof HTMLElement)) return false;\n 1956→ if (el.isContentEditable) return true;\n 1957→ const tag = el.tagName;\n 1958→ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';\n 1959→ }\n 1960→\n 1961→ function moveSelection(delta) {\n 1962→ if (!state.filtered.length) return;\n 1963→ const idx = state.filtered.findIndex(e => e.id === state.selectedId);\n 1964→ if (idx === -1) {\n 1965→ selectEvent(state.filtered[0].id, { scroll: true });\n 1966→ return;\n 1967→ }\n 1968→ const nextIdx = Math.max(0, Math.min(state.filtered.length - 1, idx + delta));\n 1969→ if (nextIdx === idx) return;\n 1970→ selectEvent(state.filtered[nextIdx].id, { scroll: true });\n 1971→ }\n 1972→\n 1973→ async function copyText(text) {\n 1974→ if (typeof text !== 'string') return;\n 1975→ try {\n 1976→ await navigator.clipboard.writeText(text);\n 1977→ setStatus('Copied to clipboard.', 'good');\n 1978→ } catch (error) {\n 1979→ const ta = document.createElement('textarea');\n 1980→ ta.value = text;\n 1981→ ta.style.position = 'fixed';\n 1982→ ta.style.left = '-9999px';\n 1983→ document.body.appendChild(ta);\n 1984→ ta.select();\n 1985→ document.execCommand('copy');\n 1986→ document.body.removeChild(ta);\n 1987→ setStatus('Copied to clipboard.', 'good');\n 1988→ }\n 1989→ }\n 1990→\n 1991→ function setTimezoneMode(mode) {\n 1992→ state.tzMode = mode;\n 1993→ els.tzLocal.classList.toggle('active', mode === 'local');\n 1994→ els.tzUtc.classList.toggle('active', mode === 'utc');\n 1995→ applyFilters();\n 1996→ if (state.selectedId !== null) selectEvent(state.selectedId);\n 1997→ syncUrlHashFromControls();\n 1998→ }\n 1999→\n 2000→ els.promptsClose.addEventListener('click', closePromptsModal);\n 2001→ els.promptsModal.addEventListener('click', (event) => {\n 2002→ if (event.target === els.promptsModal) closePromptsModal();\n 2003→ });\n 2004→ els.promptsCopy.addEventListener('click', async () => {\n 2005→ const text = els.promptsText.value || '';\n 2006→ if (!text.trim()) {\n 2007→ setStatus('No user prompts found.', 'warn');\n 2008→ return;\n 2009→ }\n 2010→ await copyText(text);\n 2011→ if (promptsCopiedTimer) clearTimeout(promptsCopiedTimer);\n 2012→ const originalText = els.promptsCopy.textContent;\n 2013→ els.promptsCopy.textContent = 'Copied!';\n 2014→ promptsCopiedTimer = setTimeout(() => {\n 2015→ els.promptsCopy.textContent = originalText;\n 2016→ promptsCopiedTimer = null;\n 2017→ }, 1500);\n 2018→ });\n 2019→ els.showPrompts.addEventListener('click', () => {\n 2020→ const text = extractUserPrompts();\n 2021→ if (!text) {\n 2022→ setStatus('No user prompts found.', 'warn');\n 2023→ return;\n 2024→ }\n 2025→ openPromptsModal(text);\n 2026→ });\n 2027→\n 2028→ els.imageModalClose.addEventListener('click', closeImageModal);\n 2029→ els.imageModal.addEventListener('click', (event) => {\n 2030→ if (event.target === els.imageModal) closeImageModal();\n 2031→ });\n 2032→ document.addEventListener('keydown', (event) => {\n 2033→ if (event.key !== 'Escape') return;\n 2034→ if (els.promptsModal.classList.contains('open')) closePromptsModal();\n 2035→ if (els.imageModal.classList.contains('open')) closeImageModal();\n 2036→ });\n 2037→\n 2038→\t document.addEventListener('keydown', (event) => {\n 2039→\t if (els.imageModal.classList.contains('open')) return;\n 2040→ if (els.promptsModal.classList.contains('open')) return;\n 2041→\t if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;\n 2042→\t if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;\n 2043→\t if (isEditableElement(document.activeElement)) return;\n 2044→\t if (document.activeElement !== els.timeline && !els.timeline.contains(document.activeElement)) return;\n 2045→\t if (state.selectedId === null) return;\n 2046→\t event.preventDefault();\n 2047→\t moveSelection(event.key === 'ArrowUp' ? -1 : 1);\n 2048→\t });\n 2049→\n 2050→\t if (typeof wideLayoutMq.addEventListener === 'function') {\n 2051→\t wideLayoutMq.addEventListener('change', updateDetailPlacement);\n 2052→\t } else if (typeof wideLayoutMq.addListener === 'function') {\n 2053→\t wideLayoutMq.addListener(updateDetailPlacement);\n 2054→\t }\n 2055→\n 2056→\t function setTab(tabName) {\n 2057→\t for (const tab of document.querySelectorAll('.tab')) {\n 2058→\t const active = tab.dataset.tab === tabName;\n 2059→\t tab.classList.toggle('active', active);\n 2060→\t tab.setAttribute('aria-selected', active ? 'true' : 'false');\n 2061→\t }\n 2062→\t for (const panel of document.querySelectorAll('[data-panel]')) {\n 2063→\t panel.hidden = panel.dataset.panel !== tabName;\n 2064→\t }\n 2065→\t }\n 2066→\n 2067→\t function setShareUrlParam(url) {\n 2068→\t const current = new URL(window.location.href);\n 2069→\t if (url) current.searchParams.set('url', url);\n 2070→\t else current.searchParams.delete('url');\n 2071→\t window.history.replaceState(null, '', current);\n 2072→\t }\n 2073→\n 2074→\t async function fetchAndRenderUrl(url, { syncUrl = true } = {}) {\n 2075→\t const trimmed = (url || '').trim();\n 2076→\t if (!trimmed) {\n 2077→\t setStatus('Enter a URL first.', 'error');\n 2078→\t return;\n 2079→\t }\n 2080→\t if (syncUrl) setShareUrlParam(trimmed);\n 2081→\t setStatus(`Fetching ${trimmed}…`, 'warn');\n 2082→\t try {\n 2083→\t const resp = await fetch(trimmed, { mode: 'cors' });\n 2084→\t if (!resp.ok) throw new Error(`HTTP ${resp.status}`);\n 2085→\t const text = await resp.text();\n 2086→\t await loadText(text, trimmed);\n 2087→\t } catch (error) {\n 2088→\t setStatus(`Failed to fetch URL: ${String(error)}`, 'error');\n 2089→\t }\n 2090→\t }\n 2091→\n 2092→\t async function loadText(text, sourceLabel) {\n 2093→\t const started = performance.now();\n 2094→\t state.sourceLabel = sourceLabel || null;\n 2095→\t setStatus(`Parsing ${sourceLabel || 'content'}…`, 'warn');\n 2096→ closePromptsModal();\n 2097→ els.showPrompts.disabled = true;\n 2098→\t clearSelection({ syncHash: false });\n 2099→\n 2100→ const lines = text.split(/\\r?\\n/);\n 2101→ const events = [];\n 2102→ const errors = [];\n 2103→ let idCounter = 0;\n 2104→\n 2105→ for (let i = 0; i < lines.length; i++) {\n 2106→ const line = lines[i];\n 2107→ if (!line.trim()) continue;\n 2108→ const parsed = safeJsonParse(line);\n 2109→ if (!parsed.ok) {\n 2110→ errors.push({ line: i + 1, error: parsed.error });\n 2111→ continue;\n 2112→ }\n 2113→ const obj = parsed.value;\n 2114→ const ts = typeof obj.timestamp === 'string' ? new Date(obj.timestamp) : null;\n 2115→ const kind = obj.type || '(unknown)';\n 2116→ const payload = obj.payload && typeof obj.payload === 'object' ? obj.payload : null;\n 2117→ const payloadType = payload?.type ?? null;\n 2118→ const role = payload?.role ?? null;\n 2119→\n 2120→ const summary = summarizeEvent(obj);\n 2121→ events.push({\n 2122→ id: ++idCounter,\n 2123→ line: i + 1,\n 2124→ raw: line,\n 2125→ obj,\n 2126→ date: ts,\n 2127→ kind,\n 2128→ payloadType,\n 2129→ role,\n 2130→ summary,\n 2131→ search: buildSearchText(obj, summary),\n 2132→ });\n 2133→ }\n 2134→\n 2135→ state.events = events;\n 2136→ els.showPrompts.disabled = !events.length;\n 2137→ updateFilterOptions(events);\n 2138→ applyFilters();\n 2139→ applySelectionFromHash();\n 2140→ syncUrlHashFromControls();\n 2141→\n 2142→ const durationMs = Math.round(performance.now() - started);\n 2143→ if (errors.length) {\n 2144→ const first = errors[0];\n 2145→ setStatus(\n 2146→ `Loaded ${events.length} events from ${sourceLabel || 'content'} in ${durationMs}ms.\\n` +\n 2147→ `Could not parse ${errors.length} line(s). First error at line ${first.line}: ${String(first.error)}`,\n 2148→ 'warn'\n 2149→ );\n 2150→ } else {\n 2151→ setStatus(`Loaded ${events.length} events from ${sourceLabel || 'content'} in ${durationMs}ms.`, 'good');\n 2152→ }\n 2153→ }\n 2154→\n 2155→\t async function handleFile(file) {\n 2156→\t if (!file) return;\n 2157→\t setShareUrlParam(null);\n 2158→\t try {\n 2159→\t const text = await file.text();\n 2160→\t await loadText(text, file.name);\n 2161→\t } catch (error) {\n 2162→\t setStatus(`Failed to read file: ${String(error)}`, 'error');\n 2163→ }\n 2164→ }\n 2165→\n 2166→ els.dropzone.addEventListener('click', () => els.fileInput.click());\n 2167→ els.dropzone.addEventListener('keydown', (e) => {\n 2168→ if (e.key === 'Enter' || e.key === ' ') {\n 2169→ e.preventDefault();\n 2170→ els.fileInput.click();\n 2171→ }\n 2172→ });\n 2173→ els.fileInput.addEventListener('change', () => handleFile(els.fileInput.files?.[0]));\n 2174→\n 2175→ els.dropzone.addEventListener('dragover', (e) => {\n 2176→ e.preventDefault();\n 2177→ els.dropzone.classList.add('dragover');\n 2178→ });\n 2179→ els.dropzone.addEventListener('dragleave', () => els.dropzone.classList.remove('dragover'));\n 2180→ els.dropzone.addEventListener('drop', (e) => {\n 2181→ e.preventDefault();\n 2182→ els.dropzone.classList.remove('dragover');\n 2183→ const file = e.dataTransfer?.files?.[0];\n 2184→ if (file) handleFile(file);\n 2185→ });\n 2186→\n 2187→\t els.renderPaste.addEventListener('click', async () => {\n 2188→\t const text = els.pasteInput.value || '';\n 2189→\t if (!text.trim()) {\n 2190→\t setStatus('Paste some JSONL content first.', 'error');\n 2191→\t return;\n 2192→\t }\n 2193→\t setShareUrlParam(null);\n 2194→\t await loadText(text, 'pasted content');\n 2195→\t });\n 2196→\n 2197→\t els.fetchUrl.addEventListener('click', async () => {\n 2198→\t await fetchAndRenderUrl(els.urlInput.value, { syncUrl: true });\n 2199→\t });\n 2200→\n 2201→ for (const tab of document.querySelectorAll('.tab')) {\n 2202→ tab.addEventListener('click', () => setTab(tab.dataset.tab));\n 2203→ }\n 2204→\n 2205→ els.tzLocal.addEventListener('click', () => setTimezoneMode('local'));\n 2206→ els.tzUtc.addEventListener('click', () => setTimezoneMode('utc'));\n 2207→\n 2208→ els.search.addEventListener('input', () => {\n 2209→ state.filters.query = els.search.value.trim().toLowerCase();\n 2210→ applyFilters();\n 2211→ syncUrlHashFromControls();\n 2212→ });\n 2213→ els.typeFilter.addEventListener('change', () => {\n 2214→ state.filters.type = els.typeFilter.value;\n 2215→ applyFilters();\n 2216→ syncUrlHashFromControls();\n 2217→ });\n 2218→ els.payloadFilter.addEventListener('change', () => {\n 2219→ state.filters.payload = els.payloadFilter.value;\n 2220→ applyFilters();\n 2221→ syncUrlHashFromControls();\n 2222→ });\n 2223→ els.roleFilter.addEventListener('change', () => {\n 2224→ state.filters.role = els.roleFilter.value;\n 2225→ applyFilters();\n 2226→ syncUrlHashFromControls();\n 2227→ });\n 2228→ els.hideTokenCount.addEventListener('change', () => {\n 2229→ state.filters.hideTokenCount = els.hideTokenCount.checked;\n 2230→ applyFilters();\n 2231→ syncUrlHashFromControls();\n 2232→ });\n 2233→ els.truncateStrings.addEventListener('change', () => {\n 2234→ if (state.selectedId !== null) selectEvent(state.selectedId);\n 2235→ syncUrlHashFromControls();\n 2236→ });\n 2237→\n 2238→ els.clearFilters.addEventListener('click', () => {\n 2239→ els.search.value = '';\n 2240→ state.filters.query = '';\n 2241→ state.filters.type = 'all';\n 2242→ state.filters.payload = 'all';\n 2243→ state.filters.role = 'all';\n 2244→ state.filters.hideTokenCount = true;\n 2245→ els.hideTokenCount.checked = true;\n 2246→ updateFilterOptions(state.events);\n 2247→ applyFilters();\n 2248→ syncUrlHashFromControls();\n 2249→ });\n 2250→\n 2251→ els.copyJson.addEventListener('click', () => {\n 2252→ if (!state.selected) return;\n 2253→ copyText(stringifyJson(state.selected.obj, false));\n 2254→ });\n 2255→\t els.copyRaw.addEventListener('click', () => {\n 2256→\t if (!state.selected) return;\n 2257→\t copyText(state.selected.raw);\n 2258→\t });\n 2259→\n 2260→\t async function initFromQuery() {\n 2261→\t const url = new URLSearchParams(window.location.search).get('url');\n 2262→\t if (!url) return;\n 2263→\t setTab('url');\n 2264→\t els.urlInput.value = url;\n 2265→\t await fetchAndRenderUrl(url, { syncUrl: false });\n 2266→\t }\n 2267→\n 2268→\t // Initialize select options for the empty state.\n 2269→ applyControlsFromHash();\n 2270→\t updateFilterOptions([]);\n 2271→ syncUrlHashFromControls();\n 2272→ window.addEventListener('hashchange', () => {\n 2273→ applyControlsFromHash();\n 2274→ updateFilterOptions(state.events);\n 2275→ applyFilters();\n 2276→ applySelectionFromHash();\n 2277→ });\n 2278→\t void initFromQuery();\n 2279→\t </script>\n 2280→</body>\n 2281→</html>\n 2282→\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"uuid":"c6bd18f0-9245-489e-8829-399f5f4d9089","timestamp":"2025-12-18T05:51:53.719Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/simon/Dropbox/dev/tools/codex-timeline.html","content":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>Codex Timeline Viewer</title>\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js\" integrity=\"sha384-osZDKVu4ipZP703HmPOhWdyBajcFyjX2Psjk//TG1Rc0AdwEtuToaylrmcK3LdAl\" crossorigin=\"anonymous\"></script>\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/markdown-it/4.4.0/markdown-it.min.js\" integrity=\"sha384-cxGp+QDeMxACP73aA0Lj0Sc/h4z1X73+umcvCyI0Vbyh3OYxgrPI+0mxG9slsMTN\" crossorigin=\"anonymous\"></script>\n <style>\n :root {\n color-scheme: light dark;\n --bg: #0b0c0f;\n --panel: #12141a;\n --panel-2: #0f1116;\n --text: #e8eaf0;\n --muted: #a6adbf;\n --line: rgba(255, 255, 255, 0.10);\n --line-2: rgba(255, 255, 255, 0.16);\n --accent: #6aa9ff;\n --accent-2: #8a5cff;\n --good: #46d39a;\n --warn: #ffcc66;\n --bad: #ff6b6b;\n --shadow: 0 18px 50px rgba(0, 0, 0, 0.35);\n --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\",\n \"Courier New\", monospace;\n --sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial,\n \"Apple Color Emoji\", \"Segoe UI Emoji\";\n }\n\n @media (prefers-color-scheme: light) {\n :root {\n --bg: #f7f8fc;\n --panel: #ffffff;\n --panel-2: #ffffff;\n --text: #10121a;\n --muted: #5b647a;\n --line: rgba(16, 18, 26, 0.12);\n --line-2: rgba(16, 18, 26, 0.18);\n --shadow: 0 18px 50px rgba(16, 18, 26, 0.10);\n }\n }\n\n * { box-sizing: border-box; }\n html, body { height: 100%; }\n body {\n margin: 0;\n font-family: var(--sans);\n background: radial-gradient(1200px 600px at 10% -10%, rgba(106, 169, 255, 0.18), transparent 45%),\n radial-gradient(1200px 600px at 90% -20%, rgba(138, 92, 255, 0.16), transparent 50%),\n var(--bg);\n color: var(--text);\n }\n\n .wrap {\n max-width: none;\n margin: 0;\n padding: 22px 16px 42px;\n }\n\n header {\n display: grid;\n gap: 10px;\n margin-bottom: 14px;\n }\n\n h1 {\n margin: 0;\n font-size: clamp(22px, 3vw, 32px);\n letter-spacing: 0.2px;\n }\n\n .sub {\n margin: 6px 0 0;\n color: var(--muted);\n line-height: 1.35;\n max-width: 70ch;\n }\n\n .controls {\n display: flex;\n flex-wrap: wrap;\n gap: 10px;\n align-items: center;\n justify-content: flex-start;\n }\n\n .controls .field {\n display: grid;\n gap: 4px;\n min-width: 180px;\n }\n\n .label {\n font-size: 12px;\n color: var(--muted);\n }\n\n input[type=\"search\"], input[type=\"url\"], select, textarea {\n font: inherit;\n color: inherit;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n border-radius: 10px;\n padding: 10px 11px;\n outline: none;\n }\n\n input[type=\"search\"]:focus, input[type=\"url\"]:focus, select:focus, textarea:focus {\n border-color: color-mix(in srgb, var(--accent) 60%, var(--line));\n box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);\n }\n\n .segmented {\n display: inline-flex;\n border: 1px solid var(--line);\n border-radius: 999px;\n overflow: hidden;\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n }\n\n .segmented button {\n border: none;\n padding: 9px 12px;\n font: inherit;\n background: transparent;\n color: var(--muted);\n cursor: pointer;\n min-width: 74px;\n }\n\n .segmented button.active {\n background: color-mix(in srgb, var(--accent) 16%, transparent);\n color: var(--text);\n }\n\n .segmented button:focus-visible {\n outline: 2px solid color-mix(in srgb, var(--accent) 70%, transparent);\n outline-offset: -2px;\n }\n\n .btn {\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n color: var(--text);\n border-radius: 10px;\n padding: 10px 12px;\n font: inherit;\n cursor: pointer;\n transition: transform 80ms ease, border-color 120ms ease;\n }\n\n .btn:hover { border-color: var(--line-2); }\n .btn:active { transform: translateY(1px); }\n .btn.primary {\n border-color: color-mix(in srgb, var(--accent) 55%, var(--line));\n background: color-mix(in srgb, var(--accent) 14%, transparent);\n }\n\n .btn:disabled {\n opacity: 0.55;\n cursor: not-allowed;\n }\n\n .panel {\n background: var(--panel);\n border: 1px solid var(--line);\n border-radius: 16px;\n box-shadow: var(--shadow);\n }\n\n .input-panel {\n padding: 14px;\n margin-bottom: 14px;\n }\n\n .filters-panel {\n padding: 14px;\n margin-bottom: 14px;\n }\n\n .input-grid {\n display: grid;\n grid-template-columns: 1fr;\n gap: 14px;\n align-items: stretch;\n }\n\n @media (min-width: 920px) {\n .input-grid {\n grid-template-columns: 380px 1fr;\n }\n }\n\n .dropzone {\n border: 1px dashed color-mix(in srgb, var(--accent) 40%, var(--line));\n border-radius: 14px;\n background: linear-gradient(\n 135deg,\n color-mix(in srgb, var(--accent) 12%, transparent),\n transparent 45%,\n color-mix(in srgb, var(--accent-2) 10%, transparent)\n );\n padding: 16px;\n display: grid;\n gap: 6px;\n cursor: pointer;\n min-height: 124px;\n align-content: center;\n }\n\n .dropzone.dragover {\n border-style: solid;\n border-color: color-mix(in srgb, var(--accent) 70%, var(--line));\n box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);\n }\n\n .dropzone strong { font-size: 15px; }\n .muted { color: var(--muted); }\n .tiny { font-size: 12px; color: var(--muted); }\n\n .tabs {\n display: flex;\n gap: 8px;\n margin-bottom: 10px;\n flex-wrap: wrap;\n align-items: center;\n }\n\n .tab {\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel-2) 94%, transparent);\n color: var(--muted);\n border-radius: 999px;\n padding: 7px 10px;\n cursor: pointer;\n font: inherit;\n }\n\n .tab.active {\n color: var(--text);\n border-color: color-mix(in srgb, var(--accent) 55%, var(--line));\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n }\n\n textarea {\n width: 100%;\n min-height: 190px;\n resize: vertical;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.4;\n }\n\n .row {\n display: flex;\n gap: 10px;\n flex-wrap: wrap;\n align-items: center;\n margin-top: 10px;\n }\n\n .status {\n margin-top: 12px;\n padding: 10px 12px;\n border-radius: 12px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel-2) 94%, transparent);\n display: none;\n white-space: pre-wrap;\n word-break: break-word;\n }\n .status.visible { display: block; }\n .status.error { border-color: color-mix(in srgb, var(--bad) 60%, var(--line)); }\n .status.good { border-color: color-mix(in srgb, var(--good) 55%, var(--line)); }\n .status.warn { border-color: color-mix(in srgb, var(--warn) 55%, var(--line)); }\n\n main {\n display: grid;\n gap: 14px;\n grid-template-columns: 1fr;\n min-height: 560px;\n }\n\n @media (min-width: 1060px) {\n main {\n grid-template-columns: 1fr 520px;\n align-items: stretch;\n }\n }\n\n .timeline-panel {\n display: grid;\n grid-template-rows: auto 1fr;\n min-height: 560px;\n }\n\n .stats {\n display: flex;\n gap: 12px;\n flex-wrap: wrap;\n align-items: baseline;\n justify-content: space-between;\n padding: 12px 14px;\n border-bottom: 1px solid var(--line);\n }\n\n .stats .big {\n font-size: 14px;\n font-weight: 600;\n }\n\n .timeline {\n overflow: auto;\n padding: 8px 0;\n }\n\n .timeline:focus-visible {\n outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent);\n outline-offset: -2px;\n border-radius: 14px;\n }\n\n .day-sep {\n position: sticky;\n top: 0;\n z-index: 2;\n padding: 8px 14px;\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n border-top: 1px solid var(--line);\n border-bottom: 1px solid var(--line);\n font-size: 12px;\n color: var(--muted);\n backdrop-filter: blur(6px);\n }\n\n .event {\n display: grid;\n grid-template-columns: 176px 1fr;\n gap: 12px;\n padding: 10px 14px;\n border-bottom: 1px solid color-mix(in srgb, var(--line) 85%, transparent);\n cursor: pointer;\n }\n\n .event:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); }\n .event.selected {\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n outline: 2px solid color-mix(in srgb, var(--accent) 35%, transparent);\n outline-offset: -2px;\n }\n\n .time {\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.35;\n color: var(--muted);\n display: grid;\n gap: 3px;\n }\n\n .time .ts {\n color: var(--text);\n font-weight: 600;\n }\n\n .badges {\n display: flex;\n gap: 6px;\n flex-wrap: wrap;\n align-items: center;\n margin-bottom: 6px;\n }\n\n .badge {\n font-size: 11px;\n font-family: var(--mono);\n padding: 2px 7px;\n border-radius: 999px;\n border: 1px solid var(--line);\n color: var(--muted);\n background: color-mix(in srgb, var(--panel-2) 90%, transparent);\n }\n\n .badge.k-event_msg { border-color: color-mix(in srgb, #8690a8 55%, var(--line)); color: color-mix(in srgb, #8690a8 70%, var(--text)); }\n .badge.k-response_item { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); color: color-mix(in srgb, var(--accent) 75%, var(--text)); }\n .badge.k-turn_context { border-color: color-mix(in srgb, var(--accent-2) 55%, var(--line)); color: color-mix(in srgb, var(--accent-2) 75%, var(--text)); }\n .badge.k-session_meta { border-color: color-mix(in srgb, var(--good) 55%, var(--line)); color: color-mix(in srgb, var(--good) 75%, var(--text)); }\n .badge.k-compacted { border-color: color-mix(in srgb, var(--warn) 65%, var(--line)); color: color-mix(in srgb, var(--warn) 85%, var(--text)); }\n\n .badge.role-user { border-color: color-mix(in srgb, #00c8ff 55%, var(--line)); color: color-mix(in srgb, #00c8ff 78%, var(--text)); }\n .badge.role-assistant { border-color: color-mix(in srgb, #ff73c4 55%, var(--line)); color: color-mix(in srgb, #ff73c4 78%, var(--text)); }\n\n .summary {\n font-size: 13px;\n line-height: 1.35;\n color: var(--text);\n word-break: break-word;\n }\n\n .summary code {\n font-family: var(--mono);\n font-size: 12px;\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n padding: 2px 5px;\n border-radius: 7px;\n }\n\n\t .detail {\n\t display: flex;\n\t flex-direction: column;\n\t min-height: 560px;\n\t }\n\n\t @media (min-width: 1060px) {\n\t .detail {\n\t position: sticky;\n\t top: 16px;\n\t align-self: start;\n\t max-height: calc(100vh - 32px);\n\t }\n\t }\n\n\t .detail.inline {\n\t position: static;\n\t top: auto;\n\t align-self: auto;\n\t max-height: none;\n\t min-height: 0;\n\t margin: 8px 14px 12px;\n\t box-shadow: none;\n\t }\n\n\t .detail.inline pre {\n\t max-height: 52vh;\n\t }\n\n\t .detail-head {\n\t padding: 12px 14px;\n\t border-bottom: 1px solid var(--line);\n\t display: grid;\n\t gap: 8px;\n }\n\n .detail-title {\n font-size: 14px;\n font-weight: 700;\n line-height: 1.25;\n margin: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .detail-actions {\n display: flex;\n gap: 8px;\n flex-wrap: wrap;\n align-items: center;\n justify-content: flex-start;\n }\n\n .check {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n color: var(--muted);\n font-size: 12px;\n user-select: none;\n }\n\n pre {\n margin: 0;\n padding: 12px 14px 16px;\n overflow: auto;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.45;\n color: color-mix(in srgb, var(--text) 92%, transparent);\n background: transparent;\n white-space: pre-wrap;\n word-break: break-word;\n }\n\n #detail-json {\n flex: 1;\n min-height: 0;\n }\n\n .detail-images {\n display: flex;\n gap: 10px;\n padding: 12px 14px;\n border-bottom: 1px solid var(--line);\n overflow-x: auto;\n overscroll-behavior-x: contain;\n }\n\n .thumb {\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel-2) 92%, transparent);\n border-radius: 12px;\n padding: 0;\n cursor: pointer;\n width: 78px;\n height: 58px;\n overflow: hidden;\n flex: 0 0 auto;\n display: grid;\n place-items: stretch;\n }\n\n .thumb:hover { border-color: var(--line-2); }\n\n .thumb img {\n width: 100%;\n height: 100%;\n display: block;\n object-fit: cover;\n }\n\n .image-modal {\n position: fixed;\n inset: 0;\n z-index: 9999;\n background: rgba(0, 0, 0, 0.68);\n display: none;\n padding: 18px;\n place-items: center;\n }\n\n .image-modal.open { display: grid; }\n\n .image-modal .dialog {\n position: relative;\n border-radius: 16px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 98%, transparent);\n box-shadow: var(--shadow);\n padding: 14px;\n max-width: min(1200px, 96vw);\n max-height: 92vh;\n display: grid;\n place-items: center;\n }\n\n .image-modal img {\n display: block;\n max-width: min(1140px, 92vw);\n max-height: 84vh;\n width: auto;\n height: auto;\n object-fit: contain;\n border-radius: 10px;\n background: color-mix(in srgb, var(--panel-2) 92%, transparent);\n }\n\n .image-modal .close {\n position: absolute;\n top: 10px;\n right: 10px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n color: var(--text);\n border-radius: 999px;\n padding: 8px 10px;\n font: inherit;\n cursor: pointer;\n }\n\n .image-modal .close:hover { border-color: var(--line-2); }\n\n .empty {\n padding: 14px;\n color: var(--muted);\n font-size: 13px;\n line-height: 1.35;\n }\n\n .detail-pretty {\n padding: 14px;\n border-bottom: 1px solid var(--line);\n overflow: auto;\n max-height: 50vh;\n }\n\n .detail.inline .detail-pretty {\n max-height: 40vh;\n }\n\n .pretty-section {\n margin-bottom: 14px;\n }\n\n .pretty-section:last-child {\n margin-bottom: 0;\n }\n\n .pretty-label {\n font-size: 12px;\n color: var(--muted);\n margin-bottom: 6px;\n font-weight: 600;\n letter-spacing: 0.2px;\n text-transform: uppercase;\n }\n\n .pretty-content {\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n border-radius: 10px;\n padding: 12px;\n font-size: 13px;\n line-height: 1.55;\n }\n\n .pretty-content code {\n font-family: var(--mono);\n font-size: 0.95em;\n }\n\n .pretty-content pre {\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.4;\n white-space: pre;\n overflow: auto;\n padding: 10px 12px;\n border-radius: 10px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n max-height: 520px;\n }\n\n .thinking-block {\n background: color-mix(in srgb, var(--accent-2) 10%, transparent);\n border: 1px solid color-mix(in srgb, var(--accent-2) 30%, var(--line));\n border-radius: 10px;\n padding: 12px 16px;\n font-size: 13px;\n line-height: 1.5;\n }\n\n .command-block {\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n border-radius: 10px;\n overflow: hidden;\n }\n\n .command-header {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 10px 12px;\n border-bottom: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel-2) 50%, transparent);\n gap: 10px;\n }\n\n .tool-name {\n font-family: var(--mono);\n font-size: 12px;\n color: var(--accent);\n font-weight: 600;\n }\n\n .tool-desc {\n font-size: 12px;\n color: var(--muted);\n margin-left: auto;\n }\n\n .command-input {\n padding: 10px 12px;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.4;\n white-space: pre-wrap;\n word-break: break-word;\n }\n\n .tool-params {\n display: grid;\n gap: 8px;\n margin-top: 8px;\n padding: 10px 12px;\n }\n\n .tool-param {\n display: grid;\n grid-template-columns: auto 1fr;\n gap: 8px;\n align-items: start;\n font-size: 12px;\n }\n\n .tool-param-name {\n font-family: var(--mono);\n font-weight: 600;\n color: var(--accent);\n padding: 2px 6px;\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n border-radius: 4px;\n white-space: nowrap;\n }\n\n .tool-param-value {\n font-family: var(--mono);\n white-space: pre-wrap;\n word-break: break-word;\n padding: 2px 0;\n }\n\n .prompts-modal {\n position: fixed;\n inset: 0;\n z-index: 80;\n background: rgba(0, 0, 0, 0.68);\n display: none;\n padding: 18px;\n place-items: center;\n }\n\n .prompts-modal.open { display: grid; }\n\n .prompts-modal .dialog {\n position: relative;\n border-radius: 16px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 98%, transparent);\n box-shadow: var(--shadow);\n padding: 14px;\n width: min(1100px, 96vw);\n height: min(86vh, 780px);\n display: grid;\n grid-template-rows: auto 1fr auto;\n gap: 10px;\n }\n\n .prompts-head {\n display: flex;\n gap: 10px;\n align-items: center;\n justify-content: space-between;\n }\n\n .prompts-title {\n font-weight: 700;\n letter-spacing: 0.2px;\n }\n\n .prompts-modal textarea {\n width: 100%;\n height: 100%;\n resize: none;\n border-radius: 12px;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.45;\n padding: 12px;\n }\n\n .prompts-actions {\n display: flex;\n gap: 10px;\n align-items: center;\n justify-content: flex-end;\n flex-wrap: wrap;\n }\n </style>\n</head>\n <body>\n <div class=\"wrap\">\n <header>\n <div>\n <h1>Codex Timeline Viewer</h1>\n <p class=\"sub\">Open a Codex rollout <code>.jsonl</code> file and explore it as an interactive timeline. Times are shown in your local timezone by default.</p>\n </div>\n </header>\n\n <section class=\"panel input-panel\" aria-label=\"Input\">\n <div class=\"input-grid\">\n <div>\n <div id=\"dropzone\" class=\"dropzone\" tabindex=\"0\">\n <strong>Drop a <code>.jsonl</code> file here</strong>\n <div class=\"muted\">or click to choose a file</div>\n <div class=\"tiny\">Supported: file picker, drag-and-drop, paste, or URL fetch (CORS-enabled).</div>\n <input id=\"file-input\" type=\"file\" accept=\".jsonl,application/json,text/plain\" hidden />\n </div>\n </div>\n <div>\n <div class=\"tabs\" role=\"tablist\" aria-label=\"Input tabs\">\n <button type=\"button\" class=\"tab active\" data-tab=\"paste\" aria-selected=\"true\">Paste</button>\n <button type=\"button\" class=\"tab\" data-tab=\"url\" aria-selected=\"false\">URL</button>\n </div>\n\n <div id=\"panel-paste\" data-panel=\"paste\">\n <textarea id=\"paste-input\" placeholder=\"Paste JSONL content here…\"></textarea>\n <div class=\"row\">\n <button type=\"button\" class=\"btn primary\" id=\"render-paste\">Render pasted content</button>\n <div class=\"tiny\">Tip: the timeline uses one JSON object per line.</div>\n </div>\n </div>\n\n <div id=\"panel-url\" data-panel=\"url\" hidden>\n <input id=\"url-input\" type=\"url\" placeholder=\"https://example.com/rollout.jsonl\" />\n <div class=\"row\">\n <button type=\"button\" class=\"btn primary\" id=\"fetch-url\">Fetch and render</button>\n <div class=\"tiny\">The URL must allow cross-origin requests (CORS).</div>\n </div>\n </div>\n </div>\n </div>\n\n <div id=\"status\" class=\"status\" role=\"status\" aria-live=\"polite\"></div>\n </section>\n\n <section class=\"panel filters-panel\" aria-label=\"Filters\">\n <div class=\"controls\" aria-label=\"Timeline controls\">\n <div class=\"field\" style=\"min-width: 170px\">\n <div class=\"label\">Timezone</div>\n <div class=\"segmented\" role=\"group\" aria-label=\"Timezone toggle\">\n <button type=\"button\" id=\"tz-local\" class=\"active\">Local</button>\n <button type=\"button\" id=\"tz-utc\">UTC</button>\n </div>\n </div>\n <div class=\"field\" style=\"min-width: 220px\">\n <div class=\"label\">Search</div>\n <input id=\"search\" type=\"search\" placeholder=\"message, function name, cwd…\" autocomplete=\"off\" />\n </div>\n <div class=\"field\" style=\"min-width: 190px\">\n <div class=\"label\">Type</div>\n <select id=\"type-filter\"></select>\n </div>\n <div class=\"field\" style=\"min-width: 210px\">\n <div class=\"label\">Payload</div>\n <select id=\"payload-filter\"></select>\n </div>\n <div class=\"field\" style=\"min-width: 160px\">\n <div class=\"label\">Role</div>\n <select id=\"role-filter\"></select>\n </div>\n <label class=\"check\" title=\"Hide token_count events (often very noisy)\">\n <input type=\"checkbox\" id=\"hide-token-count\" checked />\n Hide token_count\n </label>\n <button type=\"button\" class=\"btn\" id=\"clear-filters\">Clear</button>\n </div>\n </section>\n\n <div class=\"controls\" style=\"margin-bottom: 14px;\">\n <button type=\"button\" class=\"btn\" id=\"show-prompts\" disabled>Show prompts</button>\n </div>\n\n <main>\n <section class=\"panel timeline-panel\" aria-label=\"Timeline\">\n <div class=\"stats\">\n <div class=\"big\"><span id=\"stats-showing\">0</span> <span class=\"muted\">showing</span> <span class=\"muted\">/</span> <span id=\"stats-total\">0</span> <span class=\"muted\">events</span></div>\n <div class=\"muted\" id=\"stats-range\">—</div>\n </div>\n <div id=\"timeline\" class=\"timeline\" role=\"list\" tabindex=\"0\" aria-label=\"Events list\"></div>\n </section>\n\n <aside class=\"panel detail\" aria-label=\"Details\">\n <div class=\"detail-head\">\n <div class=\"detail-title\" id=\"detail-title\">Select an event</div>\n <div class=\"detail-actions\">\n <button type=\"button\" class=\"btn\" id=\"copy-json\" disabled>Copy JSON</button>\n <button type=\"button\" class=\"btn\" id=\"copy-raw\" disabled>Copy raw line</button>\n <label class=\"check\" title=\"Truncate very long strings in the JSON preview\">\n <input type=\"checkbox\" id=\"truncate-strings\" checked />\n Truncate long strings\n </label>\n </div>\n </div>\n <div id=\"detail-images\" class=\"detail-images\" hidden aria-label=\"Event images\"></div>\n <div id=\"detail-pretty\" class=\"detail-pretty\" hidden aria-label=\"Formatted event view\"></div>\n <pre id=\"detail-json\" aria-label=\"Selected event JSON\" class=\"empty\">Load a file to get started.</pre>\n </aside>\n </main>\n </div>\n\n <div id=\"image-modal\" class=\"image-modal\" role=\"dialog\" aria-modal=\"true\" aria-hidden=\"true\">\n <div class=\"dialog\">\n <button type=\"button\" class=\"close\" id=\"image-modal-close\" aria-label=\"Close image\">Close</button>\n <img id=\"image-modal-img\" alt=\"\" />\n </div>\n </div>\n\n <div id=\"prompts-modal\" class=\"prompts-modal\" role=\"dialog\" aria-modal=\"true\" aria-hidden=\"true\" aria-label=\"User prompts\">\n <div class=\"dialog\">\n <div class=\"prompts-head\">\n <div class=\"prompts-title\">User prompts</div>\n <button type=\"button\" class=\"btn\" id=\"prompts-close\" aria-label=\"Close prompts\">Close</button>\n </div>\n <textarea id=\"prompts-text\" readonly spellcheck=\"false\"></textarea>\n <div class=\"prompts-actions\">\n <button type=\"button\" class=\"btn primary\" id=\"prompts-copy\">Copy</button>\n </div>\n </div>\n </div>\n\n <script>\n\t const els = {\n\t dropzone: document.getElementById('dropzone'),\n\t fileInput: document.getElementById('file-input'),\n\t pasteInput: document.getElementById('paste-input'),\n\t renderPaste: document.getElementById('render-paste'),\n\t urlInput: document.getElementById('url-input'),\n\t fetchUrl: document.getElementById('fetch-url'),\n\t status: document.getElementById('status'),\n\t timeline: document.getElementById('timeline'),\n\t detailPanel: document.querySelector('aside.detail'),\n\t detailTitle: document.getElementById('detail-title'),\n\t detailImages: document.getElementById('detail-images'),\n\t detailPretty: document.getElementById('detail-pretty'),\n\t detailJson: document.getElementById('detail-json'),\n\t copyJson: document.getElementById('copy-json'),\n\t copyRaw: document.getElementById('copy-raw'),\n truncateStrings: document.getElementById('truncate-strings'),\n tzLocal: document.getElementById('tz-local'),\n tzUtc: document.getElementById('tz-utc'),\n search: document.getElementById('search'),\n typeFilter: document.getElementById('type-filter'),\n payloadFilter: document.getElementById('payload-filter'),\n roleFilter: document.getElementById('role-filter'),\n hideTokenCount: document.getElementById('hide-token-count'),\n statsShowing: document.getElementById('stats-showing'),\n statsTotal: document.getElementById('stats-total'),\n statsRange: document.getElementById('stats-range'),\n clearFilters: document.getElementById('clear-filters'),\n imageModal: document.getElementById('image-modal'),\n imageModalImg: document.getElementById('image-modal-img'),\n imageModalClose: document.getElementById('image-modal-close'),\n showPrompts: document.getElementById('show-prompts'),\n promptsModal: document.getElementById('prompts-modal'),\n promptsClose: document.getElementById('prompts-close'),\n promptsText: document.getElementById('prompts-text'),\n promptsCopy: document.getElementById('prompts-copy'),\n\t };\n\n const state = {\n sourceLabel: null,\n tzMode: 'local',\n events: [],\n filtered: [],\n selectedId: null,\n selected: null,\n filters: {\n query: '',\n type: 'all',\n payload: 'all',\n role: 'all',\n hideTokenCount: true,\n },\n };\n\n\t let lastActiveElementBeforeModal = null;\n\t let lastBodyOverflow = null;\n let lastActiveElementBeforePrompts = null;\n let lastBodyOverflowBeforePrompts = null;\n let promptsCopiedTimer = null;\n\t let suppressUrlStateSync = false;\n\t const wideLayoutMq = window.matchMedia('(min-width: 1060px)');\n\t const detailDock = document.createElement('div');\n\t detailDock.id = 'detail-dock';\n\t detailDock.style.display = 'none';\n\t els.detailPanel.parentElement.insertBefore(detailDock, els.detailPanel);\n\n const md = typeof window.markdownit === 'function'\n ? window.markdownit({\n html: false,\n linkify: true,\n typographer: true,\n breaks: true,\n })\n : null;\n\n function escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = String(text ?? '');\n return div.innerHTML;\n }\n\n function renderMarkdown(text) {\n if (typeof text !== 'string') return '';\n if (!md) return escapeHtml(text).replace(/\\n/g, '<br>');\n const html = md.render(text);\n if (window.DOMPurify && typeof window.DOMPurify.sanitize === 'function') {\n return window.DOMPurify.sanitize(html);\n }\n return html;\n }\n\n\t function updateDetailPlacement() {\n\t const inline = !wideLayoutMq.matches;\n\n\t if (!inline) {\n\t if (els.detailPanel.previousSibling !== detailDock) detailDock.after(els.detailPanel);\n\t els.detailPanel.classList.remove('inline');\n\t return;\n\t }\n\n\t const selectedNode = els.timeline.querySelector('.event.selected');\n\t if (!selectedNode) {\n\t if (els.detailPanel.previousSibling !== detailDock) detailDock.after(els.detailPanel);\n\t els.detailPanel.classList.remove('inline');\n\t return;\n\t }\n\n\t if (selectedNode.nextSibling !== els.detailPanel) selectedNode.after(els.detailPanel);\n\t els.detailPanel.classList.add('inline');\n\t }\n\n\t function setStatus(message, kind = 'good') {\n\t if (!message) {\n\t els.status.className = 'status';\n\t els.status.textContent = '';\n return;\n }\n els.status.className = `status visible ${kind}`;\n els.status.textContent = message;\n }\n\n function pad2(number) {\n return String(number).padStart(2, '0');\n }\n\n function pad3(number) {\n return String(number).padStart(3, '0');\n }\n\n function getOffsetString(date) {\n const offsetMinutes = -date.getTimezoneOffset(); // minutes east of UTC\n const sign = offsetMinutes >= 0 ? '+' : '-';\n const abs = Math.abs(offsetMinutes);\n const hours = Math.floor(abs / 60);\n const minutes = abs % 60;\n return `${sign}${pad2(hours)}:${pad2(minutes)}`;\n }\n\n function formatTimestamp(date, tzMode) {\n if (!(date instanceof Date) || Number.isNaN(date.getTime())) return '—';\n if (tzMode === 'utc') {\n return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ` +\n `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}:${pad2(date.getUTCSeconds())}.` +\n `${pad3(date.getUTCMilliseconds())}Z`;\n }\n return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ` +\n `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}.` +\n `${pad3(date.getMilliseconds())} ${getOffsetString(date)}`;\n }\n\n function dayKey(date, tzMode) {\n if (!(date instanceof Date) || Number.isNaN(date.getTime())) return '—';\n if (tzMode === 'utc') {\n return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())}`;\n }\n return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;\n }\n\n function formatDelta(ms) {\n if (typeof ms !== 'number' || !Number.isFinite(ms)) return '';\n const abs = Math.abs(ms);\n const sign = ms < 0 ? '−' : '+';\n if (abs < 1000) return `${sign}${Math.round(abs)}ms`;\n if (abs < 60_000) return `${sign}${(abs / 1000).toFixed(abs < 10_000 ? 2 : 1)}s`;\n if (abs < 3_600_000) {\n const m = Math.floor(abs / 60_000);\n const s = Math.floor((abs % 60_000) / 1000);\n return `${sign}${m}m ${pad2(s)}s`;\n }\n if (abs < 86_400_000) {\n const h = Math.floor(abs / 3_600_000);\n const m = Math.floor((abs % 3_600_000) / 60_000);\n return `${sign}${h}h ${m}m`;\n }\n const d = Math.floor(abs / 86_400_000);\n const h = Math.floor((abs % 86_400_000) / 3_600_000);\n return `${sign}${d}d ${h}h`;\n }\n\n function safeJsonParse(text) {\n try {\n return { ok: true, value: JSON.parse(text) };\n } catch (error) {\n return { ok: false, error };\n }\n }\n\n function extractMessageText(payload) {\n if (!payload || typeof payload !== 'object') return null;\n const content = payload.content;\n if (!Array.isArray(content)) return null;\n const parts = [];\n for (const item of content) {\n if (!item || typeof item !== 'object') continue;\n if (typeof item.text === 'string') parts.push(item.text);\n else if (typeof item.input_text === 'string') parts.push(item.input_text);\n else if (typeof item.output_text === 'string') parts.push(item.output_text);\n }\n const joined = parts.join('');\n return joined || null;\n }\n\n function firstLine(text) {\n if (typeof text !== 'string') return null;\n const trimmed = text.trim();\n if (!trimmed) return null;\n const idx = trimmed.indexOf('\\n');\n return idx === -1 ? trimmed : trimmed.slice(0, idx).trim();\n }\n\n function normalizeSummaryText(text, maxLen = 260) {\n if (typeof text !== 'string') return '';\n const singleLine = text.replace(/\\s+/g, ' ').trim();\n if (singleLine.length <= maxLen) return singleLine;\n return singleLine.slice(0, maxLen - 1) + '…';\n }\n\n function summarizeEvent(obj) {\n const kind = obj?.type || '';\n const payload = obj?.payload;\n const payloadType = payload && typeof payload === 'object' ? payload.type : null;\n\n if (kind === 'session_meta') {\n const id = payload?.id ? `id=${payload.id}` : null;\n const cwd = payload?.cwd ? `cwd=${payload.cwd}` : null;\n const model = payload?.model_provider ? `model_provider=${payload.model_provider}` : null;\n return normalizeSummaryText([id, cwd, model].filter(Boolean).join(' · ')) || 'session_meta';\n }\n\n if (kind === 'turn_context') {\n const cwd = payload?.cwd ? `cwd=${payload.cwd}` : null;\n const model = payload?.model ? `model=${payload.model}` : null;\n const effort = payload?.effort ? `effort=${payload.effort}` : null;\n return normalizeSummaryText([cwd, model, effort].filter(Boolean).join(' · ')) || 'turn_context';\n }\n\n if (payloadType === 'message') {\n const role = payload?.role ? `${payload.role}: ` : '';\n const msg = extractMessageText(payload) || '';\n return normalizeSummaryText(role + msg) || 'message';\n }\n\n if (payloadType === 'user_message' || payloadType === 'agent_message') {\n const msg = payload?.message || '';\n return normalizeSummaryText(msg) || payloadType;\n }\n\n if (payloadType === 'function_call') {\n const name = payload?.name || 'function_call';\n const argsText = typeof payload?.arguments === 'string' ? payload.arguments : null;\n const args = argsText ? safeJsonParse(argsText) : null;\n if (name === 'shell_command' && args?.ok && typeof args.value?.command === 'string') {\n return normalizeSummaryText(`${name}: ${args.value.command}`);\n }\n if (name === 'update_plan' && args?.ok && typeof args.value?.explanation === 'string') {\n return normalizeSummaryText(`${name}: ${args.value.explanation}`);\n }\n if (name === 'apply_patch' && args?.ok && typeof args.value?.patch === 'string') {\n return normalizeSummaryText(`${name}: ${firstLine(args.value.patch) || 'patch'}`);\n }\n if (args?.ok && args.value && typeof args.value === 'object') {\n const keys = Object.keys(args.value).slice(0, 5);\n return normalizeSummaryText(`${name}(${keys.join(', ')}${Object.keys(args.value).length > 5 ? ', …' : ''})`);\n }\n return normalizeSummaryText(`${name}(${argsText ? '…' : ''})`);\n }\n\n if (payloadType === 'function_call_output') {\n const out = typeof payload?.output === 'string' ? payload.output : '';\n const line = firstLine(out) || '';\n return normalizeSummaryText(`output: ${line}`) || 'function_call_output';\n }\n\n if (payloadType === 'custom_tool_call') {\n const name = payload?.name || 'custom_tool_call';\n const status = payload?.status ? ` (${payload.status})` : '';\n const inputLine = firstLine(payload?.input) || '';\n return normalizeSummaryText(`${name}${status}: ${inputLine}`) || `${name}${status}`;\n }\n\n if (payloadType === 'custom_tool_call_output') {\n const out = typeof payload?.output === 'string' ? payload.output : '';\n return normalizeSummaryText(`tool output: ${firstLine(out) || ''}`) || 'custom_tool_call_output';\n }\n\n if (payloadType === 'token_count') {\n const used = payload?.rate_limits?.primary?.used_percent;\n const windowMinutes = payload?.rate_limits?.primary?.window_minutes;\n if (typeof used === 'number' && typeof windowMinutes === 'number') {\n return normalizeSummaryText(`token_count: primary ${used}% / ${windowMinutes}m window`);\n }\n return 'token_count';\n }\n\n if (payloadType === 'agent_reasoning') {\n return normalizeSummaryText(firstLine(payload?.text) || payload?.text || '') || 'agent_reasoning';\n }\n\n if (payloadType === 'reasoning') {\n const summaryText = payload?.summary?.[0]?.text;\n return normalizeSummaryText(firstLine(summaryText) || summaryText || '') || 'reasoning';\n }\n\n if (kind && payloadType) return `${kind} · ${payloadType}`;\n if (kind) return kind;\n return 'event';\n }\n\n function buildSearchText(obj, summary) {\n const pieces = [];\n pieces.push(obj?.type || '');\n const payload = obj?.payload;\n if (payload && typeof payload === 'object') {\n pieces.push(payload.type || '');\n pieces.push(payload.role || '');\n pieces.push(payload.name || '');\n if (typeof payload.message === 'string') pieces.push(payload.message);\n if (typeof payload.text === 'string') pieces.push(payload.text);\n const msg = extractMessageText(payload);\n if (msg) pieces.push(msg);\n if (typeof payload.arguments === 'string') pieces.push(payload.arguments);\n if (typeof payload.output === 'string') pieces.push(payload.output.slice(0, 500));\n if (typeof payload.cwd === 'string') pieces.push(payload.cwd);\n if (typeof payload.model === 'string') pieces.push(payload.model);\n }\n if (typeof summary === 'string') pieces.push(summary);\n return pieces.join('\\n').toLowerCase();\n }\n\n function normalizeShellCommand(args) {\n if (!args || typeof args !== 'object') return null;\n const cmd = args.command;\n if (typeof cmd === 'string') return cmd;\n if (Array.isArray(cmd)) return cmd.map(v => String(v)).join(' ');\n if (Array.isArray(args.cmd)) return args.cmd.map(v => String(v)).join(' ');\n if (typeof args.cmd === 'string') return args.cmd;\n return null;\n }\n\n function renderPrettyView(obj) {\n els.detailPretty.innerHTML = '';\n\n if (!obj || typeof obj !== 'object') {\n els.detailPretty.hidden = true;\n return;\n }\n\n const payload = obj.payload && typeof obj.payload === 'object' ? obj.payload : null;\n const payloadType = payload?.type;\n const fragment = document.createDocumentFragment();\n let hasContent = false;\n\n function addSection(labelText, bodyEl) {\n const section = document.createElement('div');\n section.className = 'pretty-section';\n const label = document.createElement('div');\n label.className = 'pretty-label';\n label.textContent = labelText;\n section.appendChild(label);\n section.appendChild(bodyEl);\n fragment.appendChild(section);\n hasContent = true;\n }\n\n if (payloadType === 'message') {\n const role = payload?.role || 'message';\n const content = payload?.content;\n\n if (Array.isArray(content)) {\n const textParts = [];\n const thinkingParts = [];\n for (const item of content) {\n if (!item || typeof item !== 'object') continue;\n\n const kind = item.type || '';\n if ((kind === 'input_text' || kind === 'output_text' || kind === 'text') && typeof item.text === 'string') {\n textParts.push(item.text);\n } else if (kind === 'thinking' && typeof item.thinking === 'string') {\n thinkingParts.push(item.thinking);\n } else if (kind === 'tool_use' && item.name) {\n const toolName = String(item.name);\n const input = item.input && typeof item.input === 'object' ? item.input : {};\n\n const block = document.createElement('div');\n block.className = 'command-block';\n\n const header = document.createElement('div');\n header.className = 'command-header';\n const nameSpan = document.createElement('span');\n nameSpan.className = 'tool-name';\n nameSpan.textContent = toolName;\n header.appendChild(nameSpan);\n block.appendChild(header);\n\n if (Object.keys(input).length) {\n const params = document.createElement('div');\n params.className = 'tool-params';\n for (const [key, value] of Object.entries(input)) {\n const param = document.createElement('div');\n param.className = 'tool-param';\n const paramName = document.createElement('span');\n paramName.className = 'tool-param-name';\n paramName.textContent = key;\n const paramValue = document.createElement('span');\n paramValue.className = 'tool-param-value';\n if (typeof value === 'string') paramValue.textContent = value.length > 2000 ? value.slice(0, 2000) + '…' : value;\n else paramValue.textContent = JSON.stringify(value, null, 2);\n param.appendChild(paramName);\n param.appendChild(paramValue);\n params.appendChild(param);\n }\n block.appendChild(params);\n }\n\n addSection('Tool', block);\n }\n }\n\n const joinedText = textParts.map(v => String(v ?? '').trim()).filter(Boolean).join('\\n\\n').trim();\n if (joinedText) {\n const body = document.createElement('div');\n body.className = 'pretty-content';\n body.innerHTML = renderMarkdown(joinedText);\n addSection(role === 'assistant' ? 'Response' : 'Prompt', body);\n }\n\n const joinedThinking = thinkingParts.map(v => String(v ?? '').trim()).filter(Boolean).join('\\n\\n').trim();\n if (joinedThinking) {\n const body = document.createElement('div');\n body.className = 'thinking-block';\n body.innerHTML = renderMarkdown(joinedThinking);\n addSection('Thinking', body);\n }\n }\n }\n\n if (payloadType === 'user_message' || payloadType === 'agent_message') {\n const msg = typeof payload?.message === 'string' ? payload.message : '';\n if (msg) {\n const body = document.createElement('div');\n body.className = 'pretty-content';\n body.innerHTML = renderMarkdown(msg);\n addSection(payloadType === 'user_message' ? 'Prompt' : 'Message', body);\n }\n }\n\n if (payloadType === 'function_call') {\n const toolName = payload?.name || 'function_call';\n const argsText = typeof payload?.arguments === 'string' ? payload.arguments : '';\n const parsed = argsText ? safeJsonParse(argsText) : null;\n const args = parsed?.ok ? parsed.value : null;\n\n const block = document.createElement('div');\n block.className = 'command-block';\n\n const header = document.createElement('div');\n header.className = 'command-header';\n const nameSpan = document.createElement('span');\n nameSpan.className = 'tool-name';\n nameSpan.textContent = toolName;\n header.appendChild(nameSpan);\n block.appendChild(header);\n\n const maybeCmd = normalizeShellCommand(args);\n if (maybeCmd) {\n const cmd = document.createElement('div');\n cmd.className = 'command-input';\n cmd.textContent = maybeCmd;\n block.appendChild(cmd);\n }\n\n const argObj = args && typeof args === 'object' ? args : null;\n if (argObj && Object.keys(argObj).length) {\n const params = document.createElement('div');\n params.className = 'tool-params';\n for (const [key, value] of Object.entries(argObj)) {\n if (key === 'command' && maybeCmd) continue;\n const param = document.createElement('div');\n param.className = 'tool-param';\n const paramName = document.createElement('span');\n paramName.className = 'tool-param-name';\n paramName.textContent = key;\n const paramValue = document.createElement('span');\n paramValue.className = 'tool-param-value';\n if (typeof value === 'string') paramValue.textContent = value.length > 4000 ? value.slice(0, 4000) + '…' : value;\n else paramValue.textContent = JSON.stringify(value, null, 2);\n param.appendChild(paramName);\n param.appendChild(paramValue);\n params.appendChild(param);\n }\n block.appendChild(params);\n } else if (argsText) {\n const raw = document.createElement('div');\n raw.className = 'command-input';\n raw.textContent = argsText.length > 8000 ? argsText.slice(0, 8000) + '…' : argsText;\n block.appendChild(raw);\n }\n\n addSection('Tool call', block);\n }\n\n if (payloadType === 'function_call_output') {\n const outText = typeof payload?.output === 'string' ? payload.output : '';\n let display = outText;\n const parsed = outText ? safeJsonParse(outText) : null;\n if (parsed?.ok && parsed.value && typeof parsed.value === 'object' && typeof parsed.value.output === 'string') {\n display = parsed.value.output;\n }\n if (display) {\n const body = document.createElement('div');\n body.className = 'pretty-content';\n const pre = document.createElement('pre');\n pre.textContent = display.length > 60_000 ? display.slice(0, 60_000) + '\\n… [truncated]' : display;\n body.appendChild(pre);\n addSection('Tool output', body);\n }\n }\n\n if (payloadType === 'agent_reasoning') {\n const text = typeof payload?.text === 'string' ? payload.text : '';\n if (text) {\n const body = document.createElement('div');\n body.className = 'thinking-block';\n body.innerHTML = renderMarkdown(text);\n addSection('Reasoning', body);\n }\n }\n\n if (payloadType === 'reasoning') {\n const parts = Array.isArray(payload?.summary)\n ? payload.summary.map(item => (item && typeof item === 'object' ? item.text : null)).filter(v => typeof v === 'string')\n : [];\n const summaryText = parts.map(v => v.trim()).filter(Boolean).join('\\n\\n').trim();\n if (summaryText) {\n const body = document.createElement('div');\n body.className = 'thinking-block';\n body.innerHTML = renderMarkdown(summaryText);\n addSection('Reasoning', body);\n }\n }\n\n if (payloadType === 'custom_tool_call') {\n const name = payload?.name || 'custom_tool_call';\n const status = payload?.status ? ` (${payload.status})` : '';\n const input = typeof payload?.input === 'string' ? payload.input : '';\n if (name || input) {\n const block = document.createElement('div');\n block.className = 'command-block';\n const header = document.createElement('div');\n header.className = 'command-header';\n const nameSpan = document.createElement('span');\n nameSpan.className = 'tool-name';\n nameSpan.textContent = `${name}${status}`;\n header.appendChild(nameSpan);\n block.appendChild(header);\n if (input) {\n const pre = document.createElement('div');\n pre.className = 'command-input';\n pre.textContent = input.length > 20_000 ? input.slice(0, 20_000) + '\\n… [truncated]' : input;\n block.appendChild(pre);\n }\n addSection('Tool call', block);\n }\n }\n\n if (payloadType === 'custom_tool_call_output') {\n const out = typeof payload?.output === 'string' ? payload.output : '';\n if (out) {\n const body = document.createElement('div');\n body.className = 'pretty-content';\n const pre = document.createElement('pre');\n pre.textContent = out.length > 60_000 ? out.slice(0, 60_000) + '\\n… [truncated]' : out;\n body.appendChild(pre);\n addSection('Tool output', body);\n }\n }\n\n if (hasContent) {\n els.detailPretty.appendChild(fragment);\n els.detailPretty.hidden = false;\n } else {\n els.detailPretty.hidden = true;\n }\n }\n\n function stringifyJson(value, truncateLongStrings) {\n const maxLen = 8000;\n return JSON.stringify(value, (key, v) => {\n if (!truncateLongStrings) return v;\n if (typeof v === 'string' && v.length > maxLen) {\n return v.slice(0, maxLen) + `… [truncated ${v.length - maxLen} chars]`;\n }\n return v;\n }, 2);\n }\n\n function updateFilterOptions(events) {\n const typeSet = new Set();\n const payloadSet = new Set();\n const roleSet = new Set();\n for (const e of events) {\n if (e.kind) typeSet.add(e.kind);\n if (e.payloadType !== null && e.payloadType !== undefined) payloadSet.add(e.payloadType);\n else payloadSet.add('(none)');\n if (e.role) roleSet.add(e.role);\n }\n\n function setOptions(select, items, current) {\n select.innerHTML = '';\n const optAll = document.createElement('option');\n optAll.value = 'all';\n optAll.textContent = 'All';\n select.appendChild(optAll);\n for (const item of items) {\n const opt = document.createElement('option');\n opt.value = item;\n opt.textContent = item;\n select.appendChild(opt);\n }\n select.value = current;\n }\n\n setOptions(els.typeFilter, Array.from(typeSet).sort(), state.filters.type);\n setOptions(els.payloadFilter, Array.from(payloadSet).sort(), state.filters.payload);\n setOptions(els.roleFilter, Array.from(roleSet).sort(), state.filters.role);\n\n // Only clamp unknown filter values once we have real options (i.e. after loading a file).\n if (events.length) {\n state.filters.type = els.typeFilter.value || 'all';\n state.filters.payload = els.payloadFilter.value || 'all';\n state.filters.role = els.roleFilter.value || 'all';\n }\n }\n\n function readHashParams() {\n const raw = window.location.hash && window.location.hash.startsWith('#')\n ? window.location.hash.slice(1)\n : '';\n return new URLSearchParams(raw);\n }\n\n function writeHashParams(params) {\n const base = window.location.href.split('#')[0];\n const hash = params.toString();\n const next = base + (hash ? `#${hash}` : '');\n window.history.replaceState(null, '', next);\n }\n\n function syncUrlHashFromControls({ clearSel = false } = {}) {\n if (suppressUrlStateSync) return;\n const params = readHashParams();\n params.set('tz', state.tzMode);\n params.set('q', (els.search.value || '').trim());\n params.set('type', state.filters.type || 'all');\n params.set('payload', state.filters.payload || 'all');\n params.set('role', state.filters.role || 'all');\n params.set('hide', state.filters.hideTokenCount ? '1' : '0');\n params.set('truncate', els.truncateStrings.checked ? '1' : '0');\n const selectedLine = state.selected?.line;\n if (typeof selectedLine === 'number' && Number.isFinite(selectedLine)) {\n params.set('sel', String(selectedLine));\n } else if (clearSel) {\n params.delete('sel');\n }\n writeHashParams(params);\n }\n\n function applySelectionFromHash() {\n if (!state.events.length) return;\n const params = readHashParams();\n const raw = params.get('sel');\n if (!raw) return;\n const line = Number(raw);\n if (!Number.isFinite(line)) return;\n const found = state.events.find(e => e.line === line);\n if (!found) return;\n if (state.selectedId === found.id) return;\n suppressUrlStateSync = true;\n try {\n selectEvent(found.id, { scroll: true });\n } finally {\n suppressUrlStateSync = false;\n }\n }\n\n function applyControlsFromHash() {\n const params = readHashParams();\n suppressUrlStateSync = true;\n try {\n const q = params.get('q');\n if (q !== null) {\n els.search.value = q;\n state.filters.query = q.trim().toLowerCase();\n }\n\n const type = params.get('type');\n if (type !== null) state.filters.type = type;\n const payload = params.get('payload');\n if (payload !== null) state.filters.payload = payload;\n const role = params.get('role');\n if (role !== null) state.filters.role = role;\n\n const hide = params.get('hide');\n if (hide !== null) {\n els.hideTokenCount.checked = hide === '1' || hide === 'true';\n state.filters.hideTokenCount = els.hideTokenCount.checked;\n }\n\n const truncate = params.get('truncate');\n if (truncate !== null) {\n els.truncateStrings.checked = truncate === '1' || truncate === 'true';\n }\n\n const tz = params.get('tz');\n if (tz === 'utc' || tz === 'local') {\n state.tzMode = tz;\n els.tzLocal.classList.toggle('active', tz === 'local');\n els.tzUtc.classList.toggle('active', tz === 'utc');\n }\n } finally {\n suppressUrlStateSync = false;\n }\n }\n\n function applyFilters() {\n const query = state.filters.query;\n const type = state.filters.type;\n const payload = state.filters.payload;\n const role = state.filters.role;\n const hideTokenCount = state.filters.hideTokenCount;\n\n const out = [];\n for (const e of state.events) {\n if (hideTokenCount && e.payloadType === 'token_count') continue;\n if (type !== 'all' && e.kind !== type) continue;\n if (payload !== 'all') {\n const pt = e.payloadType ?? '(none)';\n if (pt !== payload) continue;\n }\n if (role !== 'all' && e.role !== role) continue;\n if (query && !e.search.includes(query)) continue;\n out.push(e);\n }\n\n state.filtered = out;\n els.statsShowing.textContent = String(out.length);\n els.statsTotal.textContent = String(state.events.length);\n\n const start = out.find(e => e.date instanceof Date && !Number.isNaN(e.date.getTime()))?.date;\n const end = [...out].reverse().find(e => e.date instanceof Date && !Number.isNaN(e.date.getTime()))?.date;\n if (start && end) {\n els.statsRange.textContent = `${formatTimestamp(start, state.tzMode)} → ${formatTimestamp(end, state.tzMode)}`;\n } else {\n els.statsRange.textContent = '—';\n }\n\n renderTimeline();\n }\n\n\t function clearSelection({ syncHash = true } = {}) {\n\t state.selectedId = null;\n\t state.selected = null;\n\t els.detailTitle.textContent = 'Select an event';\n\t renderDetailImages(null);\n els.detailPretty.innerHTML = '';\n els.detailPretty.hidden = true;\n\t els.detailJson.textContent = state.events.length ? 'Select an event from the timeline.' : 'Load a file to get started.';\n\t els.detailJson.classList.add('empty');\n\t els.copyJson.disabled = true;\n\t els.copyRaw.disabled = true;\n\t for (const node of els.timeline.querySelectorAll('.event.selected')) {\n\t node.classList.remove('selected');\n\t }\n\t updateDetailPlacement();\n\t if (syncHash) syncUrlHashFromControls({ clearSel: true });\n\t }\n\n function renderTimeline() {\n els.timeline.innerHTML = '';\n\n if (!state.filtered.length) {\n const empty = document.createElement('div');\n empty.className = 'empty';\n empty.textContent = state.events.length ? 'No events match your filters.' : 'Load a file to see a timeline.';\n els.timeline.appendChild(empty);\n clearSelection();\n return;\n }\n\n const fragment = document.createDocumentFragment();\n let lastDay = null;\n let prevDate = null;\n\n for (const e of state.filtered) {\n const day = dayKey(e.date, state.tzMode);\n if (day !== lastDay) {\n lastDay = day;\n const sep = document.createElement('div');\n sep.className = 'day-sep';\n sep.textContent = day;\n fragment.appendChild(sep);\n }\n\n const row = document.createElement('div');\n row.className = 'event';\n row.setAttribute('role', 'listitem');\n row.dataset.eventId = String(e.id);\n\n const time = document.createElement('div');\n time.className = 'time';\n\n const ts = document.createElement('div');\n ts.className = 'ts';\n ts.textContent = formatTimestamp(e.date, state.tzMode);\n ts.dataset.kind = 'timestamp';\n\n const meta = document.createElement('div');\n meta.textContent = [\n `#${e.line}`,\n (prevDate && e.date) ? formatDelta(e.date.getTime() - prevDate.getTime()) : null,\n ].filter(Boolean).join(' · ');\n\n time.appendChild(ts);\n time.appendChild(meta);\n\n const body = document.createElement('div');\n\n const badges = document.createElement('div');\n badges.className = 'badges';\n const badgeKind = document.createElement('span');\n badgeKind.className = `badge k-${e.kind}`;\n badgeKind.textContent = e.kind;\n badges.appendChild(badgeKind);\n\n if (e.payloadType) {\n const badgePayload = document.createElement('span');\n badgePayload.className = 'badge';\n badgePayload.textContent = e.payloadType;\n badges.appendChild(badgePayload);\n }\n\n if (e.role) {\n const badgeRole = document.createElement('span');\n badgeRole.className = `badge role-${e.role}`;\n badgeRole.textContent = e.role;\n badges.appendChild(badgeRole);\n }\n\n const summary = document.createElement('div');\n summary.className = 'summary';\n summary.textContent = e.summary;\n\n body.appendChild(badges);\n body.appendChild(summary);\n\n row.appendChild(time);\n row.appendChild(body);\n\n row.addEventListener('click', () => selectEvent(e.id, { focusTimeline: true }));\n fragment.appendChild(row);\n\n if (e.date instanceof Date && !Number.isNaN(e.date.getTime())) prevDate = e.date;\n }\n\n els.timeline.appendChild(fragment);\n\n if (state.selectedId !== null) {\n const stillThere = state.filtered.some(e => e.id === state.selectedId);\n if (!stillThere) clearSelection();\n else selectEvent(state.selectedId, { scroll: false });\n } else {\n clearSelection({ syncHash: false });\n }\n }\n\n\t function selectEvent(id, { scroll = false, focusTimeline = false } = {}) {\n\t const e = state.filtered.find(item => item.id === id) || state.events.find(item => item.id === id);\n\t if (!e) return;\n\t state.selectedId = id;\n\t state.selected = e;\n\n if (focusTimeline) {\n els.timeline.focus({ preventScroll: true });\n }\n\n for (const node of els.timeline.querySelectorAll('.event.selected')) {\n node.classList.remove('selected');\n }\n\t const node = els.timeline.querySelector(`.event[data-event-id=\"${CSS.escape(String(id))}\"]`);\n\t if (node) {\n\t node.classList.add('selected');\n\t updateDetailPlacement();\n\t if (scroll) node.scrollIntoView({ block: 'center' });\n\t }\n\n\t els.detailJson.classList.remove('empty');\n\t const titleParts = [\n formatTimestamp(e.date, state.tzMode),\n `#${e.line}`,\n e.kind,\n e.payloadType ? `· ${e.payloadType}` : null,\n ].filter(Boolean);\n els.detailTitle.textContent = titleParts.join(' ');\n\n const shouldTruncate = els.truncateStrings.checked;\n renderDetailImages(e.obj);\n renderPrettyView(e.obj);\n\t els.detailJson.textContent = stringifyJson(e.obj, shouldTruncate) || '';\n\t els.copyJson.disabled = false;\n\t els.copyRaw.disabled = false;\n\t updateDetailPlacement();\n\t syncUrlHashFromControls();\n\t }\n\n function extractImageUrls(obj) {\n const payloadImages = obj?.payload?.images;\n if (Array.isArray(payloadImages)) return payloadImages.filter(v => typeof v === 'string' && v.trim());\n const rootImages = obj?.images;\n if (Array.isArray(rootImages)) return rootImages.filter(v => typeof v === 'string' && v.trim());\n return [];\n }\n\n function extractUserPrompts() {\n const prompts = [];\n\n const canonicalize = (text) => String(text ?? '')\n .replace(/\\r\\n/g, '\\n')\n .replace(/[ \\t]+\\n/g, '\\n')\n .trim();\n\n const isEnvContextBlock = (text) => {\n const t = canonicalize(text);\n return t.startsWith('<environment_context>') && t.includes('</environment_context>');\n };\n\n const hasUserMessageEvents = state.events.some((e) => e?.obj?.payload?.type === 'user_message');\n\n let lastCanonical = null;\n const pushPrompt = (text) => {\n const canon = canonicalize(text);\n if (!canon) return;\n if (canon === lastCanonical) return; // adjacent dedupe\n prompts.push(canon);\n lastCanonical = canon;\n };\n\n for (const e of state.events) {\n const payload = e?.obj?.payload;\n if (!payload || typeof payload !== 'object') continue;\n const payloadType = payload.type;\n\n if (payloadType === 'user_message' && typeof payload.message === 'string') {\n pushPrompt(payload.message);\n continue;\n }\n\n if (payloadType === 'message' && payload.role === 'user') {\n const msg = extractMessageText(payload);\n if (!msg) continue;\n if (!hasUserMessageEvents) {\n pushPrompt(msg);\n continue;\n }\n // When user_message events exist, only include environment context blocks from message events.\n if (isEnvContextBlock(msg)) pushPrompt(msg);\n }\n }\n\n return prompts.join('\\n\\n').trim();\n }\n\n function openPromptsModal(text) {\n lastActiveElementBeforePrompts = document.activeElement instanceof HTMLElement ? document.activeElement : null;\n lastBodyOverflowBeforePrompts = document.body.style.overflow;\n document.body.style.overflow = 'hidden';\n\n els.promptsText.value = text || '';\n els.promptsModal.classList.add('open');\n els.promptsModal.setAttribute('aria-hidden', 'false');\n els.promptsClose.focus();\n els.promptsText.scrollTop = 0;\n }\n\n function closePromptsModal() {\n if (!els.promptsModal.classList.contains('open')) return;\n els.promptsModal.classList.remove('open');\n els.promptsModal.setAttribute('aria-hidden', 'true');\n els.promptsText.value = '';\n document.body.style.overflow = lastBodyOverflowBeforePrompts ?? '';\n\n if (lastActiveElementBeforePrompts) lastActiveElementBeforePrompts.focus();\n lastActiveElementBeforePrompts = null;\n lastBodyOverflowBeforePrompts = null;\n }\n\n function openImageModal(src, altText = '') {\n if (!src) return;\n lastActiveElementBeforeModal = document.activeElement instanceof HTMLElement ? document.activeElement : null;\n lastBodyOverflow = document.body.style.overflow;\n document.body.style.overflow = 'hidden';\n\n els.imageModalImg.src = src;\n els.imageModalImg.alt = altText;\n els.imageModal.classList.add('open');\n els.imageModal.setAttribute('aria-hidden', 'false');\n els.imageModalClose.focus();\n }\n\n function closeImageModal() {\n if (!els.imageModal.classList.contains('open')) return;\n els.imageModal.classList.remove('open');\n els.imageModal.setAttribute('aria-hidden', 'true');\n els.imageModalImg.removeAttribute('src');\n els.imageModalImg.alt = '';\n document.body.style.overflow = lastBodyOverflow ?? '';\n\n if (lastActiveElementBeforeModal) {\n lastActiveElementBeforeModal.focus();\n }\n lastActiveElementBeforeModal = null;\n lastBodyOverflow = null;\n }\n\n function renderDetailImages(obj) {\n const urls = extractImageUrls(obj);\n els.detailImages.innerHTML = '';\n if (!urls.length) {\n els.detailImages.hidden = true;\n return;\n }\n const fragment = document.createDocumentFragment();\n urls.forEach((src, idx) => {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'thumb';\n btn.title = `Open image ${idx + 1}`;\n const img = document.createElement('img');\n img.loading = 'lazy';\n img.src = src;\n img.alt = `Image ${idx + 1}`;\n btn.appendChild(img);\n btn.addEventListener('click', () => openImageModal(src, `Image ${idx + 1}`));\n fragment.appendChild(btn);\n });\n els.detailImages.appendChild(fragment);\n els.detailImages.hidden = false;\n }\n\n function isEditableElement(el) {\n if (!(el instanceof HTMLElement)) return false;\n if (el.isContentEditable) return true;\n const tag = el.tagName;\n return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';\n }\n\n function moveSelection(delta) {\n if (!state.filtered.length) return;\n const idx = state.filtered.findIndex(e => e.id === state.selectedId);\n if (idx === -1) {\n selectEvent(state.filtered[0].id, { scroll: true });\n return;\n }\n const nextIdx = Math.max(0, Math.min(state.filtered.length - 1, idx + delta));\n if (nextIdx === idx) return;\n selectEvent(state.filtered[nextIdx].id, { scroll: true });\n }\n\n async function copyText(text) {\n if (typeof text !== 'string') return;\n try {\n await navigator.clipboard.writeText(text);\n setStatus('Copied to clipboard.', 'good');\n } catch (error) {\n const ta = document.createElement('textarea');\n ta.value = text;\n ta.style.position = 'fixed';\n ta.style.left = '-9999px';\n document.body.appendChild(ta);\n ta.select();\n document.execCommand('copy');\n document.body.removeChild(ta);\n setStatus('Copied to clipboard.', 'good');\n }\n }\n\n function setTimezoneMode(mode) {\n state.tzMode = mode;\n els.tzLocal.classList.toggle('active', mode === 'local');\n els.tzUtc.classList.toggle('active', mode === 'utc');\n applyFilters();\n if (state.selectedId !== null) selectEvent(state.selectedId);\n syncUrlHashFromControls();\n }\n\n els.promptsClose.addEventListener('click', closePromptsModal);\n els.promptsModal.addEventListener('click', (event) => {\n if (event.target === els.promptsModal) closePromptsModal();\n });\n els.promptsCopy.addEventListener('click', async () => {\n const text = els.promptsText.value || '';\n if (!text.trim()) {\n setStatus('No user prompts found.', 'warn');\n return;\n }\n await copyText(text);\n if (promptsCopiedTimer) clearTimeout(promptsCopiedTimer);\n const originalText = els.promptsCopy.textContent;\n els.promptsCopy.textContent = 'Copied!';\n promptsCopiedTimer = setTimeout(() => {\n els.promptsCopy.textContent = originalText;\n promptsCopiedTimer = null;\n }, 1500);\n });\n els.showPrompts.addEventListener('click', () => {\n const text = extractUserPrompts();\n if (!text) {\n setStatus('No user prompts found.', 'warn');\n return;\n }\n openPromptsModal(text);\n });\n\n els.imageModalClose.addEventListener('click', closeImageModal);\n els.imageModal.addEventListener('click', (event) => {\n if (event.target === els.imageModal) closeImageModal();\n });\n document.addEventListener('keydown', (event) => {\n if (event.key !== 'Escape') return;\n if (els.promptsModal.classList.contains('open')) closePromptsModal();\n if (els.imageModal.classList.contains('open')) closeImageModal();\n });\n\n\t document.addEventListener('keydown', (event) => {\n\t if (els.imageModal.classList.contains('open')) return;\n if (els.promptsModal.classList.contains('open')) return;\n\t if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;\n\t if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;\n\t if (isEditableElement(document.activeElement)) return;\n\t if (document.activeElement !== els.timeline && !els.timeline.contains(document.activeElement)) return;\n\t if (state.selectedId === null) return;\n\t event.preventDefault();\n\t moveSelection(event.key === 'ArrowUp' ? -1 : 1);\n\t });\n\n\t if (typeof wideLayoutMq.addEventListener === 'function') {\n\t wideLayoutMq.addEventListener('change', updateDetailPlacement);\n\t } else if (typeof wideLayoutMq.addListener === 'function') {\n\t wideLayoutMq.addListener(updateDetailPlacement);\n\t }\n\n\t function setTab(tabName) {\n\t for (const tab of document.querySelectorAll('.tab')) {\n\t const active = tab.dataset.tab === tabName;\n\t tab.classList.toggle('active', active);\n\t tab.setAttribute('aria-selected', active ? 'true' : 'false');\n\t }\n\t for (const panel of document.querySelectorAll('[data-panel]')) {\n\t panel.hidden = panel.dataset.panel !== tabName;\n\t }\n\t }\n\n\t function setShareUrlParam(url) {\n\t const current = new URL(window.location.href);\n\t if (url) current.searchParams.set('url', url);\n\t else current.searchParams.delete('url');\n\t window.history.replaceState(null, '', current);\n\t }\n\n\t async function fetchAndRenderUrl(url, { syncUrl = true } = {}) {\n\t const trimmed = (url || '').trim();\n\t if (!trimmed) {\n\t setStatus('Enter a URL first.', 'error');\n\t return;\n\t }\n\t if (syncUrl) setShareUrlParam(trimmed);\n\t setStatus(`Fetching ${trimmed}…`, 'warn');\n\t try {\n\t const resp = await fetch(trimmed, { mode: 'cors' });\n\t if (!resp.ok) throw new Error(`HTTP ${resp.status}`);\n\t const text = await resp.text();\n\t await loadText(text, trimmed);\n\t } catch (error) {\n\t setStatus(`Failed to fetch URL: ${String(error)}`, 'error');\n\t }\n\t }\n\n\t async function loadText(text, sourceLabel) {\n\t const started = performance.now();\n\t state.sourceLabel = sourceLabel || null;\n\t setStatus(`Parsing ${sourceLabel || 'content'}…`, 'warn');\n closePromptsModal();\n els.showPrompts.disabled = true;\n\t clearSelection({ syncHash: false });\n\n const lines = text.split(/\\r?\\n/);\n const events = [];\n const errors = [];\n let idCounter = 0;\n\n for (let i = 0; i < lines.length; i++) {\n const line = lines[i];\n if (!line.trim()) continue;\n const parsed = safeJsonParse(line);\n if (!parsed.ok) {\n errors.push({ line: i + 1, error: parsed.error });\n continue;\n }\n const obj = parsed.value;\n const ts = typeof obj.timestamp === 'string' ? new Date(obj.timestamp) : null;\n const kind = obj.type || '(unknown)';\n const payload = obj.payload && typeof obj.payload === 'object' ? obj.payload : null;\n const payloadType = payload?.type ?? null;\n const role = payload?.role ?? null;\n\n const summary = summarizeEvent(obj);\n events.push({\n id: ++idCounter,\n line: i + 1,\n raw: line,\n obj,\n date: ts,\n kind,\n payloadType,\n role,\n summary,\n search: buildSearchText(obj, summary),\n });\n }\n\n state.events = events;\n els.showPrompts.disabled = !events.length;\n updateFilterOptions(events);\n applyFilters();\n applySelectionFromHash();\n syncUrlHashFromControls();\n\n const durationMs = Math.round(performance.now() - started);\n if (errors.length) {\n const first = errors[0];\n setStatus(\n `Loaded ${events.length} events from ${sourceLabel || 'content'} in ${durationMs}ms.\\n` +\n `Could not parse ${errors.length} line(s). First error at line ${first.line}: ${String(first.error)}`,\n 'warn'\n );\n } else {\n setStatus(`Loaded ${events.length} events from ${sourceLabel || 'content'} in ${durationMs}ms.`, 'good');\n }\n }\n\n\t async function handleFile(file) {\n\t if (!file) return;\n\t setShareUrlParam(null);\n\t try {\n\t const text = await file.text();\n\t await loadText(text, file.name);\n\t } catch (error) {\n\t setStatus(`Failed to read file: ${String(error)}`, 'error');\n }\n }\n\n els.dropzone.addEventListener('click', () => els.fileInput.click());\n els.dropzone.addEventListener('keydown', (e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n els.fileInput.click();\n }\n });\n els.fileInput.addEventListener('change', () => handleFile(els.fileInput.files?.[0]));\n\n els.dropzone.addEventListener('dragover', (e) => {\n e.preventDefault();\n els.dropzone.classList.add('dragover');\n });\n els.dropzone.addEventListener('dragleave', () => els.dropzone.classList.remove('dragover'));\n els.dropzone.addEventListener('drop', (e) => {\n e.preventDefault();\n els.dropzone.classList.remove('dragover');\n const file = e.dataTransfer?.files?.[0];\n if (file) handleFile(file);\n });\n\n\t els.renderPaste.addEventListener('click', async () => {\n\t const text = els.pasteInput.value || '';\n\t if (!text.trim()) {\n\t setStatus('Paste some JSONL content first.', 'error');\n\t return;\n\t }\n\t setShareUrlParam(null);\n\t await loadText(text, 'pasted content');\n\t });\n\n\t els.fetchUrl.addEventListener('click', async () => {\n\t await fetchAndRenderUrl(els.urlInput.value, { syncUrl: true });\n\t });\n\n for (const tab of document.querySelectorAll('.tab')) {\n tab.addEventListener('click', () => setTab(tab.dataset.tab));\n }\n\n els.tzLocal.addEventListener('click', () => setTimezoneMode('local'));\n els.tzUtc.addEventListener('click', () => setTimezoneMode('utc'));\n\n els.search.addEventListener('input', () => {\n state.filters.query = els.search.value.trim().toLowerCase();\n applyFilters();\n syncUrlHashFromControls();\n });\n els.typeFilter.addEventListener('change', () => {\n state.filters.type = els.typeFilter.value;\n applyFilters();\n syncUrlHashFromControls();\n });\n els.payloadFilter.addEventListener('change', () => {\n state.filters.payload = els.payloadFilter.value;\n applyFilters();\n syncUrlHashFromControls();\n });\n els.roleFilter.addEventListener('change', () => {\n state.filters.role = els.roleFilter.value;\n applyFilters();\n syncUrlHashFromControls();\n });\n els.hideTokenCount.addEventListener('change', () => {\n state.filters.hideTokenCount = els.hideTokenCount.checked;\n applyFilters();\n syncUrlHashFromControls();\n });\n els.truncateStrings.addEventListener('change', () => {\n if (state.selectedId !== null) selectEvent(state.selectedId);\n syncUrlHashFromControls();\n });\n\n els.clearFilters.addEventListener('click', () => {\n els.search.value = '';\n state.filters.query = '';\n state.filters.type = 'all';\n state.filters.payload = 'all';\n state.filters.role = 'all';\n state.filters.hideTokenCount = true;\n els.hideTokenCount.checked = true;\n updateFilterOptions(state.events);\n applyFilters();\n syncUrlHashFromControls();\n });\n\n els.copyJson.addEventListener('click', () => {\n if (!state.selected) return;\n copyText(stringifyJson(state.selected.obj, false));\n });\n\t els.copyRaw.addEventListener('click', () => {\n\t if (!state.selected) return;\n\t copyText(state.selected.raw);\n\t });\n\n\t async function initFromQuery() {\n\t const url = new URLSearchParams(window.location.search).get('url');\n\t if (!url) return;\n\t setTab('url');\n\t els.urlInput.value = url;\n\t await fetchAndRenderUrl(url, { syncUrl: false });\n\t }\n\n\t // Initialize select options for the empty state.\n applyControlsFromHash();\n\t updateFilterOptions([]);\n syncUrlHashFromControls();\n window.addEventListener('hashchange', () => {\n applyControlsFromHash();\n updateFilterOptions(state.events);\n applyFilters();\n applySelectionFromHash();\n });\n\t void initFromQuery();\n\t </script>\n</body>\n</html>\n","numLines":2282,"startLine":1,"totalLines":2282}}}
{"parentUuid":"c6bd18f0-9245-489e-8829-399f5f4d9089","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","slug":"glittery-skipping-rivest","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01ST9myydC8cZgs1MYebwvb5","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Perfect! Now I can see the differences. Let me analyze what needs to be updated:\n\nIn codex-timeline.html:\n1. Has a modal for showing prompts (lines 892-903):\n ```html\n <div id=\"prompts-modal\" class=\"prompts-modal\" role=\"dialog\" aria-modal=\"true\" aria-hidden=\"true\" aria-label=\"User prompts\">\n <div class=\"dialog\">\n <div class=\"prompts-head\">\n <div class=\"prompts-title\">User prompts</div>\n <button type=\"button\" class=\"btn\" id=\"prompts-close\" aria-label=\"Close prompts\">Close</button>\n </div>\n <textarea id=\"prompts-text\" readonly spellcheck=\"false\"></textarea>\n <div class=\"prompts-actions\">\n <button type=\"button\" class=\"btn primary\" id=\"prompts-copy\">Copy</button>\n </div>\n </div>\n </div>\n ```\n\n2. Has CSS for the prompts modal (lines 716-771)\n\n3. Has a \"Show prompts\" button instead of \"Copy prompts\" (line 854)\n\n4. Has modal state management variables (lines 962-964)\n\n5. Has functions for opening/closing prompts modal (lines 1877-1899, 2000-2026)\n\n6. Has prompts copy event listener with timer (lines 2004-2018)\n\n7. Shows prompts in modal with read-only textarea and a Copy button\n\nIn claude-code-timeline.html:\n1. Currently has a simple \"Copy prompts\" button (line 945)\n2. Has a copyUserPrompts() function that directly copies to clipboard (lines 2047-2059)\n3. No modal implementation\n\nI need to:\n1. Add the prompts-modal CSS (from codex-timeline)\n2. Add the prompts-modal HTML element (from codex-timeline)\n\n\n3. Modify the button to display \"Show prompts\" functionality\n4. Integrate modal state management\n5. Create functions to handle modal interactions\n6. Ensure prompt extraction works correctly\n7. Set up event listeners for modal controls\n\nI'll focus on adapting the modal implementation to match the codex-timeline approach, ensuring seamless integration with Claude's specific prompt format.\n\nThe key differences involve parsing user messages, with Claude's version requiring more nuanced type and role checks. I'll leverage the existing claude-code extraction method while incorporating the modal interaction pattern.","signature":"EowUCkYIChgCKkAOk36XDo+ALeQQqb9TAAXxxClFYG0s+VRleBLUzBQ0slndTCUn3/VGyszvGoERWgu/ZH23KoGYRtdEiARI1scsEgxV4O1HMrh+D/NUj0MaDDHW+YtAGZn/bQKkjyIwE3I1SVNsXmCrmKtZKbr8lgXmJ3JQ9bF/q8V1SdzrucQnQNh4pkZLuZFMtXJ7dOT/KvMShG6BER1BtJceP4j5cIgO6p9LXYJCBWwJfXlvH8y9hOUAHzcjJtpU2xuURPVqfB16NYIKQGvec5gHO56EA9664OWG+6R9oXhNDrWTnRmp10x/WXVQhKmiPY5f07Wn96Mp/NvY+wnfSKyLFl4e/vXuWSMk1c/1aId+u2M1gt3nhYREjhVBe8vm0OU63hljBClob1Dv2LHxM5c42Zugl0JZQRYzoFQumb7CvdjuIqvmWVdjGdyJdZFeZOsSs1vbZi/THSzqgxrxfmbkaOrefCxN+/NXIfZZPs5AvcpKmRdRCVbZCxqkwTrQ3D2CuDMurNEDjhqtT7gUqDrAIXhSiB1NI/rMLdvIo59XYxfc+iSHslJGr6LLtBprZQjZ29jqNXQ5X54qNAC/XZ8srGIt2MzgOSm5XzoOMxbA/lxorHnvw2FS7UztcGWsY6aTcSuiHzC/D3ACWfF3u0CD0vaoOXi4mbT5mhIVRZEz9oqnx5c/BlFaxiEAEVcsHtWVsXB9WdAHIB8xnSB91L9qZ/s6HjNyRuJ4KXdaj6SQc7rd5IOx5Lv3znkZEEPqI+XsKULnV/4haG7Sh+64tBrgs0+if0+Aj0cCWUT7+Py3+3z3MT2senMGjnDUIJbO6+AgVaVZCsZAR1qCbHElGyPXPD3t5erXREu8V49Hfro5x9t/7iBUEjBrOtcTGzOlDvvCJggKLwx7ktquYWzO99PsrUMHGz8dShMqb8qQ9Nrqyh6b3rqA1IMbECqpWx044f8WMQZ1yFSy4+9QW1SrHiy3cYwDx0OLo1OHjd0FJfP+ihVo4wy/iGJAI05EQE7o9Ve64NG8NBT9otjJABjv9ucdcPqrxI4zsz0g/ju/T8K7/ZG3tp3QD4SnP4TfcW5AKjx7p0jH9vTl7oO4yhaFwuvURfnpt7aloyUctHUCXFXS8gDWrODga+xV23hkmjj9TT+icVpQepxIRKV/Vrq5NNfyK7w3trzncM4Wr35QMogCVrc9DfL1j3TaOl9KoIFdeigOPmBPYmisPwFAUGJqVf5KlweNQfpzqLwzjyWpzSkt/9wt+7r28qCt+W7dXZFXwHtGdFXXBu54VaOZesGHmh4r3n1ct+PDbh2HLj8s8JWPqJ8sN5jj7ih9R94dtSCVJelds0meIcqyl514/6ffL8M09L8q7Atgiyu4Et4RQ45i55bP39nceKvaUGpz0pJW6DS8MYD2oFBW4v7iaE/fGtXs8YfyKoJqJNgp9V4GMSgz54pKp8FeTOZtooRJmXqiTqD4+A/ecTS7jQAQ0/rexMnfmMSXFQQ1aPgVCh/zI5YzaPARKOcNATVYSlIJ3emwN+KGKU2ZbdSxGMOHfn4keZ6fl0Ft/V6BLs6ve7r8+HzuFZhyJ1gincGuWwcag4yKnb3g0KfjIKMk9PID8jaEPj/GYa+qc2EVMfKwJmFw0j0Kne1RyLXL+Z4AsjFre69TONX/hVt7Lz25ue944N/X/dyhVtOr8fwoGLJnNS/3ycoOpePPl6+lp7GWxJ+n6uzsu6D5YI+qiwp8j7EAam/uB2DJ0D5LTOFcu3Hf+nIeruzdLZyc9bjPzEeJEkr+FuXWdts2bOr9nFmpE0h4Arwk8V/rvtccnPR36wvlXwD2WgN0FsBVreKu0SH6CpFwBwLUVxZPZNl/hrv4FF4JMxd4taCmvVoxNfj6mTHZvWgf4s/MT2VM50LrZeGBIsyrWOSCK8mjWPKTVbZpr1QUOz6FoR9/5NeacEbYa0x6Tkybr8yXQKmfJnPtxz2w646iR7cEYGG8yhSpUf/fUpFDpD8Pj7j4JybFJ+Qf9nW4ZpoSdOQWhEvEoKjICtNhA+2dvo7Oth4ewZpJI5o2+DgF69a9lZGsd8dXR2cmvwqtsoDpiLzlvi0zBEl4cuTbLNIQQvJA58J5FHK8cVyDGAlOJ55LD7LIkxB8H0K96Hul83hVmyCoGQi7qUPaesFCBORc0IdU9TBHbi2I/9/yI9XOk9s0/RG8gH5r8q4wtsIkwOXO26I0Qokh7AhxqkzsVG1xsghSgyK5nAm9nkk/rg3WNTPzVTa0QdjOMDULGkUuw31g1r2isY4jdNGWaye+ZMQxjD8sVGY18L78LNwoXcnpu863cyy4D6azyWBM88rJKPdwR9kND3C83ESfP+CUjXGIAAZ1Ho04GPJA1seA61d8XjAWSINLioqQTFm/JE2uLYNNO+Dvv8O0OQFe8d6yM0rV7kYzZXZwgAcBMl5kkh0SpXNSC2wGOro3c2m3IaUIrteiXKJMVRwzRWBumyovyzLz+LH3t9kwOs+nOhyEbVhBy8Hk/14trPmtR2AK0j+Ua/RRbqDiSnJ8tqQar44rbzhxiNWYPpZ1jUtcb5VOLGbKY0KoQTHcUT1Mvlpttft58Rl9aU91F2+xQgSaYxRrCt8YN3G/oHk0nWP6e88P4kdnw8r1aYvwsxHVrslFUQnRGiQrQyHCleEYNyxn1itxqvnw2jzCIVnG+41SDeFP/AQvU7YQVA9q2U84S4OyUCflNjhCWAJXdQZ7cu0I4CgysrGig/WyTHfhRtkunnEtR2uSpE/YZVtOA6GNVFwRA157C6djH+4HtN5BqSN01uvc5M/QI4pEqQVBtR6gl9qT448KOB17Vvqm9GNqI9kWeW570LY7LxmRB+qh0t3KCUTwPFR+5FvCUILUqA42oQyIIkEUf/7fMLjSm54aIvZLGFEMyqHB3DxucFIIIm4oHmTzuRy8/iccjQGVUTzNixLwJuqkrOJmj/bAO6I4qI230eClnawwmfM8g8HTZTy4FHK0GkBGx2q8BZj4oWnjbNOj50VXxwsIAH6JSdI2AHweRuEG+2zKfxQnB244S2praw7l6MfUzjZo09Q+BjphGgy+n/XNaXgdcPeyxXt7diHgY8wUNQkZuTcsJwNYuk5NNn/FcDkH+kcpGcoEJJy0drNZFyW1jUUGHfrdplJYtPGxEsaifnN4jUhYTlCZaG4CGoje/AN2ZQ9NMeNvAkt48QJ3tGKfvM88+1PEYJf3C971ZPtRXz2WFLYIHvlsdoWI5DJFmcwXWkOHho+CYkzULbUmaU++QVC/5DGqd17HihY6EedQ820eIv9MXpYUbw4QI7ZjqzO5EH0hxa5g3jZuX2BgOSAcRbdMQj8vl8o8ALWVKIBFjRPDlrDh/YhcE4u45hJnnvcpNkJXbo36+BnBU9An5m45UEPYUho5ZNo1OR+zys9/NBgB"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":14,"cache_creation_input_tokens":65274,"cache_read_input_tokens":19085,"cache_creation":{"ephemeral_5m_input_tokens":65274,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWDdZirhPYeobwUWyYUBm","type":"assistant","uuid":"00066c5e-e98e-4058-a10e-b889b82b7883","timestamp":"2025-12-18T05:52:05.538Z"}
{"parentUuid":"00066c5e-e98e-4058-a10e-b889b82b7883","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","slug":"glittery-skipping-rivest","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01ST9myydC8cZgs1MYebwvb5","type":"message","role":"assistant","content":[{"type":"text","text":"Now I can see the differences. The codex-timeline.html has a \"Show prompts\" button that opens a modal dialog with the prompts in a read-only textarea and a Copy button. The claude-code-timeline.html currently just has a \"Copy prompts\" button that directly copies to clipboard.\n\nLet me upgrade claude-code-timeline.html to match the codex implementation:"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":14,"cache_creation_input_tokens":65274,"cache_read_input_tokens":19085,"cache_creation":{"ephemeral_5m_input_tokens":65274,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWDdZirhPYeobwUWyYUBm","type":"assistant","uuid":"b3645b91-02c4-4f02-88c9-ea18542db12f","timestamp":"2025-12-18T05:52:05.553Z"}
{"parentUuid":"b3645b91-02c4-4f02-88c9-ea18542db12f","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","slug":"glittery-skipping-rivest","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01ST9myydC8cZgs1MYebwvb5","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01DNEQqXF7mu6RDvi7NX3CfH","name":"Edit","input":{"file_path":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","old_string":" .json-toggle-label {\n font-size: 12px;\n color: var(--muted);\n font-weight: 500;\n }\n </style>","new_string":" .json-toggle-label {\n font-size: 12px;\n color: var(--muted);\n font-weight: 500;\n }\n\n .prompts-modal {\n position: fixed;\n inset: 0;\n z-index: 80;\n background: rgba(0, 0, 0, 0.68);\n display: none;\n padding: 18px;\n place-items: center;\n }\n\n .prompts-modal.open { display: grid; }\n\n .prompts-modal .dialog {\n position: relative;\n border-radius: 16px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 98%, transparent);\n box-shadow: var(--shadow);\n padding: 14px;\n width: min(1100px, 96vw);\n height: min(86vh, 780px);\n display: grid;\n grid-template-rows: auto 1fr auto;\n gap: 10px;\n }\n\n .prompts-head {\n display: flex;\n gap: 10px;\n align-items: center;\n justify-content: space-between;\n }\n\n .prompts-title {\n font-weight: 700;\n letter-spacing: 0.2px;\n }\n\n .prompts-modal textarea {\n width: 100%;\n height: 100%;\n resize: none;\n border-radius: 12px;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.45;\n padding: 12px;\n }\n\n .prompts-actions {\n display: flex;\n gap: 10px;\n align-items: center;\n justify-content: flex-end;\n flex-wrap: wrap;\n }\n </style>"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":14,"cache_creation_input_tokens":65274,"cache_read_input_tokens":19085,"cache_creation":{"ephemeral_5m_input_tokens":65274,"ephemeral_1h_input_tokens":0},"output_tokens":1411,"service_tier":"standard"}},"requestId":"req_011CWDdZirhPYeobwUWyYUBm","type":"assistant","uuid":"6e1b969e-3fe7-471e-a757-9572b488cdb6","timestamp":"2025-12-18T05:52:05.815Z"}
{"type":"file-history-snapshot","messageId":"6e1b969e-3fe7-471e-a757-9572b488cdb6","snapshot":{"messageId":"11cf2c88-128e-4ca4-9a83-46c5672aa70e","trackedFileBackups":{"claude-code-timeline.html":{"backupFileName":"cc3a5887c9685f80@v1","version":1,"backupTime":"2025-12-18T05:52:05.859Z"}},"timestamp":"2025-12-18T05:51:48.598Z"},"isSnapshotUpdate":true}
{"parentUuid":"6e1b969e-3fe7-471e-a757-9572b488cdb6","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","slug":"glittery-skipping-rivest","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01DNEQqXF7mu6RDvi7NX3CfH","type":"tool_result","content":"The file /Users/simon/Dropbox/dev/tools/claude-code-timeline.html has been updated. Here's the result of running `cat -n` on a snippet of the edited file:\n 854→ border-bottom: 1px solid var(--line);\n 855→ background: color-mix(in srgb, var(--panel-2) 50%, transparent);\n 856→ }\n 857→\n 858→ .json-toggle-label {\n 859→ font-size: 12px;\n 860→ color: var(--muted);\n 861→ font-weight: 500;\n 862→ }\n 863→\n 864→ .prompts-modal {\n 865→ position: fixed;\n 866→ inset: 0;\n 867→ z-index: 80;\n 868→ background: rgba(0, 0, 0, 0.68);\n 869→ display: none;\n 870→ padding: 18px;\n 871→ place-items: center;\n 872→ }\n 873→\n 874→ .prompts-modal.open { display: grid; }\n 875→\n 876→ .prompts-modal .dialog {\n 877→ position: relative;\n 878→ border-radius: 16px;\n 879→ border: 1px solid var(--line);\n 880→ background: color-mix(in srgb, var(--panel) 98%, transparent);\n 881→ box-shadow: var(--shadow);\n 882→ padding: 14px;\n 883→ width: min(1100px, 96vw);\n 884→ height: min(86vh, 780px);\n 885→ display: grid;\n 886→ grid-template-rows: auto 1fr auto;\n 887→ gap: 10px;\n 888→ }\n 889→\n 890→ .prompts-head {\n 891→ display: flex;\n 892→ gap: 10px;\n 893→ align-items: center;\n 894→ justify-content: space-between;\n 895→ }\n 896→\n 897→ .prompts-title {\n 898→ font-weight: 700;\n 899→ letter-spacing: 0.2px;\n 900→ }\n 901→\n 902→ .prompts-modal textarea {\n 903→ width: 100%;\n 904→ height: 100%;\n 905→ resize: none;\n 906→ border-radius: 12px;\n 907→ font-family: var(--mono);\n 908→ font-size: 12px;\n 909→ line-height: 1.45;\n 910→ padding: 12px;\n 911→ }\n 912→\n 913→ .prompts-actions {\n 914→ display: flex;\n 915→ gap: 10px;\n 916→ align-items: center;\n 917→ justify-content: flex-end;\n 918→ flex-wrap: wrap;\n 919→ }\n 920→ </style>\n 921→</head>\n 922→ <body>\n 923→ <div class=\"wrap\">\n 924→ <header>"}]},"uuid":"dcce2012-2232-44b0-979c-ec204d588a12","timestamp":"2025-12-18T05:52:05.852Z","toolUseResult":{"filePath":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","oldString":" .json-toggle-label {\n font-size: 12px;\n color: var(--muted);\n font-weight: 500;\n }\n </style>","newString":" .json-toggle-label {\n font-size: 12px;\n color: var(--muted);\n font-weight: 500;\n }\n\n .prompts-modal {\n position: fixed;\n inset: 0;\n z-index: 80;\n background: rgba(0, 0, 0, 0.68);\n display: none;\n padding: 18px;\n place-items: center;\n }\n\n .prompts-modal.open { display: grid; }\n\n .prompts-modal .dialog {\n position: relative;\n border-radius: 16px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 98%, transparent);\n box-shadow: var(--shadow);\n padding: 14px;\n width: min(1100px, 96vw);\n height: min(86vh, 780px);\n display: grid;\n grid-template-rows: auto 1fr auto;\n gap: 10px;\n }\n\n .prompts-head {\n display: flex;\n gap: 10px;\n align-items: center;\n justify-content: space-between;\n }\n\n .prompts-title {\n font-weight: 700;\n letter-spacing: 0.2px;\n }\n\n .prompts-modal textarea {\n width: 100%;\n height: 100%;\n resize: none;\n border-radius: 12px;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.45;\n padding: 12px;\n }\n\n .prompts-actions {\n display: flex;\n gap: 10px;\n align-items: center;\n justify-content: flex-end;\n flex-wrap: wrap;\n }\n </style>","originalFile":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>Claude Code Timeline Viewer</title>\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js\" integrity=\"sha384-osZDKVu4ipZP703HmPOhWdyBajcFyjX2Psjk//TG1Rc0AdwEtuToaylrmcK3LdAl\" crossorigin=\"anonymous\"></script>\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/markdown-it/4.4.0/markdown-it.min.js\" integrity=\"sha384-cxGp+QDeMxACP73aA0Lj0Sc/h4z1X73+umcvCyI0Vbyh3OYxgrPI+0mxG9slsMTN\" crossorigin=\"anonymous\"></script>\n <style>\n :root {\n color-scheme: light dark;\n --bg: #0b0c0f;\n --panel: #12141a;\n --panel-2: #0f1116;\n --text: #e8eaf0;\n --muted: #a6adbf;\n --line: rgba(255, 255, 255, 0.10);\n --line-2: rgba(255, 255, 255, 0.16);\n --accent: #6aa9ff;\n --accent-2: #8a5cff;\n --good: #46d39a;\n --warn: #ffcc66;\n --bad: #ff6b6b;\n --shadow: 0 18px 50px rgba(0, 0, 0, 0.35);\n --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\",\n \"Courier New\", monospace;\n --sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial,\n \"Apple Color Emoji\", \"Segoe UI Emoji\";\n }\n\n @media (prefers-color-scheme: light) {\n :root {\n --bg: #f7f8fc;\n --panel: #ffffff;\n --panel-2: #ffffff;\n --text: #10121a;\n --muted: #5b647a;\n --line: rgba(16, 18, 26, 0.12);\n --line-2: rgba(16, 18, 26, 0.18);\n --shadow: 0 18px 50px rgba(16, 18, 26, 0.10);\n }\n }\n\n * { box-sizing: border-box; }\n html, body { height: 100%; }\n body {\n margin: 0;\n font-family: var(--sans);\n background: radial-gradient(1200px 600px at 10% -10%, rgba(106, 169, 255, 0.18), transparent 45%),\n radial-gradient(1200px 600px at 90% -20%, rgba(138, 92, 255, 0.16), transparent 50%),\n var(--bg);\n color: var(--text);\n }\n\n .wrap {\n max-width: none;\n margin: 0;\n padding: 22px 16px 42px;\n }\n\n header {\n display: grid;\n gap: 10px;\n margin-bottom: 14px;\n }\n\n h1 {\n margin: 0;\n font-size: clamp(22px, 3vw, 32px);\n letter-spacing: 0.2px;\n }\n\n .sub {\n margin: 6px 0 0;\n color: var(--muted);\n line-height: 1.35;\n max-width: 70ch;\n }\n\n .controls {\n display: flex;\n flex-wrap: wrap;\n gap: 10px;\n align-items: center;\n justify-content: flex-start;\n }\n\n .controls .field {\n display: grid;\n gap: 4px;\n min-width: 180px;\n }\n\n .label {\n font-size: 12px;\n color: var(--muted);\n }\n\n input[type=\"search\"], input[type=\"url\"], select, textarea {\n font: inherit;\n color: inherit;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n border-radius: 10px;\n padding: 10px 11px;\n outline: none;\n }\n\n input[type=\"search\"]:focus, input[type=\"url\"]:focus, select:focus, textarea:focus {\n border-color: color-mix(in srgb, var(--accent) 60%, var(--line));\n box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);\n }\n\n .segmented {\n display: inline-flex;\n border: 1px solid var(--line);\n border-radius: 999px;\n overflow: hidden;\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n }\n\n .segmented button {\n border: none;\n padding: 9px 12px;\n font: inherit;\n background: transparent;\n color: var(--muted);\n cursor: pointer;\n min-width: 74px;\n }\n\n .segmented button.active {\n background: color-mix(in srgb, var(--accent) 16%, transparent);\n color: var(--text);\n }\n\n .segmented button:focus-visible {\n outline: 2px solid color-mix(in srgb, var(--accent) 70%, transparent);\n outline-offset: -2px;\n }\n\n .btn {\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n color: var(--text);\n border-radius: 10px;\n padding: 10px 12px;\n font: inherit;\n cursor: pointer;\n transition: transform 80ms ease, border-color 120ms ease;\n }\n\n .btn:hover { border-color: var(--line-2); }\n .btn:active { transform: translateY(1px); }\n .btn.primary {\n border-color: color-mix(in srgb, var(--accent) 55%, var(--line));\n background: color-mix(in srgb, var(--accent) 14%, transparent);\n }\n\n .btn:disabled {\n opacity: 0.55;\n cursor: not-allowed;\n }\n\n .panel {\n background: var(--panel);\n border: 1px solid var(--line);\n border-radius: 16px;\n box-shadow: var(--shadow);\n }\n\n .input-panel {\n padding: 14px;\n margin-bottom: 14px;\n }\n\n .filters-panel {\n padding: 14px;\n margin-bottom: 14px;\n }\n\n .input-grid {\n display: grid;\n grid-template-columns: 1fr;\n gap: 14px;\n align-items: stretch;\n }\n\n @media (min-width: 920px) {\n .input-grid {\n grid-template-columns: 380px 1fr;\n }\n }\n\n .dropzone {\n border: 1px dashed color-mix(in srgb, var(--accent) 40%, var(--line));\n border-radius: 14px;\n background: linear-gradient(\n 135deg,\n color-mix(in srgb, var(--accent) 12%, transparent),\n transparent 45%,\n color-mix(in srgb, var(--accent-2) 10%, transparent)\n );\n padding: 16px;\n display: grid;\n gap: 6px;\n cursor: pointer;\n min-height: 124px;\n align-content: center;\n }\n\n .dropzone.dragover {\n border-style: solid;\n border-color: color-mix(in srgb, var(--accent) 70%, var(--line));\n box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);\n }\n\n .dropzone strong { font-size: 15px; }\n .muted { color: var(--muted); }\n .tiny { font-size: 12px; color: var(--muted); }\n\n .tabs {\n display: flex;\n gap: 8px;\n margin-bottom: 10px;\n flex-wrap: wrap;\n align-items: center;\n }\n\n .tab {\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel-2) 94%, transparent);\n color: var(--muted);\n border-radius: 999px;\n padding: 7px 10px;\n cursor: pointer;\n font: inherit;\n }\n\n .tab.active {\n color: var(--text);\n border-color: color-mix(in srgb, var(--accent) 55%, var(--line));\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n }\n\n textarea {\n width: 100%;\n min-height: 190px;\n resize: vertical;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.4;\n }\n\n .row {\n display: flex;\n gap: 10px;\n flex-wrap: wrap;\n align-items: center;\n margin-top: 10px;\n }\n\n .status {\n margin-top: 12px;\n padding: 10px 12px;\n border-radius: 12px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel-2) 94%, transparent);\n display: none;\n white-space: pre-wrap;\n word-break: break-word;\n }\n .status.visible { display: block; }\n .status.error { border-color: color-mix(in srgb, var(--bad) 60%, var(--line)); }\n .status.good { border-color: color-mix(in srgb, var(--good) 55%, var(--line)); }\n .status.warn { border-color: color-mix(in srgb, var(--warn) 55%, var(--line)); }\n\n main {\n display: grid;\n gap: 14px;\n grid-template-columns: 1fr;\n min-height: 560px;\n }\n\n @media (min-width: 1060px) {\n main {\n grid-template-columns: 1fr 520px;\n align-items: stretch;\n }\n }\n\n .timeline-panel {\n display: grid;\n grid-template-rows: auto 1fr;\n min-height: 560px;\n }\n\n .stats {\n display: flex;\n gap: 12px;\n flex-wrap: wrap;\n align-items: baseline;\n justify-content: space-between;\n padding: 12px 14px;\n border-bottom: 1px solid var(--line);\n }\n\n .stats .big {\n font-size: 14px;\n font-weight: 600;\n }\n\n .timeline {\n overflow: auto;\n padding: 8px 0;\n }\n\n .timeline:focus-visible {\n outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent);\n outline-offset: -2px;\n border-radius: 14px;\n }\n\n .day-sep {\n position: sticky;\n top: 0;\n z-index: 2;\n padding: 8px 14px;\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n border-top: 1px solid var(--line);\n border-bottom: 1px solid var(--line);\n font-size: 12px;\n color: var(--muted);\n backdrop-filter: blur(6px);\n }\n\n .event {\n display: grid;\n grid-template-columns: 176px 1fr;\n gap: 12px;\n padding: 10px 14px;\n border-bottom: 1px solid color-mix(in srgb, var(--line) 85%, transparent);\n cursor: pointer;\n }\n\n .event:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); }\n .event.selected {\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n outline: 2px solid color-mix(in srgb, var(--accent) 35%, transparent);\n outline-offset: -2px;\n }\n\n .time {\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.35;\n color: var(--muted);\n display: grid;\n gap: 3px;\n }\n\n .time .ts {\n color: var(--text);\n font-weight: 600;\n }\n\n .badges {\n display: flex;\n gap: 6px;\n flex-wrap: wrap;\n align-items: center;\n margin-bottom: 6px;\n }\n\n .badge {\n font-size: 11px;\n font-family: var(--mono);\n padding: 2px 7px;\n border-radius: 999px;\n border: 1px solid var(--line);\n color: var(--muted);\n background: color-mix(in srgb, var(--panel-2) 90%, transparent);\n }\n\n .badge.k-user { border-color: color-mix(in srgb, #00c8ff 55%, var(--line)); color: color-mix(in srgb, #00c8ff 78%, var(--text)); }\n .badge.k-assistant { border-color: color-mix(in srgb, #ff73c4 55%, var(--line)); color: color-mix(in srgb, #ff73c4 78%, var(--text)); }\n .badge.k-file-history-snapshot { border-color: color-mix(in srgb, var(--warn) 55%, var(--line)); color: color-mix(in srgb, var(--warn) 75%, var(--text)); }\n\n .badge.ct-text { border-color: color-mix(in srgb, var(--good) 55%, var(--line)); color: color-mix(in srgb, var(--good) 75%, var(--text)); }\n .badge.ct-thinking { border-color: color-mix(in srgb, var(--accent-2) 55%, var(--line)); color: color-mix(in srgb, var(--accent-2) 75%, var(--text)); }\n .badge.ct-tool_use { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); color: color-mix(in srgb, var(--accent) 75%, var(--text)); }\n .badge.ct-tool_result { border-color: color-mix(in srgb, #8690a8 55%, var(--line)); color: color-mix(in srgb, #8690a8 70%, var(--text)); }\n .badge.ct-image { border-color: color-mix(in srgb, #ff9f43 55%, var(--line)); color: color-mix(in srgb, #ff9f43 75%, var(--text)); }\n\n .badge.role-user { border-color: color-mix(in srgb, #00c8ff 55%, var(--line)); color: color-mix(in srgb, #00c8ff 78%, var(--text)); }\n .badge.role-assistant { border-color: color-mix(in srgb, #ff73c4 55%, var(--line)); color: color-mix(in srgb, #ff73c4 78%, var(--text)); }\n\n .summary {\n font-size: 13px;\n line-height: 1.35;\n color: var(--text);\n word-break: break-word;\n }\n\n .summary code {\n font-family: var(--mono);\n font-size: 12px;\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n padding: 2px 5px;\n border-radius: 7px;\n }\n\n .detail {\n display: grid;\n grid-template-rows: auto auto auto auto 1fr;\n min-height: 560px;\n }\n\n @media (min-width: 1060px) {\n .detail {\n position: sticky;\n top: 16px;\n align-self: start;\n max-height: calc(100vh - 32px);\n }\n }\n\n .detail.inline {\n position: static;\n top: auto;\n align-self: auto;\n max-height: none;\n min-height: 0;\n margin: 8px 14px 12px;\n box-shadow: none;\n }\n\n .detail.inline pre {\n max-height: 52vh;\n }\n\n .detail-head {\n padding: 12px 14px;\n border-bottom: 1px solid var(--line);\n display: grid;\n gap: 8px;\n }\n\n .detail-title {\n font-size: 14px;\n font-weight: 700;\n line-height: 1.25;\n margin: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .detail-actions {\n display: flex;\n gap: 8px;\n flex-wrap: wrap;\n align-items: center;\n justify-content: flex-start;\n }\n\n .check {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n color: var(--muted);\n font-size: 12px;\n user-select: none;\n }\n\n pre {\n margin: 0;\n padding: 12px 14px 16px;\n overflow: auto;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.45;\n color: color-mix(in srgb, var(--text) 92%, transparent);\n background: transparent;\n white-space: pre-wrap;\n word-break: break-word;\n }\n\n .detail-images {\n display: flex;\n gap: 10px;\n padding: 12px 14px;\n border-bottom: 1px solid var(--line);\n overflow-x: auto;\n overscroll-behavior-x: contain;\n }\n\n .thumb {\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel-2) 92%, transparent);\n border-radius: 12px;\n padding: 0;\n cursor: pointer;\n width: 78px;\n height: 58px;\n overflow: hidden;\n flex: 0 0 auto;\n display: grid;\n place-items: stretch;\n }\n\n .thumb:hover { border-color: var(--line-2); }\n\n .thumb img {\n width: 100%;\n height: 100%;\n display: block;\n object-fit: cover;\n }\n\n .image-modal {\n position: fixed;\n inset: 0;\n z-index: 9999;\n background: rgba(0, 0, 0, 0.68);\n display: none;\n padding: 18px;\n place-items: center;\n }\n\n .image-modal.open { display: grid; }\n\n .image-modal .dialog {\n position: relative;\n border-radius: 16px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 98%, transparent);\n box-shadow: var(--shadow);\n padding: 14px;\n max-width: min(1200px, 96vw);\n max-height: 92vh;\n display: grid;\n place-items: center;\n }\n\n .image-modal img {\n display: block;\n max-width: min(1140px, 92vw);\n max-height: 84vh;\n width: auto;\n height: auto;\n object-fit: contain;\n border-radius: 10px;\n background: color-mix(in srgb, var(--panel-2) 92%, transparent);\n }\n\n .image-modal .close {\n position: absolute;\n top: 10px;\n right: 10px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n color: var(--text);\n border-radius: 999px;\n padding: 8px 10px;\n font: inherit;\n cursor: pointer;\n }\n\n .image-modal .close:hover { border-color: var(--line-2); }\n\n .empty {\n padding: 14px;\n color: var(--muted);\n font-size: 13px;\n line-height: 1.35;\n }\n\n /* Pretty view styles */\n .detail-pretty {\n padding: 14px;\n border-bottom: 1px solid var(--line);\n overflow: auto;\n max-height: 50vh;\n }\n\n .detail.inline .detail-pretty {\n max-height: 40vh;\n }\n\n .pretty-section {\n margin-bottom: 14px;\n }\n\n .pretty-section:last-child {\n margin-bottom: 0;\n }\n\n .pretty-label {\n font-size: 11px;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n color: var(--muted);\n margin-bottom: 6px;\n }\n\n .pretty-content {\n font-size: 13px;\n line-height: 1.5;\n }\n\n .pretty-content p {\n margin: 0 0 10px;\n }\n\n .pretty-content p:last-child {\n margin-bottom: 0;\n }\n\n .pretty-content h1, .pretty-content h2, .pretty-content h3,\n .pretty-content h4, .pretty-content h5, .pretty-content h6 {\n margin: 14px 0 8px;\n font-weight: 600;\n line-height: 1.3;\n }\n\n .pretty-content h1:first-child, .pretty-content h2:first-child,\n .pretty-content h3:first-child {\n margin-top: 0;\n }\n\n .pretty-content h1 { font-size: 1.4em; }\n .pretty-content h2 { font-size: 1.25em; }\n .pretty-content h3 { font-size: 1.1em; }\n\n .pretty-content ul, .pretty-content ol {\n margin: 8px 0;\n padding-left: 1.5em;\n list-style-position: outside;\n }\n\n .pretty-content li {\n margin: 4px 0;\n }\n\n .thinking-block ul, .thinking-block ol {\n padding-left: 2em;\n }\n\n .pretty-content code {\n font-family: var(--mono);\n font-size: 0.9em;\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n padding: 2px 5px;\n border-radius: 5px;\n }\n\n .pretty-content pre {\n margin: 10px 0;\n padding: 12px;\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n border-radius: 10px;\n overflow-x: auto;\n }\n\n .pretty-content pre code {\n background: none;\n border: none;\n padding: 0;\n font-size: 12px;\n }\n\n .pretty-content blockquote {\n margin: 10px 0;\n padding: 8px 12px;\n border-left: 3px solid var(--accent);\n background: color-mix(in srgb, var(--accent) 8%, transparent);\n border-radius: 0 8px 8px 0;\n }\n\n .pretty-content a {\n color: var(--accent);\n text-decoration: none;\n }\n\n .pretty-content a:hover {\n text-decoration: underline;\n }\n\n .pretty-content strong {\n font-weight: 600;\n }\n\n .pretty-content em {\n font-style: italic;\n }\n\n .pretty-content table {\n border-collapse: collapse;\n margin: 10px 0;\n width: 100%;\n font-size: 13px;\n }\n\n .pretty-content th,\n .pretty-content td {\n border: 1px solid var(--line);\n padding: 8px 10px;\n text-align: left;\n }\n\n .pretty-content th {\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n font-weight: 600;\n }\n\n .pretty-content tr:hover {\n background: color-mix(in srgb, var(--accent) 6%, transparent);\n }\n\n .command-block {\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n border-radius: 10px;\n overflow: hidden;\n margin: 8px 0;\n }\n\n .command-header {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 8px 12px;\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n border-bottom: 1px solid var(--line);\n font-size: 12px;\n }\n\n .command-header .tool-name {\n font-weight: 600;\n color: var(--accent);\n }\n\n .command-header .tool-desc {\n color: var(--muted);\n }\n\n .command-input {\n padding: 10px 12px;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.4;\n white-space: pre-wrap;\n word-break: break-word;\n border-bottom: 1px solid var(--line);\n }\n\n .command-input::before {\n content: '$ ';\n color: var(--good);\n font-weight: 600;\n }\n\n .command-output {\n padding: 10px 12px;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.4;\n white-space: pre-wrap;\n word-break: break-word;\n max-height: 300px;\n overflow: auto;\n color: var(--muted);\n }\n\n .file-path {\n font-family: var(--mono);\n font-size: 12px;\n padding: 6px 10px;\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n border-radius: 8px;\n display: inline-block;\n margin: 4px 0;\n }\n\n .tool-params {\n display: grid;\n gap: 8px;\n margin-top: 8px;\n }\n\n .tool-param {\n display: grid;\n grid-template-columns: auto 1fr;\n gap: 8px;\n align-items: start;\n font-size: 12px;\n }\n\n .tool-param-name {\n font-family: var(--mono);\n font-weight: 600;\n color: var(--accent);\n padding: 2px 6px;\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n border-radius: 4px;\n }\n\n .tool-param-value {\n font-family: var(--mono);\n white-space: pre-wrap;\n word-break: break-word;\n padding: 2px 0;\n }\n\n .thinking-block {\n background: color-mix(in srgb, var(--accent-2) 10%, transparent);\n border: 1px solid color-mix(in srgb, var(--accent-2) 30%, var(--line));\n border-radius: 10px;\n padding: 12px 16px;\n font-size: 13px;\n line-height: 1.5;\n }\n\n .result-block {\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n border-radius: 10px;\n padding: 12px;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.4;\n white-space: pre-wrap;\n word-break: break-word;\n max-height: 400px;\n overflow: auto;\n }\n\n .result-error {\n border-color: color-mix(in srgb, var(--bad) 50%, var(--line));\n background: color-mix(in srgb, var(--bad) 8%, transparent);\n }\n\n .json-toggle {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 8px 14px;\n border-bottom: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel-2) 50%, transparent);\n }\n\n .json-toggle-label {\n font-size: 12px;\n color: var(--muted);\n font-weight: 500;\n }\n </style>\n</head>\n <body>\n <div class=\"wrap\">\n <header>\n <div>\n <h1>Claude Code Timeline Viewer</h1>\n <p class=\"sub\">Open a Claude Code session <code>.jsonl</code> file and explore it as an interactive timeline. Times are shown in your local timezone by default. Find sessions in <code>~/.claude/projects/</code>.</p>\n </div>\n </header>\n\n <section class=\"panel input-panel\" aria-label=\"Input\">\n <div class=\"input-grid\">\n <div>\n <div id=\"dropzone\" class=\"dropzone\" tabindex=\"0\">\n <strong>Drop a <code>.jsonl</code> file here</strong>\n <div class=\"muted\">or click to choose a file</div>\n <div class=\"tiny\">Supported: file picker, drag-and-drop, paste, or URL fetch (CORS-enabled).</div>\n <input id=\"file-input\" type=\"file\" accept=\".jsonl,application/json,text/plain\" hidden />\n </div>\n </div>\n <div>\n <div class=\"tabs\" role=\"tablist\" aria-label=\"Input tabs\">\n <button type=\"button\" class=\"tab active\" data-tab=\"paste\" aria-selected=\"true\">Paste</button>\n <button type=\"button\" class=\"tab\" data-tab=\"url\" aria-selected=\"false\">URL</button>\n </div>\n\n <div id=\"panel-paste\" data-panel=\"paste\">\n <textarea id=\"paste-input\" placeholder=\"Paste JSONL content here...\"></textarea>\n <div class=\"row\">\n <button type=\"button\" class=\"btn primary\" id=\"render-paste\">Render pasted content</button>\n <div class=\"tiny\">Tip: the timeline uses one JSON object per line.</div>\n </div>\n </div>\n\n <div id=\"panel-url\" data-panel=\"url\" hidden>\n <input id=\"url-input\" type=\"url\" placeholder=\"https://example.com/session.jsonl\" />\n <div class=\"row\">\n <button type=\"button\" class=\"btn primary\" id=\"fetch-url\">Fetch and render</button>\n <div class=\"tiny\">The URL must allow cross-origin requests (CORS).</div>\n </div>\n </div>\n </div>\n </div>\n\n <div id=\"status\" class=\"status\" role=\"status\" aria-live=\"polite\"></div>\n </section>\n\n <section class=\"panel filters-panel\" aria-label=\"Filters\">\n <div class=\"controls\" aria-label=\"Timeline controls\">\n <div class=\"field\" style=\"min-width: 170px\">\n <div class=\"label\">Timezone</div>\n <div class=\"segmented\" role=\"group\" aria-label=\"Timezone toggle\">\n <button type=\"button\" id=\"tz-local\" class=\"active\">Local</button>\n <button type=\"button\" id=\"tz-utc\">UTC</button>\n </div>\n </div>\n <div class=\"field\" style=\"min-width: 220px\">\n <div class=\"label\">Search</div>\n <input id=\"search\" type=\"search\" placeholder=\"message, tool name, cwd...\" autocomplete=\"off\" />\n </div>\n <div class=\"field\" style=\"min-width: 190px\">\n <div class=\"label\">Type</div>\n <select id=\"type-filter\"></select>\n </div>\n <div class=\"field\" style=\"min-width: 210px\">\n <div class=\"label\">Content Type</div>\n <select id=\"content-type-filter\"></select>\n </div>\n <div class=\"field\" style=\"min-width: 160px\">\n <div class=\"label\">Role</div>\n <select id=\"role-filter\"></select>\n </div>\n <label class=\"check\" title=\"Hide thinking blocks (can be verbose)\">\n <input type=\"checkbox\" id=\"hide-thinking\" />\n Hide thinking\n </label>\n <button type=\"button\" class=\"btn\" id=\"clear-filters\">Clear</button>\n </div>\n </section>\n\n <div class=\"controls\" style=\"margin-bottom: 14px;\">\n <button type=\"button\" class=\"btn\" id=\"copy-prompts\">Copy prompts</button>\n </div>\n\n <main>\n <section class=\"panel timeline-panel\" aria-label=\"Timeline\">\n <div class=\"stats\">\n <div class=\"big\"><span id=\"stats-showing\">0</span> <span class=\"muted\">showing</span> <span class=\"muted\">/</span> <span id=\"stats-total\">0</span> <span class=\"muted\">events</span></div>\n <div class=\"muted\" id=\"stats-range\">-</div>\n </div>\n <div id=\"timeline\" class=\"timeline\" role=\"list\" tabindex=\"0\" aria-label=\"Events list\"></div>\n </section>\n\n <aside class=\"panel detail\" aria-label=\"Details\">\n <div class=\"detail-head\">\n <div class=\"detail-title\" id=\"detail-title\">Select an event</div>\n <div class=\"detail-actions\">\n <button type=\"button\" class=\"btn\" id=\"copy-json\" disabled>Copy JSON</button>\n <button type=\"button\" class=\"btn\" id=\"copy-raw\" disabled>Copy raw line</button>\n <label class=\"check\" title=\"Truncate very long strings in the JSON preview\">\n <input type=\"checkbox\" id=\"truncate-strings\" checked />\n Truncate long strings\n </label>\n </div>\n </div>\n <div id=\"detail-images\" class=\"detail-images\" hidden aria-label=\"Event images\"></div>\n <div id=\"detail-pretty\" class=\"detail-pretty\" hidden aria-label=\"Formatted event view\"></div>\n <div class=\"json-toggle\">\n <span class=\"json-toggle-label\">Raw JSON</span>\n </div>\n <pre id=\"detail-json\" aria-label=\"Selected event JSON\" class=\"empty\">Load a file to get started.</pre>\n </aside>\n </main>\n </div>\n\n <div id=\"image-modal\" class=\"image-modal\" role=\"dialog\" aria-modal=\"true\" aria-hidden=\"true\">\n <div class=\"dialog\">\n <button type=\"button\" class=\"close\" id=\"image-modal-close\" aria-label=\"Close image\">Close</button>\n <img id=\"image-modal-img\" alt=\"\" />\n </div>\n </div>\n\n <script>\n const els = {\n dropzone: document.getElementById('dropzone'),\n fileInput: document.getElementById('file-input'),\n pasteInput: document.getElementById('paste-input'),\n renderPaste: document.getElementById('render-paste'),\n urlInput: document.getElementById('url-input'),\n fetchUrl: document.getElementById('fetch-url'),\n status: document.getElementById('status'),\n timeline: document.getElementById('timeline'),\n detailPanel: document.querySelector('aside.detail'),\n detailTitle: document.getElementById('detail-title'),\n detailImages: document.getElementById('detail-images'),\n detailPretty: document.getElementById('detail-pretty'),\n detailJson: document.getElementById('detail-json'),\n copyJson: document.getElementById('copy-json'),\n copyRaw: document.getElementById('copy-raw'),\n truncateStrings: document.getElementById('truncate-strings'),\n tzLocal: document.getElementById('tz-local'),\n tzUtc: document.getElementById('tz-utc'),\n search: document.getElementById('search'),\n typeFilter: document.getElementById('type-filter'),\n contentTypeFilter: document.getElementById('content-type-filter'),\n roleFilter: document.getElementById('role-filter'),\n hideThinking: document.getElementById('hide-thinking'),\n statsShowing: document.getElementById('stats-showing'),\n statsTotal: document.getElementById('stats-total'),\n statsRange: document.getElementById('stats-range'),\n clearFilters: document.getElementById('clear-filters'),\n imageModal: document.getElementById('image-modal'),\n imageModalImg: document.getElementById('image-modal-img'),\n imageModalClose: document.getElementById('image-modal-close'),\n copyPrompts: document.getElementById('copy-prompts'),\n };\n\n const state = {\n sourceLabel: null,\n tzMode: 'local',\n events: [],\n filtered: [],\n selectedId: null,\n selected: null,\n filters: {\n query: '',\n type: 'all',\n contentType: 'all',\n role: 'all',\n hideThinking: false,\n },\n };\n\n let lastActiveElementBeforeModal = null;\n let lastBodyOverflow = null;\n let suppressUrlStateSync = false;\n const wideLayoutMq = window.matchMedia('(min-width: 1060px)');\n const detailDock = document.createElement('div');\n detailDock.id = 'detail-dock';\n detailDock.style.display = 'none';\n els.detailPanel.parentElement.insertBefore(detailDock, els.detailPanel);\n\n function updateDetailPlacement() {\n const inline = !wideLayoutMq.matches;\n\n if (!inline) {\n if (els.detailPanel.previousSibling !== detailDock) detailDock.after(els.detailPanel);\n els.detailPanel.classList.remove('inline');\n return;\n }\n\n const selectedNode = els.timeline.querySelector('.event.selected');\n if (!selectedNode) {\n if (els.detailPanel.previousSibling !== detailDock) detailDock.after(els.detailPanel);\n els.detailPanel.classList.remove('inline');\n return;\n }\n\n if (selectedNode.nextSibling !== els.detailPanel) selectedNode.after(els.detailPanel);\n els.detailPanel.classList.add('inline');\n }\n\n function setStatus(message, kind = 'good') {\n if (!message) {\n els.status.className = 'status';\n els.status.textContent = '';\n return;\n }\n els.status.className = `status visible ${kind}`;\n els.status.textContent = message;\n }\n\n function pad2(number) {\n return String(number).padStart(2, '0');\n }\n\n function pad3(number) {\n return String(number).padStart(3, '0');\n }\n\n function getOffsetString(date) {\n const offsetMinutes = -date.getTimezoneOffset();\n const sign = offsetMinutes >= 0 ? '+' : '-';\n const abs = Math.abs(offsetMinutes);\n const hours = Math.floor(abs / 60);\n const minutes = abs % 60;\n return `${sign}${pad2(hours)}:${pad2(minutes)}`;\n }\n\n function formatTimestamp(date, tzMode) {\n if (!(date instanceof Date) || Number.isNaN(date.getTime())) return '-';\n if (tzMode === 'utc') {\n return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ` +\n `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}:${pad2(date.getUTCSeconds())}.` +\n `${pad3(date.getUTCMilliseconds())}Z`;\n }\n return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ` +\n `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}.` +\n `${pad3(date.getMilliseconds())} ${getOffsetString(date)}`;\n }\n\n function dayKey(date, tzMode) {\n if (!(date instanceof Date) || Number.isNaN(date.getTime())) return '-';\n if (tzMode === 'utc') {\n return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())}`;\n }\n return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;\n }\n\n function formatDelta(ms) {\n if (typeof ms !== 'number' || !Number.isFinite(ms)) return '';\n const abs = Math.abs(ms);\n const sign = ms < 0 ? '-' : '+';\n if (abs < 1000) return `${sign}${Math.round(abs)}ms`;\n if (abs < 60_000) return `${sign}${(abs / 1000).toFixed(abs < 10_000 ? 2 : 1)}s`;\n if (abs < 3_600_000) {\n const m = Math.floor(abs / 60_000);\n const s = Math.floor((abs % 60_000) / 1000);\n return `${sign}${m}m ${pad2(s)}s`;\n }\n if (abs < 86_400_000) {\n const h = Math.floor(abs / 3_600_000);\n const m = Math.floor((abs % 3_600_000) / 60_000);\n return `${sign}${h}h ${m}m`;\n }\n const d = Math.floor(abs / 86_400_000);\n const h = Math.floor((abs % 86_400_000) / 3_600_000);\n return `${sign}${d}d ${h}h`;\n }\n\n function safeJsonParse(text) {\n try {\n return { ok: true, value: JSON.parse(text) };\n } catch (error) {\n return { ok: false, error };\n }\n }\n\n function extractMessageText(message) {\n if (!message || typeof message !== 'object') return null;\n const content = message.content;\n if (typeof content === 'string') return content;\n if (!Array.isArray(content)) return null;\n const parts = [];\n for (const item of content) {\n if (!item || typeof item !== 'object') continue;\n if (typeof item.text === 'string') parts.push(item.text);\n else if (typeof item.thinking === 'string') parts.push(item.thinking);\n }\n const joined = parts.join('');\n return joined || null;\n }\n\n function getContentTypes(message) {\n if (!message || typeof message !== 'object') return [];\n const content = message.content;\n if (typeof content === 'string') return ['text'];\n if (!Array.isArray(content)) return [];\n const types = new Set();\n for (const item of content) {\n if (!item || typeof item !== 'object') continue;\n if (item.type) types.add(item.type);\n }\n return Array.from(types);\n }\n\n function firstLine(text) {\n if (typeof text !== 'string') return null;\n const trimmed = text.trim();\n if (!trimmed) return null;\n const idx = trimmed.indexOf('\\n');\n return idx === -1 ? trimmed : trimmed.slice(0, idx).trim();\n }\n\n function normalizeSummaryText(text, maxLen = 260) {\n if (typeof text !== 'string') return '';\n const singleLine = text.replace(/\\s+/g, ' ').trim();\n if (singleLine.length <= maxLen) return singleLine;\n return singleLine.slice(0, maxLen - 1) + '...';\n }\n\n function summarizeEvent(obj) {\n const kind = obj?.type || '';\n const message = obj?.message;\n\n if (kind === 'file-history-snapshot') {\n const msgId = obj?.messageId ? `msg=${obj.messageId.slice(0, 8)}...` : null;\n return normalizeSummaryText(msgId || 'file-history-snapshot');\n }\n\n if (kind === 'user' || kind === 'assistant') {\n const role = message?.role || kind;\n const content = message?.content;\n\n if (typeof content === 'string') {\n return normalizeSummaryText(`${role}: ${content}`);\n }\n\n if (Array.isArray(content) && content.length > 0) {\n for (const item of content) {\n if (item?.type === 'text' && item.text) {\n return normalizeSummaryText(`${role}: ${item.text}`);\n }\n if (item?.type === 'thinking' && item.thinking) {\n return normalizeSummaryText(`${role} (thinking): ${item.thinking}`);\n }\n if (item?.type === 'tool_use') {\n const name = item.name || 'tool';\n const input = item.input;\n if (name === 'Bash' && input?.command) {\n return normalizeSummaryText(`${name}: ${input.command}`);\n }\n if (name === 'Read' && input?.file_path) {\n return normalizeSummaryText(`${name}: ${input.file_path}`);\n }\n if (name === 'Write' && input?.file_path) {\n return normalizeSummaryText(`${name}: ${input.file_path}`);\n }\n if (name === 'Edit' && input?.file_path) {\n return normalizeSummaryText(`${name}: ${input.file_path}`);\n }\n if (name === 'Glob' && input?.pattern) {\n return normalizeSummaryText(`${name}: ${input.pattern}`);\n }\n if (name === 'Grep' && input?.pattern) {\n return normalizeSummaryText(`${name}: ${input.pattern}`);\n }\n if (name === 'Task' && input?.description) {\n return normalizeSummaryText(`${name}: ${input.description}`);\n }\n if (input && typeof input === 'object') {\n const keys = Object.keys(input).slice(0, 3);\n return normalizeSummaryText(`${name}(${keys.join(', ')}${Object.keys(input).length > 3 ? ', ...' : ''})`);\n }\n return normalizeSummaryText(`${name}()`);\n }\n if (item?.type === 'tool_result') {\n const toolUseId = item.tool_use_id ? item.tool_use_id.slice(0, 12) : 'result';\n const resultContent = item.content;\n if (typeof resultContent === 'string') {\n return normalizeSummaryText(`tool_result: ${firstLine(resultContent) || toolUseId}`);\n }\n if (Array.isArray(resultContent)) {\n for (const rc of resultContent) {\n if (rc?.type === 'text' && rc.text) {\n return normalizeSummaryText(`tool_result: ${firstLine(rc.text) || toolUseId}`);\n }\n if (rc?.type === 'image') {\n return normalizeSummaryText(`tool_result: [image]`);\n }\n }\n }\n return normalizeSummaryText(`tool_result: ${toolUseId}`);\n }\n if (item?.type === 'image') {\n return normalizeSummaryText(`${role}: [image]`);\n }\n }\n return normalizeSummaryText(`${role}: [${content.length} content items]`);\n }\n\n return kind;\n }\n\n return kind || 'event';\n }\n\n function buildSearchText(obj, summary) {\n const pieces = [];\n pieces.push(obj?.type || '');\n pieces.push(obj?.uuid || '');\n pieces.push(obj?.sessionId || '');\n pieces.push(obj?.cwd || '');\n pieces.push(obj?.gitBranch || '');\n pieces.push(obj?.slug || '');\n const message = obj?.message;\n if (message && typeof message === 'object') {\n pieces.push(message.role || '');\n const msg = extractMessageText(message);\n if (msg) pieces.push(msg);\n const content = message.content;\n if (Array.isArray(content)) {\n for (const item of content) {\n if (item?.type === 'tool_use') {\n pieces.push(item.name || '');\n if (item.input && typeof item.input === 'object') {\n pieces.push(JSON.stringify(item.input).slice(0, 500));\n }\n }\n if (item?.type === 'tool_result' && typeof item.content === 'string') {\n pieces.push(item.content.slice(0, 500));\n }\n }\n }\n }\n if (typeof summary === 'string') pieces.push(summary);\n return pieces.join('\\n').toLowerCase();\n }\n\n function stringifyJson(value, truncateLongStrings) {\n const maxLen = 8000;\n return JSON.stringify(value, (key, v) => {\n if (!truncateLongStrings) return v;\n if (typeof v === 'string' && v.length > maxLen) {\n return v.slice(0, maxLen) + `... [truncated ${v.length - maxLen} chars]`;\n }\n return v;\n }, 2);\n }\n\n function updateFilterOptions(events) {\n const typeSet = new Set();\n const contentTypeSet = new Set();\n const roleSet = new Set();\n for (const e of events) {\n if (e.kind) typeSet.add(e.kind);\n for (const ct of e.contentTypes) {\n contentTypeSet.add(ct);\n }\n if (!e.contentTypes.length) contentTypeSet.add('(none)');\n if (e.role) roleSet.add(e.role);\n }\n\n function setOptions(select, items, current) {\n select.innerHTML = '';\n const optAll = document.createElement('option');\n optAll.value = 'all';\n optAll.textContent = 'All';\n select.appendChild(optAll);\n for (const item of items) {\n const opt = document.createElement('option');\n opt.value = item;\n opt.textContent = item;\n select.appendChild(opt);\n }\n select.value = current;\n }\n\n setOptions(els.typeFilter, Array.from(typeSet).sort(), state.filters.type);\n setOptions(els.contentTypeFilter, Array.from(contentTypeSet).sort(), state.filters.contentType);\n setOptions(els.roleFilter, Array.from(roleSet).sort(), state.filters.role);\n\n if (events.length) {\n state.filters.type = els.typeFilter.value || 'all';\n state.filters.contentType = els.contentTypeFilter.value || 'all';\n state.filters.role = els.roleFilter.value || 'all';\n }\n }\n\n function readHashParams() {\n const raw = window.location.hash && window.location.hash.startsWith('#')\n ? window.location.hash.slice(1)\n : '';\n return new URLSearchParams(raw);\n }\n\n function writeHashParams(params) {\n const base = window.location.href.split('#')[0];\n const hash = params.toString();\n const next = base + (hash ? `#${hash}` : '');\n window.history.replaceState(null, '', next);\n }\n\n function syncUrlHashFromControls({ clearSel = false } = {}) {\n if (suppressUrlStateSync) return;\n const params = readHashParams();\n params.set('tz', state.tzMode);\n params.set('q', (els.search.value || '').trim());\n params.set('type', state.filters.type || 'all');\n params.set('ct', state.filters.contentType || 'all');\n params.set('role', state.filters.role || 'all');\n params.set('hide', state.filters.hideThinking ? '1' : '0');\n params.set('truncate', els.truncateStrings.checked ? '1' : '0');\n const selectedLine = state.selected?.line;\n if (typeof selectedLine === 'number' && Number.isFinite(selectedLine)) {\n params.set('sel', String(selectedLine));\n } else if (clearSel) {\n params.delete('sel');\n }\n writeHashParams(params);\n }\n\n function applySelectionFromHash() {\n if (!state.events.length) return;\n const params = readHashParams();\n const raw = params.get('sel');\n if (!raw) return;\n const line = Number(raw);\n if (!Number.isFinite(line)) return;\n const found = state.events.find(e => e.line === line);\n if (!found) return;\n if (state.selectedId === found.id) return;\n suppressUrlStateSync = true;\n try {\n selectEvent(found.id, { scroll: true });\n } finally {\n suppressUrlStateSync = false;\n }\n }\n\n function applyControlsFromHash() {\n const params = readHashParams();\n suppressUrlStateSync = true;\n try {\n const q = params.get('q');\n if (q !== null) {\n els.search.value = q;\n state.filters.query = q.trim().toLowerCase();\n }\n\n const type = params.get('type');\n if (type !== null) state.filters.type = type;\n const ct = params.get('ct');\n if (ct !== null) state.filters.contentType = ct;\n const role = params.get('role');\n if (role !== null) state.filters.role = role;\n\n const hide = params.get('hide');\n if (hide !== null) {\n els.hideThinking.checked = hide === '1' || hide === 'true';\n state.filters.hideThinking = els.hideThinking.checked;\n }\n\n const truncate = params.get('truncate');\n if (truncate !== null) {\n els.truncateStrings.checked = truncate === '1' || truncate === 'true';\n }\n\n const tz = params.get('tz');\n if (tz === 'utc' || tz === 'local') {\n state.tzMode = tz;\n els.tzLocal.classList.toggle('active', tz === 'local');\n els.tzUtc.classList.toggle('active', tz === 'utc');\n }\n } finally {\n suppressUrlStateSync = false;\n }\n }\n\n function applyFilters() {\n const query = state.filters.query;\n const type = state.filters.type;\n const contentType = state.filters.contentType;\n const role = state.filters.role;\n const hideThinking = state.filters.hideThinking;\n\n const out = [];\n for (const e of state.events) {\n if (hideThinking && e.contentTypes.includes('thinking') && e.contentTypes.length === 1) continue;\n if (type !== 'all' && e.kind !== type) continue;\n if (contentType !== 'all') {\n const ct = e.contentTypes.length ? e.contentTypes : ['(none)'];\n if (!ct.includes(contentType)) continue;\n }\n if (role !== 'all' && e.role !== role) continue;\n if (query && !e.search.includes(query)) continue;\n out.push(e);\n }\n\n state.filtered = out;\n els.statsShowing.textContent = String(out.length);\n els.statsTotal.textContent = String(state.events.length);\n\n const start = out.find(e => e.date instanceof Date && !Number.isNaN(e.date.getTime()))?.date;\n const end = [...out].reverse().find(e => e.date instanceof Date && !Number.isNaN(e.date.getTime()))?.date;\n if (start && end) {\n els.statsRange.textContent = `${formatTimestamp(start, state.tzMode)} -> ${formatTimestamp(end, state.tzMode)}`;\n } else {\n els.statsRange.textContent = '-';\n }\n\n renderTimeline();\n }\n\n function clearSelection({ syncHash = true } = {}) {\n state.selectedId = null;\n state.selected = null;\n els.detailTitle.textContent = 'Select an event';\n renderDetailImages(null);\n renderPrettyView(null);\n els.detailJson.textContent = state.events.length ? 'Select an event from the timeline.' : 'Load a file to get started.';\n els.detailJson.classList.add('empty');\n els.copyJson.disabled = true;\n els.copyRaw.disabled = true;\n for (const node of els.timeline.querySelectorAll('.event.selected')) {\n node.classList.remove('selected');\n }\n updateDetailPlacement();\n if (syncHash) syncUrlHashFromControls({ clearSel: true });\n }\n\n function renderTimeline() {\n els.timeline.innerHTML = '';\n\n if (!state.filtered.length) {\n const empty = document.createElement('div');\n empty.className = 'empty';\n empty.textContent = state.events.length ? 'No events match your filters.' : 'Load a file to see a timeline.';\n els.timeline.appendChild(empty);\n clearSelection();\n return;\n }\n\n const fragment = document.createDocumentFragment();\n let lastDay = null;\n let prevDate = null;\n\n for (const e of state.filtered) {\n const day = dayKey(e.date, state.tzMode);\n if (day !== lastDay) {\n lastDay = day;\n const sep = document.createElement('div');\n sep.className = 'day-sep';\n sep.textContent = day;\n fragment.appendChild(sep);\n }\n\n const row = document.createElement('div');\n row.className = 'event';\n row.setAttribute('role', 'listitem');\n row.dataset.eventId = String(e.id);\n\n const time = document.createElement('div');\n time.className = 'time';\n\n const ts = document.createElement('div');\n ts.className = 'ts';\n ts.textContent = formatTimestamp(e.date, state.tzMode);\n ts.dataset.kind = 'timestamp';\n\n const meta = document.createElement('div');\n meta.textContent = [\n `#${e.line}`,\n (prevDate && e.date) ? formatDelta(e.date.getTime() - prevDate.getTime()) : null,\n ].filter(Boolean).join(' . ');\n\n time.appendChild(ts);\n time.appendChild(meta);\n\n const body = document.createElement('div');\n\n const badges = document.createElement('div');\n badges.className = 'badges';\n const badgeKind = document.createElement('span');\n badgeKind.className = `badge k-${e.kind}`;\n badgeKind.textContent = e.kind;\n badges.appendChild(badgeKind);\n\n for (const ct of e.contentTypes.slice(0, 2)) {\n const badgeCt = document.createElement('span');\n badgeCt.className = `badge ct-${ct}`;\n badgeCt.textContent = ct;\n badges.appendChild(badgeCt);\n }\n if (e.contentTypes.length > 2) {\n const badgeMore = document.createElement('span');\n badgeMore.className = 'badge';\n badgeMore.textContent = `+${e.contentTypes.length - 2}`;\n badges.appendChild(badgeMore);\n }\n\n if (e.role) {\n const badgeRole = document.createElement('span');\n badgeRole.className = `badge role-${e.role}`;\n badgeRole.textContent = e.role;\n badges.appendChild(badgeRole);\n }\n\n const summary = document.createElement('div');\n summary.className = 'summary';\n summary.textContent = e.summary;\n\n body.appendChild(badges);\n body.appendChild(summary);\n\n row.appendChild(time);\n row.appendChild(body);\n\n row.addEventListener('click', () => selectEvent(e.id, { focusTimeline: true }));\n fragment.appendChild(row);\n\n if (e.date instanceof Date && !Number.isNaN(e.date.getTime())) prevDate = e.date;\n }\n\n els.timeline.appendChild(fragment);\n\n if (state.selectedId !== null) {\n const stillThere = state.filtered.some(e => e.id === state.selectedId);\n if (!stillThere) clearSelection();\n else selectEvent(state.selectedId, { scroll: false });\n } else {\n clearSelection({ syncHash: false });\n }\n }\n\n function selectEvent(id, { scroll = false, focusTimeline = false } = {}) {\n const e = state.filtered.find(item => item.id === id) || state.events.find(item => item.id === id);\n if (!e) return;\n state.selectedId = id;\n state.selected = e;\n\n if (focusTimeline) {\n els.timeline.focus({ preventScroll: true });\n }\n\n for (const node of els.timeline.querySelectorAll('.event.selected')) {\n node.classList.remove('selected');\n }\n const node = els.timeline.querySelector(`.event[data-event-id=\"${CSS.escape(String(id))}\"]`);\n if (node) {\n node.classList.add('selected');\n updateDetailPlacement();\n if (scroll) node.scrollIntoView({ block: 'center' });\n }\n\n els.detailJson.classList.remove('empty');\n const titleParts = [\n formatTimestamp(e.date, state.tzMode),\n `#${e.line}`,\n e.kind,\n e.role ? `(${e.role})` : null,\n ].filter(Boolean);\n els.detailTitle.textContent = titleParts.join(' ');\n\n const shouldTruncate = els.truncateStrings.checked;\n renderDetailImages(e.obj);\n renderPrettyView(e.obj);\n els.detailJson.textContent = stringifyJson(e.obj, shouldTruncate) || '';\n els.copyJson.disabled = false;\n els.copyRaw.disabled = false;\n updateDetailPlacement();\n syncUrlHashFromControls();\n }\n\n function extractImageUrls(obj) {\n const urls = [];\n const message = obj?.message;\n if (!message || typeof message !== 'object') return urls;\n const content = message.content;\n if (!Array.isArray(content)) return urls;\n\n for (const item of content) {\n if (!item || typeof item !== 'object') continue;\n\n // Direct image in content\n if (item.type === 'image' && item.source?.type === 'base64' && item.source?.data) {\n const mediaType = item.source.media_type || 'image/png';\n urls.push(`data:${mediaType};base64,${item.source.data}`);\n }\n\n // Image in tool_result content\n if (item.type === 'tool_result' && Array.isArray(item.content)) {\n for (const rc of item.content) {\n if (rc?.type === 'image' && rc.source?.type === 'base64' && rc.source?.data) {\n const mediaType = rc.source.media_type || 'image/png';\n urls.push(`data:${mediaType};base64,${rc.source.data}`);\n }\n }\n }\n }\n\n return urls;\n }\n\n function escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n\n // Initialize markdown-it with GFM-like settings\n const md = window.markdownit({\n html: false, // Disable raw HTML input\n linkify: true, // Auto-convert URLs to links\n typographer: true, // Smart quotes, dashes\n breaks: true // Convert \\n to <br>\n });\n\n function renderMarkdown(text) {\n if (typeof text !== 'string') return '';\n\n // Render markdown to HTML\n const html = md.render(text);\n\n // Sanitize with DOMPurify to prevent XSS\n return DOMPurify.sanitize(html);\n }\n\n function renderPrettyView(obj) {\n els.detailPretty.innerHTML = '';\n\n if (!obj || typeof obj !== 'object') {\n els.detailPretty.hidden = true;\n return;\n }\n\n const message = obj.message;\n if (!message || typeof message !== 'object') {\n els.detailPretty.hidden = true;\n return;\n }\n\n const content = message.content;\n const fragment = document.createDocumentFragment();\n let hasContent = false;\n\n // Handle string content (simple text message)\n if (typeof content === 'string') {\n const section = document.createElement('div');\n section.className = 'pretty-section';\n const label = document.createElement('div');\n label.className = 'pretty-label';\n label.textContent = message.role || 'Message';\n const body = document.createElement('div');\n body.className = 'pretty-content';\n body.innerHTML = renderMarkdown(content);\n section.appendChild(label);\n section.appendChild(body);\n fragment.appendChild(section);\n hasContent = true;\n }\n\n // Handle array content\n if (Array.isArray(content)) {\n for (const item of content) {\n if (!item || typeof item !== 'object') continue;\n\n // Text content\n if (item.type === 'text' && item.text) {\n const section = document.createElement('div');\n section.className = 'pretty-section';\n const label = document.createElement('div');\n label.className = 'pretty-label';\n label.textContent = message.role === 'assistant' ? 'Response' : 'Message';\n const body = document.createElement('div');\n body.className = 'pretty-content';\n body.innerHTML = renderMarkdown(item.text);\n section.appendChild(label);\n section.appendChild(body);\n fragment.appendChild(section);\n hasContent = true;\n }\n\n // Thinking content\n if (item.type === 'thinking' && item.thinking) {\n const section = document.createElement('div');\n section.className = 'pretty-section';\n const label = document.createElement('div');\n label.className = 'pretty-label';\n label.textContent = 'Thinking';\n const body = document.createElement('div');\n body.className = 'thinking-block pretty-content';\n body.innerHTML = renderMarkdown(item.thinking);\n section.appendChild(label);\n section.appendChild(body);\n fragment.appendChild(section);\n hasContent = true;\n }\n\n // Tool use\n if (item.type === 'tool_use') {\n const section = document.createElement('div');\n section.className = 'pretty-section';\n const label = document.createElement('div');\n label.className = 'pretty-label';\n label.textContent = 'Tool Call';\n\n const toolName = item.name || 'Unknown Tool';\n const input = item.input || {};\n\n // Special handling for Bash command\n if (toolName === 'Bash' && input.command) {\n const block = document.createElement('div');\n block.className = 'command-block';\n\n const header = document.createElement('div');\n header.className = 'command-header';\n const nameSpan = document.createElement('span');\n nameSpan.className = 'tool-name';\n nameSpan.textContent = toolName;\n header.appendChild(nameSpan);\n if (input.description) {\n const descSpan = document.createElement('span');\n descSpan.className = 'tool-desc';\n descSpan.textContent = input.description;\n header.appendChild(descSpan);\n }\n\n const cmdInput = document.createElement('div');\n cmdInput.className = 'command-input';\n cmdInput.textContent = input.command;\n\n block.appendChild(header);\n block.appendChild(cmdInput);\n section.appendChild(label);\n section.appendChild(block);\n }\n // Special handling for file operations\n else if (['Read', 'Write', 'Edit', 'Glob', 'Grep'].includes(toolName)) {\n const block = document.createElement('div');\n block.className = 'command-block';\n\n const header = document.createElement('div');\n header.className = 'command-header';\n const nameSpan = document.createElement('span');\n nameSpan.className = 'tool-name';\n nameSpan.textContent = toolName;\n header.appendChild(nameSpan);\n\n block.appendChild(header);\n\n const params = document.createElement('div');\n params.className = 'tool-params';\n params.style.padding = '10px 12px';\n\n for (const [key, value] of Object.entries(input)) {\n const param = document.createElement('div');\n param.className = 'tool-param';\n const paramName = document.createElement('span');\n paramName.className = 'tool-param-name';\n paramName.textContent = key;\n const paramValue = document.createElement('span');\n paramValue.className = 'tool-param-value';\n if (typeof value === 'string') {\n paramValue.textContent = value.length > 500 ? value.slice(0, 500) + '...' : value;\n } else {\n paramValue.textContent = JSON.stringify(value, null, 2);\n }\n param.appendChild(paramName);\n param.appendChild(paramValue);\n params.appendChild(param);\n }\n\n block.appendChild(params);\n section.appendChild(label);\n section.appendChild(block);\n }\n // Generic tool display\n else {\n const block = document.createElement('div');\n block.className = 'command-block';\n\n const header = document.createElement('div');\n header.className = 'command-header';\n const nameSpan = document.createElement('span');\n nameSpan.className = 'tool-name';\n nameSpan.textContent = toolName;\n header.appendChild(nameSpan);\n\n block.appendChild(header);\n\n if (Object.keys(input).length > 0) {\n const params = document.createElement('div');\n params.className = 'tool-params';\n params.style.padding = '10px 12px';\n\n for (const [key, value] of Object.entries(input)) {\n const param = document.createElement('div');\n param.className = 'tool-param';\n const paramName = document.createElement('span');\n paramName.className = 'tool-param-name';\n paramName.textContent = key;\n const paramValue = document.createElement('span');\n paramValue.className = 'tool-param-value';\n if (typeof value === 'string') {\n paramValue.textContent = value.length > 1000 ? value.slice(0, 1000) + '...' : value;\n } else {\n paramValue.textContent = JSON.stringify(value, null, 2);\n }\n param.appendChild(paramName);\n param.appendChild(paramValue);\n params.appendChild(param);\n }\n\n block.appendChild(params);\n }\n\n section.appendChild(label);\n section.appendChild(block);\n }\n\n fragment.appendChild(section);\n hasContent = true;\n }\n\n // Tool result\n if (item.type === 'tool_result') {\n const section = document.createElement('div');\n section.className = 'pretty-section';\n const label = document.createElement('div');\n label.className = 'pretty-label';\n label.textContent = item.is_error ? 'Tool Error' : 'Tool Result';\n\n const resultContent = item.content;\n let resultText = '';\n\n if (typeof resultContent === 'string') {\n resultText = resultContent;\n } else if (Array.isArray(resultContent)) {\n for (const rc of resultContent) {\n if (rc?.type === 'text' && rc.text) {\n resultText += rc.text;\n }\n }\n }\n\n if (resultText) {\n const block = document.createElement('div');\n block.className = 'pretty-content' + (item.is_error ? ' result-error' : '');\n const displayText = resultText.length > 5000 ? resultText.slice(0, 5000) + '\\n... [truncated]' : resultText;\n block.innerHTML = renderMarkdown(displayText);\n section.appendChild(label);\n section.appendChild(block);\n fragment.appendChild(section);\n hasContent = true;\n }\n }\n }\n }\n\n if (hasContent) {\n els.detailPretty.appendChild(fragment);\n els.detailPretty.hidden = false;\n } else {\n els.detailPretty.hidden = true;\n }\n }\n\n function openImageModal(src, altText = '') {\n if (!src) return;\n lastActiveElementBeforeModal = document.activeElement instanceof HTMLElement ? document.activeElement : null;\n lastBodyOverflow = document.body.style.overflow;\n document.body.style.overflow = 'hidden';\n\n els.imageModalImg.src = src;\n els.imageModalImg.alt = altText;\n els.imageModal.classList.add('open');\n els.imageModal.setAttribute('aria-hidden', 'false');\n els.imageModalClose.focus();\n }\n\n function closeImageModal() {\n if (!els.imageModal.classList.contains('open')) return;\n els.imageModal.classList.remove('open');\n els.imageModal.setAttribute('aria-hidden', 'true');\n els.imageModalImg.removeAttribute('src');\n els.imageModalImg.alt = '';\n document.body.style.overflow = lastBodyOverflow ?? '';\n\n if (lastActiveElementBeforeModal) {\n lastActiveElementBeforeModal.focus();\n }\n lastActiveElementBeforeModal = null;\n lastBodyOverflow = null;\n }\n\n function renderDetailImages(obj) {\n const urls = extractImageUrls(obj);\n els.detailImages.innerHTML = '';\n if (!urls.length) {\n els.detailImages.hidden = true;\n return;\n }\n const fragment = document.createDocumentFragment();\n urls.forEach((src, idx) => {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'thumb';\n btn.title = `Open image ${idx + 1}`;\n const img = document.createElement('img');\n img.loading = 'lazy';\n img.src = src;\n img.alt = `Image ${idx + 1}`;\n btn.appendChild(img);\n btn.addEventListener('click', () => openImageModal(src, `Image ${idx + 1}`));\n fragment.appendChild(btn);\n });\n els.detailImages.appendChild(fragment);\n els.detailImages.hidden = false;\n }\n\n function isEditableElement(el) {\n if (!(el instanceof HTMLElement)) return false;\n if (el.isContentEditable) return true;\n const tag = el.tagName;\n return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';\n }\n\n function moveSelection(delta) {\n if (!state.filtered.length) return;\n const idx = state.filtered.findIndex(e => e.id === state.selectedId);\n if (idx === -1) {\n selectEvent(state.filtered[0].id, { scroll: true });\n return;\n }\n const nextIdx = Math.max(0, Math.min(state.filtered.length - 1, idx + delta));\n if (nextIdx === idx) return;\n selectEvent(state.filtered[nextIdx].id, { scroll: true });\n }\n\n async function copyText(text) {\n if (typeof text !== 'string') return;\n try {\n await navigator.clipboard.writeText(text);\n setStatus('Copied to clipboard.', 'good');\n } catch (error) {\n const ta = document.createElement('textarea');\n ta.value = text;\n ta.style.position = 'fixed';\n ta.style.left = '-9999px';\n document.body.appendChild(ta);\n ta.select();\n document.execCommand('copy');\n document.body.removeChild(ta);\n setStatus('Copied to clipboard.', 'good');\n }\n }\n\n function extractUserPrompts() {\n const prompts = [];\n for (const e of state.events) {\n // Filter: type=user, content-type includes text, role=user\n if (e.kind !== 'user') continue;\n if (e.role !== 'user') continue;\n if (!e.contentTypes.includes('text')) continue;\n\n const message = e.obj?.message;\n if (!message) continue;\n\n const content = message.content;\n if (typeof content === 'string') {\n prompts.push(content);\n } else if (Array.isArray(content)) {\n for (const item of content) {\n if (item?.type === 'text' && item.text) {\n prompts.push(item.text);\n }\n }\n }\n }\n return prompts.join('\\n\\n');\n }\n\n async function copyUserPrompts() {\n const text = extractUserPrompts();\n if (!text) {\n setStatus('No user prompts found.', 'warn');\n return;\n }\n await copyText(text);\n const originalText = els.copyPrompts.textContent;\n els.copyPrompts.textContent = 'Copied!';\n setTimeout(() => {\n els.copyPrompts.textContent = originalText;\n }, 2000);\n }\n\n function setTimezoneMode(mode) {\n state.tzMode = mode;\n els.tzLocal.classList.toggle('active', mode === 'local');\n els.tzUtc.classList.toggle('active', mode === 'utc');\n applyFilters();\n if (state.selectedId !== null) selectEvent(state.selectedId);\n syncUrlHashFromControls();\n }\n\n els.imageModalClose.addEventListener('click', closeImageModal);\n els.imageModal.addEventListener('click', (event) => {\n if (event.target === els.imageModal) closeImageModal();\n });\n document.addEventListener('keydown', (event) => {\n if (event.key === 'Escape') closeImageModal();\n });\n\n document.addEventListener('keydown', (event) => {\n if (els.imageModal.classList.contains('open')) return;\n if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;\n if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;\n if (isEditableElement(document.activeElement)) return;\n if (document.activeElement !== els.timeline && !els.timeline.contains(document.activeElement)) return;\n if (state.selectedId === null) return;\n event.preventDefault();\n moveSelection(event.key === 'ArrowUp' ? -1 : 1);\n });\n\n if (typeof wideLayoutMq.addEventListener === 'function') {\n wideLayoutMq.addEventListener('change', updateDetailPlacement);\n } else if (typeof wideLayoutMq.addListener === 'function') {\n wideLayoutMq.addListener(updateDetailPlacement);\n }\n\n function setTab(tabName) {\n for (const tab of document.querySelectorAll('.tab')) {\n const active = tab.dataset.tab === tabName;\n tab.classList.toggle('active', active);\n tab.setAttribute('aria-selected', active ? 'true' : 'false');\n }\n for (const panel of document.querySelectorAll('[data-panel]')) {\n panel.hidden = panel.dataset.panel !== tabName;\n }\n }\n\n function setShareUrlParam(url) {\n const current = new URL(window.location.href);\n if (url) current.searchParams.set('url', url);\n else current.searchParams.delete('url');\n window.history.replaceState(null, '', current);\n }\n\n async function fetchAndRenderUrl(url, { syncUrl = true } = {}) {\n const trimmed = (url || '').trim();\n if (!trimmed) {\n setStatus('Enter a URL first.', 'error');\n return;\n }\n if (syncUrl) setShareUrlParam(trimmed);\n setStatus(`Fetching ${trimmed}...`, 'warn');\n try {\n const resp = await fetch(trimmed, { mode: 'cors' });\n if (!resp.ok) throw new Error(`HTTP ${resp.status}`);\n const text = await resp.text();\n await loadText(text, trimmed);\n } catch (error) {\n setStatus(`Failed to fetch URL: ${String(error)}`, 'error');\n }\n }\n\n async function loadText(text, sourceLabel) {\n const started = performance.now();\n state.sourceLabel = sourceLabel || null;\n setStatus(`Parsing ${sourceLabel || 'content'}...`, 'warn');\n clearSelection({ syncHash: false });\n\n const lines = text.split(/\\r?\\n/);\n const events = [];\n const errors = [];\n let idCounter = 0;\n\n for (let i = 0; i < lines.length; i++) {\n const line = lines[i];\n if (!line.trim()) continue;\n const parsed = safeJsonParse(line);\n if (!parsed.ok) {\n errors.push({ line: i + 1, error: parsed.error });\n continue;\n }\n const obj = parsed.value;\n const ts = typeof obj.timestamp === 'string' ? new Date(obj.timestamp) : null;\n const kind = obj.type || '(unknown)';\n const message = obj.message && typeof obj.message === 'object' ? obj.message : null;\n const contentTypes = message ? getContentTypes(message) : [];\n const role = message?.role ?? null;\n\n const summary = summarizeEvent(obj);\n events.push({\n id: ++idCounter,\n line: i + 1,\n raw: line,\n obj,\n date: ts,\n kind,\n contentTypes,\n role,\n summary,\n search: buildSearchText(obj, summary),\n });\n }\n\n state.events = events;\n updateFilterOptions(events);\n applyFilters();\n applySelectionFromHash();\n syncUrlHashFromControls();\n\n const durationMs = Math.round(performance.now() - started);\n if (errors.length) {\n const first = errors[0];\n setStatus(\n `Loaded ${events.length} events from ${sourceLabel || 'content'} in ${durationMs}ms.\\n` +\n `Could not parse ${errors.length} line(s). First error at line ${first.line}: ${String(first.error)}`,\n 'warn'\n );\n } else {\n setStatus(`Loaded ${events.length} events from ${sourceLabel || 'content'} in ${durationMs}ms.`, 'good');\n }\n }\n\n async function handleFile(file) {\n if (!file) return;\n setShareUrlParam(null);\n try {\n const text = await file.text();\n await loadText(text, file.name);\n } catch (error) {\n setStatus(`Failed to read file: ${String(error)}`, 'error');\n }\n }\n\n els.dropzone.addEventListener('click', () => els.fileInput.click());\n els.dropzone.addEventListener('keydown', (e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n els.fileInput.click();\n }\n });\n els.fileInput.addEventListener('change', () => handleFile(els.fileInput.files?.[0]));\n\n els.dropzone.addEventListener('dragover', (e) => {\n e.preventDefault();\n els.dropzone.classList.add('dragover');\n });\n els.dropzone.addEventListener('dragleave', () => els.dropzone.classList.remove('dragover'));\n els.dropzone.addEventListener('drop', (e) => {\n e.preventDefault();\n els.dropzone.classList.remove('dragover');\n const file = e.dataTransfer?.files?.[0];\n if (file) handleFile(file);\n });\n\n els.renderPaste.addEventListener('click', async () => {\n const text = els.pasteInput.value || '';\n if (!text.trim()) {\n setStatus('Paste some JSONL content first.', 'error');\n return;\n }\n setShareUrlParam(null);\n await loadText(text, 'pasted content');\n });\n\n els.fetchUrl.addEventListener('click', async () => {\n await fetchAndRenderUrl(els.urlInput.value, { syncUrl: true });\n });\n\n for (const tab of document.querySelectorAll('.tab')) {\n tab.addEventListener('click', () => setTab(tab.dataset.tab));\n }\n\n els.tzLocal.addEventListener('click', () => setTimezoneMode('local'));\n els.tzUtc.addEventListener('click', () => setTimezoneMode('utc'));\n\n els.search.addEventListener('input', () => {\n state.filters.query = els.search.value.trim().toLowerCase();\n applyFilters();\n syncUrlHashFromControls();\n });\n els.typeFilter.addEventListener('change', () => {\n state.filters.type = els.typeFilter.value;\n applyFilters();\n syncUrlHashFromControls();\n });\n els.contentTypeFilter.addEventListener('change', () => {\n state.filters.contentType = els.contentTypeFilter.value;\n applyFilters();\n syncUrlHashFromControls();\n });\n els.roleFilter.addEventListener('change', () => {\n state.filters.role = els.roleFilter.value;\n applyFilters();\n syncUrlHashFromControls();\n });\n els.hideThinking.addEventListener('change', () => {\n state.filters.hideThinking = els.hideThinking.checked;\n applyFilters();\n syncUrlHashFromControls();\n });\n els.truncateStrings.addEventListener('change', () => {\n if (state.selectedId !== null) selectEvent(state.selectedId);\n syncUrlHashFromControls();\n });\n\n els.clearFilters.addEventListener('click', () => {\n els.search.value = '';\n state.filters.query = '';\n state.filters.type = 'all';\n state.filters.contentType = 'all';\n state.filters.role = 'all';\n state.filters.hideThinking = false;\n els.hideThinking.checked = false;\n updateFilterOptions(state.events);\n applyFilters();\n syncUrlHashFromControls();\n });\n\n els.copyJson.addEventListener('click', () => {\n if (!state.selected) return;\n copyText(stringifyJson(state.selected.obj, false));\n });\n els.copyRaw.addEventListener('click', () => {\n if (!state.selected) return;\n copyText(state.selected.raw);\n });\n els.copyPrompts.addEventListener('click', copyUserPrompts);\n\n async function initFromQuery() {\n const url = new URLSearchParams(window.location.search).get('url');\n if (!url) return;\n setTab('url');\n els.urlInput.value = url;\n await fetchAndRenderUrl(url, { syncUrl: false });\n }\n\n // Initialize select options for the empty state.\n applyControlsFromHash();\n updateFilterOptions([]);\n syncUrlHashFromControls();\n window.addEventListener('hashchange', () => {\n applyControlsFromHash();\n updateFilterOptions(state.events);\n applyFilters();\n applySelectionFromHash();\n });\n void initFromQuery();\n </script>\n</body>\n</html>\n","structuredPatch":[{"oldStart":860,"oldLines":6,"newStart":860,"newLines":63,"lines":[" color: var(--muted);"," font-weight: 500;"," }","+","+ .prompts-modal {","+ position: fixed;","+ inset: 0;","+ z-index: 80;","+ background: rgba(0, 0, 0, 0.68);","+ display: none;","+ padding: 18px;","+ place-items: center;","+ }","+","+ .prompts-modal.open { display: grid; }","+","+ .prompts-modal .dialog {","+ position: relative;","+ border-radius: 16px;","+ border: 1px solid var(--line);","+ background: color-mix(in srgb, var(--panel) 98%, transparent);","+ box-shadow: var(--shadow);","+ padding: 14px;","+ width: min(1100px, 96vw);","+ height: min(86vh, 780px);","+ display: grid;","+ grid-template-rows: auto 1fr auto;","+ gap: 10px;","+ }","+","+ .prompts-head {","+ display: flex;","+ gap: 10px;","+ align-items: center;","+ justify-content: space-between;","+ }","+","+ .prompts-title {","+ font-weight: 700;","+ letter-spacing: 0.2px;","+ }","+","+ .prompts-modal textarea {","+ width: 100%;","+ height: 100%;","+ resize: none;","+ border-radius: 12px;","+ font-family: var(--mono);","+ font-size: 12px;","+ line-height: 1.45;","+ padding: 12px;","+ }","+","+ .prompts-actions {","+ display: flex;","+ gap: 10px;","+ align-items: center;","+ justify-content: flex-end;","+ flex-wrap: wrap;","+ }"," </style>"," </head>"," <body>"]}],"userModified":false,"replaceAll":false}}
{"parentUuid":"dcce2012-2232-44b0-979c-ec204d588a12","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","slug":"glittery-skipping-rivest","message":{"model":"claude-haiku-4-5-20251001","id":"msg_019Nio2S7Jo7MWhSrDhcfsLJ","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Good, I've added the CSS. Now I need to add the HTML modal element and update the button.","signature":"EoECCkYIChgCKkAKpOnqwI16SRx+d+ETn6K5Yq3pARcnz55d/lrkU5duB/qA1Led1TD2SxZwuLURdLCFabQJtaet5AFheg54g5VJEgz27aExjWjVyRYfFB0aDANVyUDCY7D61WFuxiIwwMeizS4Vff//G2z9ZUtspofDKCx9Ays7+W/GME/dd+JyULZ+pNQZj48+b4A+ZgTzKmnIMQLJ/uNLU36qJmZZxNCBcfSQfvRoHiJ/jMyTUuWxtgYFRuJ5sVOOhPQW+xMaaickTFYv3dkyg82aqO56aPgbFpX2U+UnrynwrL2kRn4EO8e7ABSSVPgIwiAqTd5PrG1FdmcBoQ1iyV8YAQ=="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":12,"cache_creation_input_tokens":2334,"cache_read_input_tokens":84359,"cache_creation":{"ephemeral_5m_input_tokens":2334,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWDdadprTHe1tWeDkQKGh","type":"assistant","uuid":"283a3b6d-8131-4710-b0ad-518483c5d161","timestamp":"2025-12-18T05:52:10.055Z"}
{"parentUuid":"283a3b6d-8131-4710-b0ad-518483c5d161","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","slug":"glittery-skipping-rivest","message":{"model":"claude-haiku-4-5-20251001","id":"msg_019Nio2S7Jo7MWhSrDhcfsLJ","type":"message","role":"assistant","content":[{"type":"text","text":"Now let me add the prompts modal HTML element. I'll replace the current button and add the modal:"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":12,"cache_creation_input_tokens":2334,"cache_read_input_tokens":84359,"cache_creation":{"ephemeral_5m_input_tokens":2334,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWDdadprTHe1tWeDkQKGh","type":"assistant","uuid":"76719d09-d9a6-4630-90f3-61db7ef0d956","timestamp":"2025-12-18T05:52:10.366Z"}
{"parentUuid":"76719d09-d9a6-4630-90f3-61db7ef0d956","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","slug":"glittery-skipping-rivest","message":{"model":"claude-haiku-4-5-20251001","id":"msg_019Nio2S7Jo7MWhSrDhcfsLJ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01QRCXAtTKzjKmoWaurifwoL","name":"Edit","input":{"file_path":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","old_string":" <div class=\"controls\" style=\"margin-bottom: 14px;\">\n <button type=\"button\" class=\"btn\" id=\"copy-prompts\">Copy prompts</button>\n </div>","new_string":" <div class=\"controls\" style=\"margin-bottom: 14px;\">\n <button type=\"button\" class=\"btn\" id=\"show-prompts\" disabled>Show prompts</button>\n </div>"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":12,"cache_creation_input_tokens":2334,"cache_read_input_tokens":84359,"cache_creation":{"ephemeral_5m_input_tokens":2334,"ephemeral_1h_input_tokens":0},"output_tokens":255,"service_tier":"standard"}},"requestId":"req_011CWDdadprTHe1tWeDkQKGh","type":"assistant","uuid":"b6007095-0b68-4491-863c-69dc446e6be5","timestamp":"2025-12-18T05:52:12.074Z"}
{"parentUuid":"b6007095-0b68-4491-863c-69dc446e6be5","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","slug":"glittery-skipping-rivest","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01QRCXAtTKzjKmoWaurifwoL","type":"tool_result","content":"The file /Users/simon/Dropbox/dev/tools/claude-code-timeline.html has been updated. Here's the result of running `cat -n` on a snippet of the edited file:\n 997→ <button type=\"button\" class=\"btn\" id=\"clear-filters\">Clear</button>\n 998→ </div>\n 999→ </section>\n 1000→\n 1001→ <div class=\"controls\" style=\"margin-bottom: 14px;\">\n 1002→ <button type=\"button\" class=\"btn\" id=\"show-prompts\" disabled>Show prompts</button>\n 1003→ </div>\n 1004→\n 1005→ <main>\n 1006→ <section class=\"panel timeline-panel\" aria-label=\"Timeline\">\n 1007→ <div class=\"stats\">"}]},"uuid":"f7cc8d04-5c8c-4bce-a3b6-ff73f2429fc8","timestamp":"2025-12-18T05:52:12.134Z","toolUseResult":{"filePath":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","oldString":" <div class=\"controls\" style=\"margin-bottom: 14px;\">\n <button type=\"button\" class=\"btn\" id=\"copy-prompts\">Copy prompts</button>\n </div>","newString":" <div class=\"controls\" style=\"margin-bottom: 14px;\">\n <button type=\"button\" class=\"btn\" id=\"show-prompts\" disabled>Show prompts</button>\n </div>","originalFile":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>Claude Code Timeline Viewer</title>\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js\" integrity=\"sha384-osZDKVu4ipZP703HmPOhWdyBajcFyjX2Psjk//TG1Rc0AdwEtuToaylrmcK3LdAl\" crossorigin=\"anonymous\"></script>\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/markdown-it/4.4.0/markdown-it.min.js\" integrity=\"sha384-cxGp+QDeMxACP73aA0Lj0Sc/h4z1X73+umcvCyI0Vbyh3OYxgrPI+0mxG9slsMTN\" crossorigin=\"anonymous\"></script>\n <style>\n :root {\n color-scheme: light dark;\n --bg: #0b0c0f;\n --panel: #12141a;\n --panel-2: #0f1116;\n --text: #e8eaf0;\n --muted: #a6adbf;\n --line: rgba(255, 255, 255, 0.10);\n --line-2: rgba(255, 255, 255, 0.16);\n --accent: #6aa9ff;\n --accent-2: #8a5cff;\n --good: #46d39a;\n --warn: #ffcc66;\n --bad: #ff6b6b;\n --shadow: 0 18px 50px rgba(0, 0, 0, 0.35);\n --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\",\n \"Courier New\", monospace;\n --sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial,\n \"Apple Color Emoji\", \"Segoe UI Emoji\";\n }\n\n @media (prefers-color-scheme: light) {\n :root {\n --bg: #f7f8fc;\n --panel: #ffffff;\n --panel-2: #ffffff;\n --text: #10121a;\n --muted: #5b647a;\n --line: rgba(16, 18, 26, 0.12);\n --line-2: rgba(16, 18, 26, 0.18);\n --shadow: 0 18px 50px rgba(16, 18, 26, 0.10);\n }\n }\n\n * { box-sizing: border-box; }\n html, body { height: 100%; }\n body {\n margin: 0;\n font-family: var(--sans);\n background: radial-gradient(1200px 600px at 10% -10%, rgba(106, 169, 255, 0.18), transparent 45%),\n radial-gradient(1200px 600px at 90% -20%, rgba(138, 92, 255, 0.16), transparent 50%),\n var(--bg);\n color: var(--text);\n }\n\n .wrap {\n max-width: none;\n margin: 0;\n padding: 22px 16px 42px;\n }\n\n header {\n display: grid;\n gap: 10px;\n margin-bottom: 14px;\n }\n\n h1 {\n margin: 0;\n font-size: clamp(22px, 3vw, 32px);\n letter-spacing: 0.2px;\n }\n\n .sub {\n margin: 6px 0 0;\n color: var(--muted);\n line-height: 1.35;\n max-width: 70ch;\n }\n\n .controls {\n display: flex;\n flex-wrap: wrap;\n gap: 10px;\n align-items: center;\n justify-content: flex-start;\n }\n\n .controls .field {\n display: grid;\n gap: 4px;\n min-width: 180px;\n }\n\n .label {\n font-size: 12px;\n color: var(--muted);\n }\n\n input[type=\"search\"], input[type=\"url\"], select, textarea {\n font: inherit;\n color: inherit;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n border-radius: 10px;\n padding: 10px 11px;\n outline: none;\n }\n\n input[type=\"search\"]:focus, input[type=\"url\"]:focus, select:focus, textarea:focus {\n border-color: color-mix(in srgb, var(--accent) 60%, var(--line));\n box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);\n }\n\n .segmented {\n display: inline-flex;\n border: 1px solid var(--line);\n border-radius: 999px;\n overflow: hidden;\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n }\n\n .segmented button {\n border: none;\n padding: 9px 12px;\n font: inherit;\n background: transparent;\n color: var(--muted);\n cursor: pointer;\n min-width: 74px;\n }\n\n .segmented button.active {\n background: color-mix(in srgb, var(--accent) 16%, transparent);\n color: var(--text);\n }\n\n .segmented button:focus-visible {\n outline: 2px solid color-mix(in srgb, var(--accent) 70%, transparent);\n outline-offset: -2px;\n }\n\n .btn {\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n color: var(--text);\n border-radius: 10px;\n padding: 10px 12px;\n font: inherit;\n cursor: pointer;\n transition: transform 80ms ease, border-color 120ms ease;\n }\n\n .btn:hover { border-color: var(--line-2); }\n .btn:active { transform: translateY(1px); }\n .btn.primary {\n border-color: color-mix(in srgb, var(--accent) 55%, var(--line));\n background: color-mix(in srgb, var(--accent) 14%, transparent);\n }\n\n .btn:disabled {\n opacity: 0.55;\n cursor: not-allowed;\n }\n\n .panel {\n background: var(--panel);\n border: 1px solid var(--line);\n border-radius: 16px;\n box-shadow: var(--shadow);\n }\n\n .input-panel {\n padding: 14px;\n margin-bottom: 14px;\n }\n\n .filters-panel {\n padding: 14px;\n margin-bottom: 14px;\n }\n\n .input-grid {\n display: grid;\n grid-template-columns: 1fr;\n gap: 14px;\n align-items: stretch;\n }\n\n @media (min-width: 920px) {\n .input-grid {\n grid-template-columns: 380px 1fr;\n }\n }\n\n .dropzone {\n border: 1px dashed color-mix(in srgb, var(--accent) 40%, var(--line));\n border-radius: 14px;\n background: linear-gradient(\n 135deg,\n color-mix(in srgb, var(--accent) 12%, transparent),\n transparent 45%,\n color-mix(in srgb, var(--accent-2) 10%, transparent)\n );\n padding: 16px;\n display: grid;\n gap: 6px;\n cursor: pointer;\n min-height: 124px;\n align-content: center;\n }\n\n .dropzone.dragover {\n border-style: solid;\n border-color: color-mix(in srgb, var(--accent) 70%, var(--line));\n box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);\n }\n\n .dropzone strong { font-size: 15px; }\n .muted { color: var(--muted); }\n .tiny { font-size: 12px; color: var(--muted); }\n\n .tabs {\n display: flex;\n gap: 8px;\n margin-bottom: 10px;\n flex-wrap: wrap;\n align-items: center;\n }\n\n .tab {\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel-2) 94%, transparent);\n color: var(--muted);\n border-radius: 999px;\n padding: 7px 10px;\n cursor: pointer;\n font: inherit;\n }\n\n .tab.active {\n color: var(--text);\n border-color: color-mix(in srgb, var(--accent) 55%, var(--line));\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n }\n\n textarea {\n width: 100%;\n min-height: 190px;\n resize: vertical;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.4;\n }\n\n .row {\n display: flex;\n gap: 10px;\n flex-wrap: wrap;\n align-items: center;\n margin-top: 10px;\n }\n\n .status {\n margin-top: 12px;\n padding: 10px 12px;\n border-radius: 12px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel-2) 94%, transparent);\n display: none;\n white-space: pre-wrap;\n word-break: break-word;\n }\n .status.visible { display: block; }\n .status.error { border-color: color-mix(in srgb, var(--bad) 60%, var(--line)); }\n .status.good { border-color: color-mix(in srgb, var(--good) 55%, var(--line)); }\n .status.warn { border-color: color-mix(in srgb, var(--warn) 55%, var(--line)); }\n\n main {\n display: grid;\n gap: 14px;\n grid-template-columns: 1fr;\n min-height: 560px;\n }\n\n @media (min-width: 1060px) {\n main {\n grid-template-columns: 1fr 520px;\n align-items: stretch;\n }\n }\n\n .timeline-panel {\n display: grid;\n grid-template-rows: auto 1fr;\n min-height: 560px;\n }\n\n .stats {\n display: flex;\n gap: 12px;\n flex-wrap: wrap;\n align-items: baseline;\n justify-content: space-between;\n padding: 12px 14px;\n border-bottom: 1px solid var(--line);\n }\n\n .stats .big {\n font-size: 14px;\n font-weight: 600;\n }\n\n .timeline {\n overflow: auto;\n padding: 8px 0;\n }\n\n .timeline:focus-visible {\n outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent);\n outline-offset: -2px;\n border-radius: 14px;\n }\n\n .day-sep {\n position: sticky;\n top: 0;\n z-index: 2;\n padding: 8px 14px;\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n border-top: 1px solid var(--line);\n border-bottom: 1px solid var(--line);\n font-size: 12px;\n color: var(--muted);\n backdrop-filter: blur(6px);\n }\n\n .event {\n display: grid;\n grid-template-columns: 176px 1fr;\n gap: 12px;\n padding: 10px 14px;\n border-bottom: 1px solid color-mix(in srgb, var(--line) 85%, transparent);\n cursor: pointer;\n }\n\n .event:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); }\n .event.selected {\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n outline: 2px solid color-mix(in srgb, var(--accent) 35%, transparent);\n outline-offset: -2px;\n }\n\n .time {\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.35;\n color: var(--muted);\n display: grid;\n gap: 3px;\n }\n\n .time .ts {\n color: var(--text);\n font-weight: 600;\n }\n\n .badges {\n display: flex;\n gap: 6px;\n flex-wrap: wrap;\n align-items: center;\n margin-bottom: 6px;\n }\n\n .badge {\n font-size: 11px;\n font-family: var(--mono);\n padding: 2px 7px;\n border-radius: 999px;\n border: 1px solid var(--line);\n color: var(--muted);\n background: color-mix(in srgb, var(--panel-2) 90%, transparent);\n }\n\n .badge.k-user { border-color: color-mix(in srgb, #00c8ff 55%, var(--line)); color: color-mix(in srgb, #00c8ff 78%, var(--text)); }\n .badge.k-assistant { border-color: color-mix(in srgb, #ff73c4 55%, var(--line)); color: color-mix(in srgb, #ff73c4 78%, var(--text)); }\n .badge.k-file-history-snapshot { border-color: color-mix(in srgb, var(--warn) 55%, var(--line)); color: color-mix(in srgb, var(--warn) 75%, var(--text)); }\n\n .badge.ct-text { border-color: color-mix(in srgb, var(--good) 55%, var(--line)); color: color-mix(in srgb, var(--good) 75%, var(--text)); }\n .badge.ct-thinking { border-color: color-mix(in srgb, var(--accent-2) 55%, var(--line)); color: color-mix(in srgb, var(--accent-2) 75%, var(--text)); }\n .badge.ct-tool_use { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); color: color-mix(in srgb, var(--accent) 75%, var(--text)); }\n .badge.ct-tool_result { border-color: color-mix(in srgb, #8690a8 55%, var(--line)); color: color-mix(in srgb, #8690a8 70%, var(--text)); }\n .badge.ct-image { border-color: color-mix(in srgb, #ff9f43 55%, var(--line)); color: color-mix(in srgb, #ff9f43 75%, var(--text)); }\n\n .badge.role-user { border-color: color-mix(in srgb, #00c8ff 55%, var(--line)); color: color-mix(in srgb, #00c8ff 78%, var(--text)); }\n .badge.role-assistant { border-color: color-mix(in srgb, #ff73c4 55%, var(--line)); color: color-mix(in srgb, #ff73c4 78%, var(--text)); }\n\n .summary {\n font-size: 13px;\n line-height: 1.35;\n color: var(--text);\n word-break: break-word;\n }\n\n .summary code {\n font-family: var(--mono);\n font-size: 12px;\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n padding: 2px 5px;\n border-radius: 7px;\n }\n\n .detail {\n display: grid;\n grid-template-rows: auto auto auto auto 1fr;\n min-height: 560px;\n }\n\n @media (min-width: 1060px) {\n .detail {\n position: sticky;\n top: 16px;\n align-self: start;\n max-height: calc(100vh - 32px);\n }\n }\n\n .detail.inline {\n position: static;\n top: auto;\n align-self: auto;\n max-height: none;\n min-height: 0;\n margin: 8px 14px 12px;\n box-shadow: none;\n }\n\n .detail.inline pre {\n max-height: 52vh;\n }\n\n .detail-head {\n padding: 12px 14px;\n border-bottom: 1px solid var(--line);\n display: grid;\n gap: 8px;\n }\n\n .detail-title {\n font-size: 14px;\n font-weight: 700;\n line-height: 1.25;\n margin: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .detail-actions {\n display: flex;\n gap: 8px;\n flex-wrap: wrap;\n align-items: center;\n justify-content: flex-start;\n }\n\n .check {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n color: var(--muted);\n font-size: 12px;\n user-select: none;\n }\n\n pre {\n margin: 0;\n padding: 12px 14px 16px;\n overflow: auto;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.45;\n color: color-mix(in srgb, var(--text) 92%, transparent);\n background: transparent;\n white-space: pre-wrap;\n word-break: break-word;\n }\n\n .detail-images {\n display: flex;\n gap: 10px;\n padding: 12px 14px;\n border-bottom: 1px solid var(--line);\n overflow-x: auto;\n overscroll-behavior-x: contain;\n }\n\n .thumb {\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel-2) 92%, transparent);\n border-radius: 12px;\n padding: 0;\n cursor: pointer;\n width: 78px;\n height: 58px;\n overflow: hidden;\n flex: 0 0 auto;\n display: grid;\n place-items: stretch;\n }\n\n .thumb:hover { border-color: var(--line-2); }\n\n .thumb img {\n width: 100%;\n height: 100%;\n display: block;\n object-fit: cover;\n }\n\n .image-modal {\n position: fixed;\n inset: 0;\n z-index: 9999;\n background: rgba(0, 0, 0, 0.68);\n display: none;\n padding: 18px;\n place-items: center;\n }\n\n .image-modal.open { display: grid; }\n\n .image-modal .dialog {\n position: relative;\n border-radius: 16px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 98%, transparent);\n box-shadow: var(--shadow);\n padding: 14px;\n max-width: min(1200px, 96vw);\n max-height: 92vh;\n display: grid;\n place-items: center;\n }\n\n .image-modal img {\n display: block;\n max-width: min(1140px, 92vw);\n max-height: 84vh;\n width: auto;\n height: auto;\n object-fit: contain;\n border-radius: 10px;\n background: color-mix(in srgb, var(--panel-2) 92%, transparent);\n }\n\n .image-modal .close {\n position: absolute;\n top: 10px;\n right: 10px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n color: var(--text);\n border-radius: 999px;\n padding: 8px 10px;\n font: inherit;\n cursor: pointer;\n }\n\n .image-modal .close:hover { border-color: var(--line-2); }\n\n .empty {\n padding: 14px;\n color: var(--muted);\n font-size: 13px;\n line-height: 1.35;\n }\n\n /* Pretty view styles */\n .detail-pretty {\n padding: 14px;\n border-bottom: 1px solid var(--line);\n overflow: auto;\n max-height: 50vh;\n }\n\n .detail.inline .detail-pretty {\n max-height: 40vh;\n }\n\n .pretty-section {\n margin-bottom: 14px;\n }\n\n .pretty-section:last-child {\n margin-bottom: 0;\n }\n\n .pretty-label {\n font-size: 11px;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n color: var(--muted);\n margin-bottom: 6px;\n }\n\n .pretty-content {\n font-size: 13px;\n line-height: 1.5;\n }\n\n .pretty-content p {\n margin: 0 0 10px;\n }\n\n .pretty-content p:last-child {\n margin-bottom: 0;\n }\n\n .pretty-content h1, .pretty-content h2, .pretty-content h3,\n .pretty-content h4, .pretty-content h5, .pretty-content h6 {\n margin: 14px 0 8px;\n font-weight: 600;\n line-height: 1.3;\n }\n\n .pretty-content h1:first-child, .pretty-content h2:first-child,\n .pretty-content h3:first-child {\n margin-top: 0;\n }\n\n .pretty-content h1 { font-size: 1.4em; }\n .pretty-content h2 { font-size: 1.25em; }\n .pretty-content h3 { font-size: 1.1em; }\n\n .pretty-content ul, .pretty-content ol {\n margin: 8px 0;\n padding-left: 1.5em;\n list-style-position: outside;\n }\n\n .pretty-content li {\n margin: 4px 0;\n }\n\n .thinking-block ul, .thinking-block ol {\n padding-left: 2em;\n }\n\n .pretty-content code {\n font-family: var(--mono);\n font-size: 0.9em;\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n padding: 2px 5px;\n border-radius: 5px;\n }\n\n .pretty-content pre {\n margin: 10px 0;\n padding: 12px;\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n border-radius: 10px;\n overflow-x: auto;\n }\n\n .pretty-content pre code {\n background: none;\n border: none;\n padding: 0;\n font-size: 12px;\n }\n\n .pretty-content blockquote {\n margin: 10px 0;\n padding: 8px 12px;\n border-left: 3px solid var(--accent);\n background: color-mix(in srgb, var(--accent) 8%, transparent);\n border-radius: 0 8px 8px 0;\n }\n\n .pretty-content a {\n color: var(--accent);\n text-decoration: none;\n }\n\n .pretty-content a:hover {\n text-decoration: underline;\n }\n\n .pretty-content strong {\n font-weight: 600;\n }\n\n .pretty-content em {\n font-style: italic;\n }\n\n .pretty-content table {\n border-collapse: collapse;\n margin: 10px 0;\n width: 100%;\n font-size: 13px;\n }\n\n .pretty-content th,\n .pretty-content td {\n border: 1px solid var(--line);\n padding: 8px 10px;\n text-align: left;\n }\n\n .pretty-content th {\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n font-weight: 600;\n }\n\n .pretty-content tr:hover {\n background: color-mix(in srgb, var(--accent) 6%, transparent);\n }\n\n .command-block {\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n border-radius: 10px;\n overflow: hidden;\n margin: 8px 0;\n }\n\n .command-header {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 8px 12px;\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n border-bottom: 1px solid var(--line);\n font-size: 12px;\n }\n\n .command-header .tool-name {\n font-weight: 600;\n color: var(--accent);\n }\n\n .command-header .tool-desc {\n color: var(--muted);\n }\n\n .command-input {\n padding: 10px 12px;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.4;\n white-space: pre-wrap;\n word-break: break-word;\n border-bottom: 1px solid var(--line);\n }\n\n .command-input::before {\n content: '$ ';\n color: var(--good);\n font-weight: 600;\n }\n\n .command-output {\n padding: 10px 12px;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.4;\n white-space: pre-wrap;\n word-break: break-word;\n max-height: 300px;\n overflow: auto;\n color: var(--muted);\n }\n\n .file-path {\n font-family: var(--mono);\n font-size: 12px;\n padding: 6px 10px;\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n border-radius: 8px;\n display: inline-block;\n margin: 4px 0;\n }\n\n .tool-params {\n display: grid;\n gap: 8px;\n margin-top: 8px;\n }\n\n .tool-param {\n display: grid;\n grid-template-columns: auto 1fr;\n gap: 8px;\n align-items: start;\n font-size: 12px;\n }\n\n .tool-param-name {\n font-family: var(--mono);\n font-weight: 600;\n color: var(--accent);\n padding: 2px 6px;\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n border-radius: 4px;\n }\n\n .tool-param-value {\n font-family: var(--mono);\n white-space: pre-wrap;\n word-break: break-word;\n padding: 2px 0;\n }\n\n .thinking-block {\n background: color-mix(in srgb, var(--accent-2) 10%, transparent);\n border: 1px solid color-mix(in srgb, var(--accent-2) 30%, var(--line));\n border-radius: 10px;\n padding: 12px 16px;\n font-size: 13px;\n line-height: 1.5;\n }\n\n .result-block {\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n border-radius: 10px;\n padding: 12px;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.4;\n white-space: pre-wrap;\n word-break: break-word;\n max-height: 400px;\n overflow: auto;\n }\n\n .result-error {\n border-color: color-mix(in srgb, var(--bad) 50%, var(--line));\n background: color-mix(in srgb, var(--bad) 8%, transparent);\n }\n\n .json-toggle {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 8px 14px;\n border-bottom: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel-2) 50%, transparent);\n }\n\n .json-toggle-label {\n font-size: 12px;\n color: var(--muted);\n font-weight: 500;\n }\n\n .prompts-modal {\n position: fixed;\n inset: 0;\n z-index: 80;\n background: rgba(0, 0, 0, 0.68);\n display: none;\n padding: 18px;\n place-items: center;\n }\n\n .prompts-modal.open { display: grid; }\n\n .prompts-modal .dialog {\n position: relative;\n border-radius: 16px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 98%, transparent);\n box-shadow: var(--shadow);\n padding: 14px;\n width: min(1100px, 96vw);\n height: min(86vh, 780px);\n display: grid;\n grid-template-rows: auto 1fr auto;\n gap: 10px;\n }\n\n .prompts-head {\n display: flex;\n gap: 10px;\n align-items: center;\n justify-content: space-between;\n }\n\n .prompts-title {\n font-weight: 700;\n letter-spacing: 0.2px;\n }\n\n .prompts-modal textarea {\n width: 100%;\n height: 100%;\n resize: none;\n border-radius: 12px;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.45;\n padding: 12px;\n }\n\n .prompts-actions {\n display: flex;\n gap: 10px;\n align-items: center;\n justify-content: flex-end;\n flex-wrap: wrap;\n }\n </style>\n</head>\n <body>\n <div class=\"wrap\">\n <header>\n <div>\n <h1>Claude Code Timeline Viewer</h1>\n <p class=\"sub\">Open a Claude Code session <code>.jsonl</code> file and explore it as an interactive timeline. Times are shown in your local timezone by default. Find sessions in <code>~/.claude/projects/</code>.</p>\n </div>\n </header>\n\n <section class=\"panel input-panel\" aria-label=\"Input\">\n <div class=\"input-grid\">\n <div>\n <div id=\"dropzone\" class=\"dropzone\" tabindex=\"0\">\n <strong>Drop a <code>.jsonl</code> file here</strong>\n <div class=\"muted\">or click to choose a file</div>\n <div class=\"tiny\">Supported: file picker, drag-and-drop, paste, or URL fetch (CORS-enabled).</div>\n <input id=\"file-input\" type=\"file\" accept=\".jsonl,application/json,text/plain\" hidden />\n </div>\n </div>\n <div>\n <div class=\"tabs\" role=\"tablist\" aria-label=\"Input tabs\">\n <button type=\"button\" class=\"tab active\" data-tab=\"paste\" aria-selected=\"true\">Paste</button>\n <button type=\"button\" class=\"tab\" data-tab=\"url\" aria-selected=\"false\">URL</button>\n </div>\n\n <div id=\"panel-paste\" data-panel=\"paste\">\n <textarea id=\"paste-input\" placeholder=\"Paste JSONL content here...\"></textarea>\n <div class=\"row\">\n <button type=\"button\" class=\"btn primary\" id=\"render-paste\">Render pasted content</button>\n <div class=\"tiny\">Tip: the timeline uses one JSON object per line.</div>\n </div>\n </div>\n\n <div id=\"panel-url\" data-panel=\"url\" hidden>\n <input id=\"url-input\" type=\"url\" placeholder=\"https://example.com/session.jsonl\" />\n <div class=\"row\">\n <button type=\"button\" class=\"btn primary\" id=\"fetch-url\">Fetch and render</button>\n <div class=\"tiny\">The URL must allow cross-origin requests (CORS).</div>\n </div>\n </div>\n </div>\n </div>\n\n <div id=\"status\" class=\"status\" role=\"status\" aria-live=\"polite\"></div>\n </section>\n\n <section class=\"panel filters-panel\" aria-label=\"Filters\">\n <div class=\"controls\" aria-label=\"Timeline controls\">\n <div class=\"field\" style=\"min-width: 170px\">\n <div class=\"label\">Timezone</div>\n <div class=\"segmented\" role=\"group\" aria-label=\"Timezone toggle\">\n <button type=\"button\" id=\"tz-local\" class=\"active\">Local</button>\n <button type=\"button\" id=\"tz-utc\">UTC</button>\n </div>\n </div>\n <div class=\"field\" style=\"min-width: 220px\">\n <div class=\"label\">Search</div>\n <input id=\"search\" type=\"search\" placeholder=\"message, tool name, cwd...\" autocomplete=\"off\" />\n </div>\n <div class=\"field\" style=\"min-width: 190px\">\n <div class=\"label\">Type</div>\n <select id=\"type-filter\"></select>\n </div>\n <div class=\"field\" style=\"min-width: 210px\">\n <div class=\"label\">Content Type</div>\n <select id=\"content-type-filter\"></select>\n </div>\n <div class=\"field\" style=\"min-width: 160px\">\n <div class=\"label\">Role</div>\n <select id=\"role-filter\"></select>\n </div>\n <label class=\"check\" title=\"Hide thinking blocks (can be verbose)\">\n <input type=\"checkbox\" id=\"hide-thinking\" />\n Hide thinking\n </label>\n <button type=\"button\" class=\"btn\" id=\"clear-filters\">Clear</button>\n </div>\n </section>\n\n <div class=\"controls\" style=\"margin-bottom: 14px;\">\n <button type=\"button\" class=\"btn\" id=\"copy-prompts\">Copy prompts</button>\n </div>\n\n <main>\n <section class=\"panel timeline-panel\" aria-label=\"Timeline\">\n <div class=\"stats\">\n <div class=\"big\"><span id=\"stats-showing\">0</span> <span class=\"muted\">showing</span> <span class=\"muted\">/</span> <span id=\"stats-total\">0</span> <span class=\"muted\">events</span></div>\n <div class=\"muted\" id=\"stats-range\">-</div>\n </div>\n <div id=\"timeline\" class=\"timeline\" role=\"list\" tabindex=\"0\" aria-label=\"Events list\"></div>\n </section>\n\n <aside class=\"panel detail\" aria-label=\"Details\">\n <div class=\"detail-head\">\n <div class=\"detail-title\" id=\"detail-title\">Select an event</div>\n <div class=\"detail-actions\">\n <button type=\"button\" class=\"btn\" id=\"copy-json\" disabled>Copy JSON</button>\n <button type=\"button\" class=\"btn\" id=\"copy-raw\" disabled>Copy raw line</button>\n <label class=\"check\" title=\"Truncate very long strings in the JSON preview\">\n <input type=\"checkbox\" id=\"truncate-strings\" checked />\n Truncate long strings\n </label>\n </div>\n </div>\n <div id=\"detail-images\" class=\"detail-images\" hidden aria-label=\"Event images\"></div>\n <div id=\"detail-pretty\" class=\"detail-pretty\" hidden aria-label=\"Formatted event view\"></div>\n <div class=\"json-toggle\">\n <span class=\"json-toggle-label\">Raw JSON</span>\n </div>\n <pre id=\"detail-json\" aria-label=\"Selected event JSON\" class=\"empty\">Load a file to get started.</pre>\n </aside>\n </main>\n </div>\n\n <div id=\"image-modal\" class=\"image-modal\" role=\"dialog\" aria-modal=\"true\" aria-hidden=\"true\">\n <div class=\"dialog\">\n <button type=\"button\" class=\"close\" id=\"image-modal-close\" aria-label=\"Close image\">Close</button>\n <img id=\"image-modal-img\" alt=\"\" />\n </div>\n </div>\n\n <script>\n const els = {\n dropzone: document.getElementById('dropzone'),\n fileInput: document.getElementById('file-input'),\n pasteInput: document.getElementById('paste-input'),\n renderPaste: document.getElementById('render-paste'),\n urlInput: document.getElementById('url-input'),\n fetchUrl: document.getElementById('fetch-url'),\n status: document.getElementById('status'),\n timeline: document.getElementById('timeline'),\n detailPanel: document.querySelector('aside.detail'),\n detailTitle: document.getElementById('detail-title'),\n detailImages: document.getElementById('detail-images'),\n detailPretty: document.getElementById('detail-pretty'),\n detailJson: document.getElementById('detail-json'),\n copyJson: document.getElementById('copy-json'),\n copyRaw: document.getElementById('copy-raw'),\n truncateStrings: document.getElementById('truncate-strings'),\n tzLocal: document.getElementById('tz-local'),\n tzUtc: document.getElementById('tz-utc'),\n search: document.getElementById('search'),\n typeFilter: document.getElementById('type-filter'),\n contentTypeFilter: document.getElementById('content-type-filter'),\n roleFilter: document.getElementById('role-filter'),\n hideThinking: document.getElementById('hide-thinking'),\n statsShowing: document.getElementById('stats-showing'),\n statsTotal: document.getElementById('stats-total'),\n statsRange: document.getElementById('stats-range'),\n clearFilters: document.getElementById('clear-filters'),\n imageModal: document.getElementById('image-modal'),\n imageModalImg: document.getElementById('image-modal-img'),\n imageModalClose: document.getElementById('image-modal-close'),\n copyPrompts: document.getElementById('copy-prompts'),\n };\n\n const state = {\n sourceLabel: null,\n tzMode: 'local',\n events: [],\n filtered: [],\n selectedId: null,\n selected: null,\n filters: {\n query: '',\n type: 'all',\n contentType: 'all',\n role: 'all',\n hideThinking: false,\n },\n };\n\n let lastActiveElementBeforeModal = null;\n let lastBodyOverflow = null;\n let suppressUrlStateSync = false;\n const wideLayoutMq = window.matchMedia('(min-width: 1060px)');\n const detailDock = document.createElement('div');\n detailDock.id = 'detail-dock';\n detailDock.style.display = 'none';\n els.detailPanel.parentElement.insertBefore(detailDock, els.detailPanel);\n\n function updateDetailPlacement() {\n const inline = !wideLayoutMq.matches;\n\n if (!inline) {\n if (els.detailPanel.previousSibling !== detailDock) detailDock.after(els.detailPanel);\n els.detailPanel.classList.remove('inline');\n return;\n }\n\n const selectedNode = els.timeline.querySelector('.event.selected');\n if (!selectedNode) {\n if (els.detailPanel.previousSibling !== detailDock) detailDock.after(els.detailPanel);\n els.detailPanel.classList.remove('inline');\n return;\n }\n\n if (selectedNode.nextSibling !== els.detailPanel) selectedNode.after(els.detailPanel);\n els.detailPanel.classList.add('inline');\n }\n\n function setStatus(message, kind = 'good') {\n if (!message) {\n els.status.className = 'status';\n els.status.textContent = '';\n return;\n }\n els.status.className = `status visible ${kind}`;\n els.status.textContent = message;\n }\n\n function pad2(number) {\n return String(number).padStart(2, '0');\n }\n\n function pad3(number) {\n return String(number).padStart(3, '0');\n }\n\n function getOffsetString(date) {\n const offsetMinutes = -date.getTimezoneOffset();\n const sign = offsetMinutes >= 0 ? '+' : '-';\n const abs = Math.abs(offsetMinutes);\n const hours = Math.floor(abs / 60);\n const minutes = abs % 60;\n return `${sign}${pad2(hours)}:${pad2(minutes)}`;\n }\n\n function formatTimestamp(date, tzMode) {\n if (!(date instanceof Date) || Number.isNaN(date.getTime())) return '-';\n if (tzMode === 'utc') {\n return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ` +\n `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}:${pad2(date.getUTCSeconds())}.` +\n `${pad3(date.getUTCMilliseconds())}Z`;\n }\n return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ` +\n `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}.` +\n `${pad3(date.getMilliseconds())} ${getOffsetString(date)}`;\n }\n\n function dayKey(date, tzMode) {\n if (!(date instanceof Date) || Number.isNaN(date.getTime())) return '-';\n if (tzMode === 'utc') {\n return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())}`;\n }\n return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;\n }\n\n function formatDelta(ms) {\n if (typeof ms !== 'number' || !Number.isFinite(ms)) return '';\n const abs = Math.abs(ms);\n const sign = ms < 0 ? '-' : '+';\n if (abs < 1000) return `${sign}${Math.round(abs)}ms`;\n if (abs < 60_000) return `${sign}${(abs / 1000).toFixed(abs < 10_000 ? 2 : 1)}s`;\n if (abs < 3_600_000) {\n const m = Math.floor(abs / 60_000);\n const s = Math.floor((abs % 60_000) / 1000);\n return `${sign}${m}m ${pad2(s)}s`;\n }\n if (abs < 86_400_000) {\n const h = Math.floor(abs / 3_600_000);\n const m = Math.floor((abs % 3_600_000) / 60_000);\n return `${sign}${h}h ${m}m`;\n }\n const d = Math.floor(abs / 86_400_000);\n const h = Math.floor((abs % 86_400_000) / 3_600_000);\n return `${sign}${d}d ${h}h`;\n }\n\n function safeJsonParse(text) {\n try {\n return { ok: true, value: JSON.parse(text) };\n } catch (error) {\n return { ok: false, error };\n }\n }\n\n function extractMessageText(message) {\n if (!message || typeof message !== 'object') return null;\n const content = message.content;\n if (typeof content === 'string') return content;\n if (!Array.isArray(content)) return null;\n const parts = [];\n for (const item of content) {\n if (!item || typeof item !== 'object') continue;\n if (typeof item.text === 'string') parts.push(item.text);\n else if (typeof item.thinking === 'string') parts.push(item.thinking);\n }\n const joined = parts.join('');\n return joined || null;\n }\n\n function getContentTypes(message) {\n if (!message || typeof message !== 'object') return [];\n const content = message.content;\n if (typeof content === 'string') return ['text'];\n if (!Array.isArray(content)) return [];\n const types = new Set();\n for (const item of content) {\n if (!item || typeof item !== 'object') continue;\n if (item.type) types.add(item.type);\n }\n return Array.from(types);\n }\n\n function firstLine(text) {\n if (typeof text !== 'string') return null;\n const trimmed = text.trim();\n if (!trimmed) return null;\n const idx = trimmed.indexOf('\\n');\n return idx === -1 ? trimmed : trimmed.slice(0, idx).trim();\n }\n\n function normalizeSummaryText(text, maxLen = 260) {\n if (typeof text !== 'string') return '';\n const singleLine = text.replace(/\\s+/g, ' ').trim();\n if (singleLine.length <= maxLen) return singleLine;\n return singleLine.slice(0, maxLen - 1) + '...';\n }\n\n function summarizeEvent(obj) {\n const kind = obj?.type || '';\n const message = obj?.message;\n\n if (kind === 'file-history-snapshot') {\n const msgId = obj?.messageId ? `msg=${obj.messageId.slice(0, 8)}...` : null;\n return normalizeSummaryText(msgId || 'file-history-snapshot');\n }\n\n if (kind === 'user' || kind === 'assistant') {\n const role = message?.role || kind;\n const content = message?.content;\n\n if (typeof content === 'string') {\n return normalizeSummaryText(`${role}: ${content}`);\n }\n\n if (Array.isArray(content) && content.length > 0) {\n for (const item of content) {\n if (item?.type === 'text' && item.text) {\n return normalizeSummaryText(`${role}: ${item.text}`);\n }\n if (item?.type === 'thinking' && item.thinking) {\n return normalizeSummaryText(`${role} (thinking): ${item.thinking}`);\n }\n if (item?.type === 'tool_use') {\n const name = item.name || 'tool';\n const input = item.input;\n if (name === 'Bash' && input?.command) {\n return normalizeSummaryText(`${name}: ${input.command}`);\n }\n if (name === 'Read' && input?.file_path) {\n return normalizeSummaryText(`${name}: ${input.file_path}`);\n }\n if (name === 'Write' && input?.file_path) {\n return normalizeSummaryText(`${name}: ${input.file_path}`);\n }\n if (name === 'Edit' && input?.file_path) {\n return normalizeSummaryText(`${name}: ${input.file_path}`);\n }\n if (name === 'Glob' && input?.pattern) {\n return normalizeSummaryText(`${name}: ${input.pattern}`);\n }\n if (name === 'Grep' && input?.pattern) {\n return normalizeSummaryText(`${name}: ${input.pattern}`);\n }\n if (name === 'Task' && input?.description) {\n return normalizeSummaryText(`${name}: ${input.description}`);\n }\n if (input && typeof input === 'object') {\n const keys = Object.keys(input).slice(0, 3);\n return normalizeSummaryText(`${name}(${keys.join(', ')}${Object.keys(input).length > 3 ? ', ...' : ''})`);\n }\n return normalizeSummaryText(`${name}()`);\n }\n if (item?.type === 'tool_result') {\n const toolUseId = item.tool_use_id ? item.tool_use_id.slice(0, 12) : 'result';\n const resultContent = item.content;\n if (typeof resultContent === 'string') {\n return normalizeSummaryText(`tool_result: ${firstLine(resultContent) || toolUseId}`);\n }\n if (Array.isArray(resultContent)) {\n for (const rc of resultContent) {\n if (rc?.type === 'text' && rc.text) {\n return normalizeSummaryText(`tool_result: ${firstLine(rc.text) || toolUseId}`);\n }\n if (rc?.type === 'image') {\n return normalizeSummaryText(`tool_result: [image]`);\n }\n }\n }\n return normalizeSummaryText(`tool_result: ${toolUseId}`);\n }\n if (item?.type === 'image') {\n return normalizeSummaryText(`${role}: [image]`);\n }\n }\n return normalizeSummaryText(`${role}: [${content.length} content items]`);\n }\n\n return kind;\n }\n\n return kind || 'event';\n }\n\n function buildSearchText(obj, summary) {\n const pieces = [];\n pieces.push(obj?.type || '');\n pieces.push(obj?.uuid || '');\n pieces.push(obj?.sessionId || '');\n pieces.push(obj?.cwd || '');\n pieces.push(obj?.gitBranch || '');\n pieces.push(obj?.slug || '');\n const message = obj?.message;\n if (message && typeof message === 'object') {\n pieces.push(message.role || '');\n const msg = extractMessageText(message);\n if (msg) pieces.push(msg);\n const content = message.content;\n if (Array.isArray(content)) {\n for (const item of content) {\n if (item?.type === 'tool_use') {\n pieces.push(item.name || '');\n if (item.input && typeof item.input === 'object') {\n pieces.push(JSON.stringify(item.input).slice(0, 500));\n }\n }\n if (item?.type === 'tool_result' && typeof item.content === 'string') {\n pieces.push(item.content.slice(0, 500));\n }\n }\n }\n }\n if (typeof summary === 'string') pieces.push(summary);\n return pieces.join('\\n').toLowerCase();\n }\n\n function stringifyJson(value, truncateLongStrings) {\n const maxLen = 8000;\n return JSON.stringify(value, (key, v) => {\n if (!truncateLongStrings) return v;\n if (typeof v === 'string' && v.length > maxLen) {\n return v.slice(0, maxLen) + `... [truncated ${v.length - maxLen} chars]`;\n }\n return v;\n }, 2);\n }\n\n function updateFilterOptions(events) {\n const typeSet = new Set();\n const contentTypeSet = new Set();\n const roleSet = new Set();\n for (const e of events) {\n if (e.kind) typeSet.add(e.kind);\n for (const ct of e.contentTypes) {\n contentTypeSet.add(ct);\n }\n if (!e.contentTypes.length) contentTypeSet.add('(none)');\n if (e.role) roleSet.add(e.role);\n }\n\n function setOptions(select, items, current) {\n select.innerHTML = '';\n const optAll = document.createElement('option');\n optAll.value = 'all';\n optAll.textContent = 'All';\n select.appendChild(optAll);\n for (const item of items) {\n const opt = document.createElement('option');\n opt.value = item;\n opt.textContent = item;\n select.appendChild(opt);\n }\n select.value = current;\n }\n\n setOptions(els.typeFilter, Array.from(typeSet).sort(), state.filters.type);\n setOptions(els.contentTypeFilter, Array.from(contentTypeSet).sort(), state.filters.contentType);\n setOptions(els.roleFilter, Array.from(roleSet).sort(), state.filters.role);\n\n if (events.length) {\n state.filters.type = els.typeFilter.value || 'all';\n state.filters.contentType = els.contentTypeFilter.value || 'all';\n state.filters.role = els.roleFilter.value || 'all';\n }\n }\n\n function readHashParams() {\n const raw = window.location.hash && window.location.hash.startsWith('#')\n ? window.location.hash.slice(1)\n : '';\n return new URLSearchParams(raw);\n }\n\n function writeHashParams(params) {\n const base = window.location.href.split('#')[0];\n const hash = params.toString();\n const next = base + (hash ? `#${hash}` : '');\n window.history.replaceState(null, '', next);\n }\n\n function syncUrlHashFromControls({ clearSel = false } = {}) {\n if (suppressUrlStateSync) return;\n const params = readHashParams();\n params.set('tz', state.tzMode);\n params.set('q', (els.search.value || '').trim());\n params.set('type', state.filters.type || 'all');\n params.set('ct', state.filters.contentType || 'all');\n params.set('role', state.filters.role || 'all');\n params.set('hide', state.filters.hideThinking ? '1' : '0');\n params.set('truncate', els.truncateStrings.checked ? '1' : '0');\n const selectedLine = state.selected?.line;\n if (typeof selectedLine === 'number' && Number.isFinite(selectedLine)) {\n params.set('sel', String(selectedLine));\n } else if (clearSel) {\n params.delete('sel');\n }\n writeHashParams(params);\n }\n\n function applySelectionFromHash() {\n if (!state.events.length) return;\n const params = readHashParams();\n const raw = params.get('sel');\n if (!raw) return;\n const line = Number(raw);\n if (!Number.isFinite(line)) return;\n const found = state.events.find(e => e.line === line);\n if (!found) return;\n if (state.selectedId === found.id) return;\n suppressUrlStateSync = true;\n try {\n selectEvent(found.id, { scroll: true });\n } finally {\n suppressUrlStateSync = false;\n }\n }\n\n function applyControlsFromHash() {\n const params = readHashParams();\n suppressUrlStateSync = true;\n try {\n const q = params.get('q');\n if (q !== null) {\n els.search.value = q;\n state.filters.query = q.trim().toLowerCase();\n }\n\n const type = params.get('type');\n if (type !== null) state.filters.type = type;\n const ct = params.get('ct');\n if (ct !== null) state.filters.contentType = ct;\n const role = params.get('role');\n if (role !== null) state.filters.role = role;\n\n const hide = params.get('hide');\n if (hide !== null) {\n els.hideThinking.checked = hide === '1' || hide === 'true';\n state.filters.hideThinking = els.hideThinking.checked;\n }\n\n const truncate = params.get('truncate');\n if (truncate !== null) {\n els.truncateStrings.checked = truncate === '1' || truncate === 'true';\n }\n\n const tz = params.get('tz');\n if (tz === 'utc' || tz === 'local') {\n state.tzMode = tz;\n els.tzLocal.classList.toggle('active', tz === 'local');\n els.tzUtc.classList.toggle('active', tz === 'utc');\n }\n } finally {\n suppressUrlStateSync = false;\n }\n }\n\n function applyFilters() {\n const query = state.filters.query;\n const type = state.filters.type;\n const contentType = state.filters.contentType;\n const role = state.filters.role;\n const hideThinking = state.filters.hideThinking;\n\n const out = [];\n for (const e of state.events) {\n if (hideThinking && e.contentTypes.includes('thinking') && e.contentTypes.length === 1) continue;\n if (type !== 'all' && e.kind !== type) continue;\n if (contentType !== 'all') {\n const ct = e.contentTypes.length ? e.contentTypes : ['(none)'];\n if (!ct.includes(contentType)) continue;\n }\n if (role !== 'all' && e.role !== role) continue;\n if (query && !e.search.includes(query)) continue;\n out.push(e);\n }\n\n state.filtered = out;\n els.statsShowing.textContent = String(out.length);\n els.statsTotal.textContent = String(state.events.length);\n\n const start = out.find(e => e.date instanceof Date && !Number.isNaN(e.date.getTime()))?.date;\n const end = [...out].reverse().find(e => e.date instanceof Date && !Number.isNaN(e.date.getTime()))?.date;\n if (start && end) {\n els.statsRange.textContent = `${formatTimestamp(start, state.tzMode)} -> ${formatTimestamp(end, state.tzMode)}`;\n } else {\n els.statsRange.textContent = '-';\n }\n\n renderTimeline();\n }\n\n function clearSelection({ syncHash = true } = {}) {\n state.selectedId = null;\n state.selected = null;\n els.detailTitle.textContent = 'Select an event';\n renderDetailImages(null);\n renderPrettyView(null);\n els.detailJson.textContent = state.events.length ? 'Select an event from the timeline.' : 'Load a file to get started.';\n els.detailJson.classList.add('empty');\n els.copyJson.disabled = true;\n els.copyRaw.disabled = true;\n for (const node of els.timeline.querySelectorAll('.event.selected')) {\n node.classList.remove('selected');\n }\n updateDetailPlacement();\n if (syncHash) syncUrlHashFromControls({ clearSel: true });\n }\n\n function renderTimeline() {\n els.timeline.innerHTML = '';\n\n if (!state.filtered.length) {\n const empty = document.createElement('div');\n empty.className = 'empty';\n empty.textContent = state.events.length ? 'No events match your filters.' : 'Load a file to see a timeline.';\n els.timeline.appendChild(empty);\n clearSelection();\n return;\n }\n\n const fragment = document.createDocumentFragment();\n let lastDay = null;\n let prevDate = null;\n\n for (const e of state.filtered) {\n const day = dayKey(e.date, state.tzMode);\n if (day !== lastDay) {\n lastDay = day;\n const sep = document.createElement('div');\n sep.className = 'day-sep';\n sep.textContent = day;\n fragment.appendChild(sep);\n }\n\n const row = document.createElement('div');\n row.className = 'event';\n row.setAttribute('role', 'listitem');\n row.dataset.eventId = String(e.id);\n\n const time = document.createElement('div');\n time.className = 'time';\n\n const ts = document.createElement('div');\n ts.className = 'ts';\n ts.textContent = formatTimestamp(e.date, state.tzMode);\n ts.dataset.kind = 'timestamp';\n\n const meta = document.createElement('div');\n meta.textContent = [\n `#${e.line}`,\n (prevDate && e.date) ? formatDelta(e.date.getTime() - prevDate.getTime()) : null,\n ].filter(Boolean).join(' . ');\n\n time.appendChild(ts);\n time.appendChild(meta);\n\n const body = document.createElement('div');\n\n const badges = document.createElement('div');\n badges.className = 'badges';\n const badgeKind = document.createElement('span');\n badgeKind.className = `badge k-${e.kind}`;\n badgeKind.textContent = e.kind;\n badges.appendChild(badgeKind);\n\n for (const ct of e.contentTypes.slice(0, 2)) {\n const badgeCt = document.createElement('span');\n badgeCt.className = `badge ct-${ct}`;\n badgeCt.textContent = ct;\n badges.appendChild(badgeCt);\n }\n if (e.contentTypes.length > 2) {\n const badgeMore = document.createElement('span');\n badgeMore.className = 'badge';\n badgeMore.textContent = `+${e.contentTypes.length - 2}`;\n badges.appendChild(badgeMore);\n }\n\n if (e.role) {\n const badgeRole = document.createElement('span');\n badgeRole.className = `badge role-${e.role}`;\n badgeRole.textContent = e.role;\n badges.appendChild(badgeRole);\n }\n\n const summary = document.createElement('div');\n summary.className = 'summary';\n summary.textContent = e.summary;\n\n body.appendChild(badges);\n body.appendChild(summary);\n\n row.appendChild(time);\n row.appendChild(body);\n\n row.addEventListener('click', () => selectEvent(e.id, { focusTimeline: true }));\n fragment.appendChild(row);\n\n if (e.date instanceof Date && !Number.isNaN(e.date.getTime())) prevDate = e.date;\n }\n\n els.timeline.appendChild(fragment);\n\n if (state.selectedId !== null) {\n const stillThere = state.filtered.some(e => e.id === state.selectedId);\n if (!stillThere) clearSelection();\n else selectEvent(state.selectedId, { scroll: false });\n } else {\n clearSelection({ syncHash: false });\n }\n }\n\n function selectEvent(id, { scroll = false, focusTimeline = false } = {}) {\n const e = state.filtered.find(item => item.id === id) || state.events.find(item => item.id === id);\n if (!e) return;\n state.selectedId = id;\n state.selected = e;\n\n if (focusTimeline) {\n els.timeline.focus({ preventScroll: true });\n }\n\n for (const node of els.timeline.querySelectorAll('.event.selected')) {\n node.classList.remove('selected');\n }\n const node = els.timeline.querySelector(`.event[data-event-id=\"${CSS.escape(String(id))}\"]`);\n if (node) {\n node.classList.add('selected');\n updateDetailPlacement();\n if (scroll) node.scrollIntoView({ block: 'center' });\n }\n\n els.detailJson.classList.remove('empty');\n const titleParts = [\n formatTimestamp(e.date, state.tzMode),\n `#${e.line}`,\n e.kind,\n e.role ? `(${e.role})` : null,\n ].filter(Boolean);\n els.detailTitle.textContent = titleParts.join(' ');\n\n const shouldTruncate = els.truncateStrings.checked;\n renderDetailImages(e.obj);\n renderPrettyView(e.obj);\n els.detailJson.textContent = stringifyJson(e.obj, shouldTruncate) || '';\n els.copyJson.disabled = false;\n els.copyRaw.disabled = false;\n updateDetailPlacement();\n syncUrlHashFromControls();\n }\n\n function extractImageUrls(obj) {\n const urls = [];\n const message = obj?.message;\n if (!message || typeof message !== 'object') return urls;\n const content = message.content;\n if (!Array.isArray(content)) return urls;\n\n for (const item of content) {\n if (!item || typeof item !== 'object') continue;\n\n // Direct image in content\n if (item.type === 'image' && item.source?.type === 'base64' && item.source?.data) {\n const mediaType = item.source.media_type || 'image/png';\n urls.push(`data:${mediaType};base64,${item.source.data}`);\n }\n\n // Image in tool_result content\n if (item.type === 'tool_result' && Array.isArray(item.content)) {\n for (const rc of item.content) {\n if (rc?.type === 'image' && rc.source?.type === 'base64' && rc.source?.data) {\n const mediaType = rc.source.media_type || 'image/png';\n urls.push(`data:${mediaType};base64,${rc.source.data}`);\n }\n }\n }\n }\n\n return urls;\n }\n\n function escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n\n // Initialize markdown-it with GFM-like settings\n const md = window.markdownit({\n html: false, // Disable raw HTML input\n linkify: true, // Auto-convert URLs to links\n typographer: true, // Smart quotes, dashes\n breaks: true // Convert \\n to <br>\n });\n\n function renderMarkdown(text) {\n if (typeof text !== 'string') return '';\n\n // Render markdown to HTML\n const html = md.render(text);\n\n // Sanitize with DOMPurify to prevent XSS\n return DOMPurify.sanitize(html);\n }\n\n function renderPrettyView(obj) {\n els.detailPretty.innerHTML = '';\n\n if (!obj || typeof obj !== 'object') {\n els.detailPretty.hidden = true;\n return;\n }\n\n const message = obj.message;\n if (!message || typeof message !== 'object') {\n els.detailPretty.hidden = true;\n return;\n }\n\n const content = message.content;\n const fragment = document.createDocumentFragment();\n let hasContent = false;\n\n // Handle string content (simple text message)\n if (typeof content === 'string') {\n const section = document.createElement('div');\n section.className = 'pretty-section';\n const label = document.createElement('div');\n label.className = 'pretty-label';\n label.textContent = message.role || 'Message';\n const body = document.createElement('div');\n body.className = 'pretty-content';\n body.innerHTML = renderMarkdown(content);\n section.appendChild(label);\n section.appendChild(body);\n fragment.appendChild(section);\n hasContent = true;\n }\n\n // Handle array content\n if (Array.isArray(content)) {\n for (const item of content) {\n if (!item || typeof item !== 'object') continue;\n\n // Text content\n if (item.type === 'text' && item.text) {\n const section = document.createElement('div');\n section.className = 'pretty-section';\n const label = document.createElement('div');\n label.className = 'pretty-label';\n label.textContent = message.role === 'assistant' ? 'Response' : 'Message';\n const body = document.createElement('div');\n body.className = 'pretty-content';\n body.innerHTML = renderMarkdown(item.text);\n section.appendChild(label);\n section.appendChild(body);\n fragment.appendChild(section);\n hasContent = true;\n }\n\n // Thinking content\n if (item.type === 'thinking' && item.thinking) {\n const section = document.createElement('div');\n section.className = 'pretty-section';\n const label = document.createElement('div');\n label.className = 'pretty-label';\n label.textContent = 'Thinking';\n const body = document.createElement('div');\n body.className = 'thinking-block pretty-content';\n body.innerHTML = renderMarkdown(item.thinking);\n section.appendChild(label);\n section.appendChild(body);\n fragment.appendChild(section);\n hasContent = true;\n }\n\n // Tool use\n if (item.type === 'tool_use') {\n const section = document.createElement('div');\n section.className = 'pretty-section';\n const label = document.createElement('div');\n label.className = 'pretty-label';\n label.textContent = 'Tool Call';\n\n const toolName = item.name || 'Unknown Tool';\n const input = item.input || {};\n\n // Special handling for Bash command\n if (toolName === 'Bash' && input.command) {\n const block = document.createElement('div');\n block.className = 'command-block';\n\n const header = document.createElement('div');\n header.className = 'command-header';\n const nameSpan = document.createElement('span');\n nameSpan.className = 'tool-name';\n nameSpan.textContent = toolName;\n header.appendChild(nameSpan);\n if (input.description) {\n const descSpan = document.createElement('span');\n descSpan.className = 'tool-desc';\n descSpan.textContent = input.description;\n header.appendChild(descSpan);\n }\n\n const cmdInput = document.createElement('div');\n cmdInput.className = 'command-input';\n cmdInput.textContent = input.command;\n\n block.appendChild(header);\n block.appendChild(cmdInput);\n section.appendChild(label);\n section.appendChild(block);\n }\n // Special handling for file operations\n else if (['Read', 'Write', 'Edit', 'Glob', 'Grep'].includes(toolName)) {\n const block = document.createElement('div');\n block.className = 'command-block';\n\n const header = document.createElement('div');\n header.className = 'command-header';\n const nameSpan = document.createElement('span');\n nameSpan.className = 'tool-name';\n nameSpan.textContent = toolName;\n header.appendChild(nameSpan);\n\n block.appendChild(header);\n\n const params = document.createElement('div');\n params.className = 'tool-params';\n params.style.padding = '10px 12px';\n\n for (const [key, value] of Object.entries(input)) {\n const param = document.createElement('div');\n param.className = 'tool-param';\n const paramName = document.createElement('span');\n paramName.className = 'tool-param-name';\n paramName.textContent = key;\n const paramValue = document.createElement('span');\n paramValue.className = 'tool-param-value';\n if (typeof value === 'string') {\n paramValue.textContent = value.length > 500 ? value.slice(0, 500) + '...' : value;\n } else {\n paramValue.textContent = JSON.stringify(value, null, 2);\n }\n param.appendChild(paramName);\n param.appendChild(paramValue);\n params.appendChild(param);\n }\n\n block.appendChild(params);\n section.appendChild(label);\n section.appendChild(block);\n }\n // Generic tool display\n else {\n const block = document.createElement('div');\n block.className = 'command-block';\n\n const header = document.createElement('div');\n header.className = 'command-header';\n const nameSpan = document.createElement('span');\n nameSpan.className = 'tool-name';\n nameSpan.textContent = toolName;\n header.appendChild(nameSpan);\n\n block.appendChild(header);\n\n if (Object.keys(input).length > 0) {\n const params = document.createElement('div');\n params.className = 'tool-params';\n params.style.padding = '10px 12px';\n\n for (const [key, value] of Object.entries(input)) {\n const param = document.createElement('div');\n param.className = 'tool-param';\n const paramName = document.createElement('span');\n paramName.className = 'tool-param-name';\n paramName.textContent = key;\n const paramValue = document.createElement('span');\n paramValue.className = 'tool-param-value';\n if (typeof value === 'string') {\n paramValue.textContent = value.length > 1000 ? value.slice(0, 1000) + '...' : value;\n } else {\n paramValue.textContent = JSON.stringify(value, null, 2);\n }\n param.appendChild(paramName);\n param.appendChild(paramValue);\n params.appendChild(param);\n }\n\n block.appendChild(params);\n }\n\n section.appendChild(label);\n section.appendChild(block);\n }\n\n fragment.appendChild(section);\n hasContent = true;\n }\n\n // Tool result\n if (item.type === 'tool_result') {\n const section = document.createElement('div');\n section.className = 'pretty-section';\n const label = document.createElement('div');\n label.className = 'pretty-label';\n label.textContent = item.is_error ? 'Tool Error' : 'Tool Result';\n\n const resultContent = item.content;\n let resultText = '';\n\n if (typeof resultContent === 'string') {\n resultText = resultContent;\n } else if (Array.isArray(resultContent)) {\n for (const rc of resultContent) {\n if (rc?.type === 'text' && rc.text) {\n resultText += rc.text;\n }\n }\n }\n\n if (resultText) {\n const block = document.createElement('div');\n block.className = 'pretty-content' + (item.is_error ? ' result-error' : '');\n const displayText = resultText.length > 5000 ? resultText.slice(0, 5000) + '\\n... [truncated]' : resultText;\n block.innerHTML = renderMarkdown(displayText);\n section.appendChild(label);\n section.appendChild(block);\n fragment.appendChild(section);\n hasContent = true;\n }\n }\n }\n }\n\n if (hasContent) {\n els.detailPretty.appendChild(fragment);\n els.detailPretty.hidden = false;\n } else {\n els.detailPretty.hidden = true;\n }\n }\n\n function openImageModal(src, altText = '') {\n if (!src) return;\n lastActiveElementBeforeModal = document.activeElement instanceof HTMLElement ? document.activeElement : null;\n lastBodyOverflow = document.body.style.overflow;\n document.body.style.overflow = 'hidden';\n\n els.imageModalImg.src = src;\n els.imageModalImg.alt = altText;\n els.imageModal.classList.add('open');\n els.imageModal.setAttribute('aria-hidden', 'false');\n els.imageModalClose.focus();\n }\n\n function closeImageModal() {\n if (!els.imageModal.classList.contains('open')) return;\n els.imageModal.classList.remove('open');\n els.imageModal.setAttribute('aria-hidden', 'true');\n els.imageModalImg.removeAttribute('src');\n els.imageModalImg.alt = '';\n document.body.style.overflow = lastBodyOverflow ?? '';\n\n if (lastActiveElementBeforeModal) {\n lastActiveElementBeforeModal.focus();\n }\n lastActiveElementBeforeModal = null;\n lastBodyOverflow = null;\n }\n\n function renderDetailImages(obj) {\n const urls = extractImageUrls(obj);\n els.detailImages.innerHTML = '';\n if (!urls.length) {\n els.detailImages.hidden = true;\n return;\n }\n const fragment = document.createDocumentFragment();\n urls.forEach((src, idx) => {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'thumb';\n btn.title = `Open image ${idx + 1}`;\n const img = document.createElement('img');\n img.loading = 'lazy';\n img.src = src;\n img.alt = `Image ${idx + 1}`;\n btn.appendChild(img);\n btn.addEventListener('click', () => openImageModal(src, `Image ${idx + 1}`));\n fragment.appendChild(btn);\n });\n els.detailImages.appendChild(fragment);\n els.detailImages.hidden = false;\n }\n\n function isEditableElement(el) {\n if (!(el instanceof HTMLElement)) return false;\n if (el.isContentEditable) return true;\n const tag = el.tagName;\n return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';\n }\n\n function moveSelection(delta) {\n if (!state.filtered.length) return;\n const idx = state.filtered.findIndex(e => e.id === state.selectedId);\n if (idx === -1) {\n selectEvent(state.filtered[0].id, { scroll: true });\n return;\n }\n const nextIdx = Math.max(0, Math.min(state.filtered.length - 1, idx + delta));\n if (nextIdx === idx) return;\n selectEvent(state.filtered[nextIdx].id, { scroll: true });\n }\n\n async function copyText(text) {\n if (typeof text !== 'string') return;\n try {\n await navigator.clipboard.writeText(text);\n setStatus('Copied to clipboard.', 'good');\n } catch (error) {\n const ta = document.createElement('textarea');\n ta.value = text;\n ta.style.position = 'fixed';\n ta.style.left = '-9999px';\n document.body.appendChild(ta);\n ta.select();\n document.execCommand('copy');\n document.body.removeChild(ta);\n setStatus('Copied to clipboard.', 'good');\n }\n }\n\n function extractUserPrompts() {\n const prompts = [];\n for (const e of state.events) {\n // Filter: type=user, content-type includes text, role=user\n if (e.kind !== 'user') continue;\n if (e.role !== 'user') continue;\n if (!e.contentTypes.includes('text')) continue;\n\n const message = e.obj?.message;\n if (!message) continue;\n\n const content = message.content;\n if (typeof content === 'string') {\n prompts.push(content);\n } else if (Array.isArray(content)) {\n for (const item of content) {\n if (item?.type === 'text' && item.text) {\n prompts.push(item.text);\n }\n }\n }\n }\n return prompts.join('\\n\\n');\n }\n\n async function copyUserPrompts() {\n const text = extractUserPrompts();\n if (!text) {\n setStatus('No user prompts found.', 'warn');\n return;\n }\n await copyText(text);\n const originalText = els.copyPrompts.textContent;\n els.copyPrompts.textContent = 'Copied!';\n setTimeout(() => {\n els.copyPrompts.textContent = originalText;\n }, 2000);\n }\n\n function setTimezoneMode(mode) {\n state.tzMode = mode;\n els.tzLocal.classList.toggle('active', mode === 'local');\n els.tzUtc.classList.toggle('active', mode === 'utc');\n applyFilters();\n if (state.selectedId !== null) selectEvent(state.selectedId);\n syncUrlHashFromControls();\n }\n\n els.imageModalClose.addEventListener('click', closeImageModal);\n els.imageModal.addEventListener('click', (event) => {\n if (event.target === els.imageModal) closeImageModal();\n });\n document.addEventListener('keydown', (event) => {\n if (event.key === 'Escape') closeImageModal();\n });\n\n document.addEventListener('keydown', (event) => {\n if (els.imageModal.classList.contains('open')) return;\n if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;\n if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;\n if (isEditableElement(document.activeElement)) return;\n if (document.activeElement !== els.timeline && !els.timeline.contains(document.activeElement)) return;\n if (state.selectedId === null) return;\n event.preventDefault();\n moveSelection(event.key === 'ArrowUp' ? -1 : 1);\n });\n\n if (typeof wideLayoutMq.addEventListener === 'function') {\n wideLayoutMq.addEventListener('change', updateDetailPlacement);\n } else if (typeof wideLayoutMq.addListener === 'function') {\n wideLayoutMq.addListener(updateDetailPlacement);\n }\n\n function setTab(tabName) {\n for (const tab of document.querySelectorAll('.tab')) {\n const active = tab.dataset.tab === tabName;\n tab.classList.toggle('active', active);\n tab.setAttribute('aria-selected', active ? 'true' : 'false');\n }\n for (const panel of document.querySelectorAll('[data-panel]')) {\n panel.hidden = panel.dataset.panel !== tabName;\n }\n }\n\n function setShareUrlParam(url) {\n const current = new URL(window.location.href);\n if (url) current.searchParams.set('url', url);\n else current.searchParams.delete('url');\n window.history.replaceState(null, '', current);\n }\n\n async function fetchAndRenderUrl(url, { syncUrl = true } = {}) {\n const trimmed = (url || '').trim();\n if (!trimmed) {\n setStatus('Enter a URL first.', 'error');\n return;\n }\n if (syncUrl) setShareUrlParam(trimmed);\n setStatus(`Fetching ${trimmed}...`, 'warn');\n try {\n const resp = await fetch(trimmed, { mode: 'cors' });\n if (!resp.ok) throw new Error(`HTTP ${resp.status}`);\n const text = await resp.text();\n await loadText(text, trimmed);\n } catch (error) {\n setStatus(`Failed to fetch URL: ${String(error)}`, 'error');\n }\n }\n\n async function loadText(text, sourceLabel) {\n const started = performance.now();\n state.sourceLabel = sourceLabel || null;\n setStatus(`Parsing ${sourceLabel || 'content'}...`, 'warn');\n clearSelection({ syncHash: false });\n\n const lines = text.split(/\\r?\\n/);\n const events = [];\n const errors = [];\n let idCounter = 0;\n\n for (let i = 0; i < lines.length; i++) {\n const line = lines[i];\n if (!line.trim()) continue;\n const parsed = safeJsonParse(line);\n if (!parsed.ok) {\n errors.push({ line: i + 1, error: parsed.error });\n continue;\n }\n const obj = parsed.value;\n const ts = typeof obj.timestamp === 'string' ? new Date(obj.timestamp) : null;\n const kind = obj.type || '(unknown)';\n const message = obj.message && typeof obj.message === 'object' ? obj.message : null;\n const contentTypes = message ? getContentTypes(message) : [];\n const role = message?.role ?? null;\n\n const summary = summarizeEvent(obj);\n events.push({\n id: ++idCounter,\n line: i + 1,\n raw: line,\n obj,\n date: ts,\n kind,\n contentTypes,\n role,\n summary,\n search: buildSearchText(obj, summary),\n });\n }\n\n state.events = events;\n updateFilterOptions(events);\n applyFilters();\n applySelectionFromHash();\n syncUrlHashFromControls();\n\n const durationMs = Math.round(performance.now() - started);\n if (errors.length) {\n const first = errors[0];\n setStatus(\n `Loaded ${events.length} events from ${sourceLabel || 'content'} in ${durationMs}ms.\\n` +\n `Could not parse ${errors.length} line(s). First error at line ${first.line}: ${String(first.error)}`,\n 'warn'\n );\n } else {\n setStatus(`Loaded ${events.length} events from ${sourceLabel || 'content'} in ${durationMs}ms.`, 'good');\n }\n }\n\n async function handleFile(file) {\n if (!file) return;\n setShareUrlParam(null);\n try {\n const text = await file.text();\n await loadText(text, file.name);\n } catch (error) {\n setStatus(`Failed to read file: ${String(error)}`, 'error');\n }\n }\n\n els.dropzone.addEventListener('click', () => els.fileInput.click());\n els.dropzone.addEventListener('keydown', (e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n els.fileInput.click();\n }\n });\n els.fileInput.addEventListener('change', () => handleFile(els.fileInput.files?.[0]));\n\n els.dropzone.addEventListener('dragover', (e) => {\n e.preventDefault();\n els.dropzone.classList.add('dragover');\n });\n els.dropzone.addEventListener('dragleave', () => els.dropzone.classList.remove('dragover'));\n els.dropzone.addEventListener('drop', (e) => {\n e.preventDefault();\n els.dropzone.classList.remove('dragover');\n const file = e.dataTransfer?.files?.[0];\n if (file) handleFile(file);\n });\n\n els.renderPaste.addEventListener('click', async () => {\n const text = els.pasteInput.value || '';\n if (!text.trim()) {\n setStatus('Paste some JSONL content first.', 'error');\n return;\n }\n setShareUrlParam(null);\n await loadText(text, 'pasted content');\n });\n\n els.fetchUrl.addEventListener('click', async () => {\n await fetchAndRenderUrl(els.urlInput.value, { syncUrl: true });\n });\n\n for (const tab of document.querySelectorAll('.tab')) {\n tab.addEventListener('click', () => setTab(tab.dataset.tab));\n }\n\n els.tzLocal.addEventListener('click', () => setTimezoneMode('local'));\n els.tzUtc.addEventListener('click', () => setTimezoneMode('utc'));\n\n els.search.addEventListener('input', () => {\n state.filters.query = els.search.value.trim().toLowerCase();\n applyFilters();\n syncUrlHashFromControls();\n });\n els.typeFilter.addEventListener('change', () => {\n state.filters.type = els.typeFilter.value;\n applyFilters();\n syncUrlHashFromControls();\n });\n els.contentTypeFilter.addEventListener('change', () => {\n state.filters.contentType = els.contentTypeFilter.value;\n applyFilters();\n syncUrlHashFromControls();\n });\n els.roleFilter.addEventListener('change', () => {\n state.filters.role = els.roleFilter.value;\n applyFilters();\n syncUrlHashFromControls();\n });\n els.hideThinking.addEventListener('change', () => {\n state.filters.hideThinking = els.hideThinking.checked;\n applyFilters();\n syncUrlHashFromControls();\n });\n els.truncateStrings.addEventListener('change', () => {\n if (state.selectedId !== null) selectEvent(state.selectedId);\n syncUrlHashFromControls();\n });\n\n els.clearFilters.addEventListener('click', () => {\n els.search.value = '';\n state.filters.query = '';\n state.filters.type = 'all';\n state.filters.contentType = 'all';\n state.filters.role = 'all';\n state.filters.hideThinking = false;\n els.hideThinking.checked = false;\n updateFilterOptions(state.events);\n applyFilters();\n syncUrlHashFromControls();\n });\n\n els.copyJson.addEventListener('click', () => {\n if (!state.selected) return;\n copyText(stringifyJson(state.selected.obj, false));\n });\n els.copyRaw.addEventListener('click', () => {\n if (!state.selected) return;\n copyText(state.selected.raw);\n });\n els.copyPrompts.addEventListener('click', copyUserPrompts);\n\n async function initFromQuery() {\n const url = new URLSearchParams(window.location.search).get('url');\n if (!url) return;\n setTab('url');\n els.urlInput.value = url;\n await fetchAndRenderUrl(url, { syncUrl: false });\n }\n\n // Initialize select options for the empty state.\n applyControlsFromHash();\n updateFilterOptions([]);\n syncUrlHashFromControls();\n window.addEventListener('hashchange', () => {\n applyControlsFromHash();\n updateFilterOptions(state.events);\n applyFilters();\n applySelectionFromHash();\n });\n void initFromQuery();\n </script>\n</body>\n</html>\n","structuredPatch":[{"oldStart":999,"oldLines":7,"newStart":999,"newLines":7,"lines":[" </section>"," "," <div class=\"controls\" style=\"margin-bottom: 14px;\">","- <button type=\"button\" class=\"btn\" id=\"copy-prompts\">Copy prompts</button>","+ <button type=\"button\" class=\"btn\" id=\"show-prompts\" disabled>Show prompts</button>"," </div>"," "," <main>"]}],"userModified":false,"replaceAll":false}}
{"parentUuid":"f7cc8d04-5c8c-4bce-a3b6-ff73f2429fc8","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","slug":"glittery-skipping-rivest","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01VBjNm7wn4DpWSaxzUdXro7","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Good. Now I need to add the prompts modal HTML element. Let me find where the image modal is and add the prompts modal nearby.","signature":"EqcCCkYIChgCKkDYARl93RIhMFgNY3nj+B2kOiFpJjYw72yAJdXHokochjITteg7eYq1JaR3f/BwdMKat0qaCGEdn3PvEcjM4oZoEgwAM3SLhDIax2abslkaDIbPMAlMGVGSPL3F9iIw6lavaLR4k6YDWZTtBXn9/kzlKIzxFSA15RM7S+8YoqNJTKCF7Z/TiYZi1uloaiSJKo4B3QiEd6HbXFCfvh8tNwbj7u5J3ESA53DP3hRjhJR1HQ8UBxFuCQ5vWruQt5udRo1CKYM9iPu6uBkQyDFNQRPKYBdZIAbP2ZFtduMDRPqfGgJNyAjBcGlKABWFA2187h5TgzS5erX03Bi33jk9T9HgFR22zubFMR+EYOh9zlerD28MMo+FZQE7y/IoiaRacBgB"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":11,"cache_creation_input_tokens":474,"cache_read_input_tokens":86693,"cache_creation":{"ephemeral_5m_input_tokens":474,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWDdb5WSYMXkPvHZyGQip","type":"assistant","uuid":"365ad43f-f01f-4fcd-a3e2-79a11f18f27e","timestamp":"2025-12-18T05:52:14.553Z"}
{"parentUuid":"365ad43f-f01f-4fcd-a3e2-79a11f18f27e","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","slug":"glittery-skipping-rivest","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01VBjNm7wn4DpWSaxzUdXro7","type":"message","role":"assistant","content":[{"type":"text","text":"Now I'll add the prompts modal HTML element after the image modal:"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":11,"cache_creation_input_tokens":474,"cache_read_input_tokens":86693,"cache_creation":{"ephemeral_5m_input_tokens":474,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWDdb5WSYMXkPvHZyGQip","type":"assistant","uuid":"9df073b9-194d-4f1c-b905-d8c830b28ca0","timestamp":"2025-12-18T05:52:14.757Z"}
{"parentUuid":"9df073b9-194d-4f1c-b905-d8c830b28ca0","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","slug":"glittery-skipping-rivest","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01VBjNm7wn4DpWSaxzUdXro7","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Kq7u6tVDhm1yjGFTk1UDGX","name":"Edit","input":{"file_path":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","old_string":" <div id=\"image-modal\" class=\"image-modal\" role=\"dialog\" aria-modal=\"true\" aria-hidden=\"true\">\n <div class=\"dialog\">\n <button type=\"button\" class=\"close\" id=\"image-modal-close\" aria-label=\"Close image\">Close</button>\n <img id=\"image-modal-img\" alt=\"\" />\n </div>\n </div>\n\n <script>","new_string":" <div id=\"image-modal\" class=\"image-modal\" role=\"dialog\" aria-modal=\"true\" aria-hidden=\"true\">\n <div class=\"dialog\">\n <button type=\"button\" class=\"close\" id=\"image-modal-close\" aria-label=\"Close image\">Close</button>\n <img id=\"image-modal-img\" alt=\"\" />\n </div>\n </div>\n\n <div id=\"prompts-modal\" class=\"prompts-modal\" role=\"dialog\" aria-modal=\"true\" aria-hidden=\"true\" aria-label=\"User prompts\">\n <div class=\"dialog\">\n <div class=\"prompts-head\">\n <div class=\"prompts-title\">User prompts</div>\n <button type=\"button\" class=\"btn\" id=\"prompts-close\" aria-label=\"Close prompts\">Close</button>\n </div>\n <textarea id=\"prompts-text\" readonly spellcheck=\"false\"></textarea>\n <div class=\"prompts-actions\">\n <button type=\"button\" class=\"btn primary\" id=\"prompts-copy\">Copy</button>\n </div>\n </div>\n </div>\n\n <script>"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":11,"cache_creation_input_tokens":474,"cache_read_input_tokens":86693,"cache_creation":{"ephemeral_5m_input_tokens":474,"ephemeral_1h_input_tokens":0},"output_tokens":544,"service_tier":"standard"}},"requestId":"req_011CWDdb5WSYMXkPvHZyGQip","type":"assistant","uuid":"78a5fdaa-266e-4dd4-aa27-3f49de44d2f8","timestamp":"2025-12-18T05:52:17.063Z"}
{"parentUuid":"78a5fdaa-266e-4dd4-aa27-3f49de44d2f8","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","slug":"glittery-skipping-rivest","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Kq7u6tVDhm1yjGFTk1UDGX","type":"tool_result","content":"The file /Users/simon/Dropbox/dev/tools/claude-code-timeline.html has been updated. Here's the result of running `cat -n` on a snippet of the edited file:\n 1032→ </aside>\n 1033→ </main>\n 1034→ </div>\n 1035→\n 1036→ <div id=\"image-modal\" class=\"image-modal\" role=\"dialog\" aria-modal=\"true\" aria-hidden=\"true\">\n 1037→ <div class=\"dialog\">\n 1038→ <button type=\"button\" class=\"close\" id=\"image-modal-close\" aria-label=\"Close image\">Close</button>\n 1039→ <img id=\"image-modal-img\" alt=\"\" />\n 1040→ </div>\n 1041→ </div>\n 1042→\n 1043→ <div id=\"prompts-modal\" class=\"prompts-modal\" role=\"dialog\" aria-modal=\"true\" aria-hidden=\"true\" aria-label=\"User prompts\">\n 1044→ <div class=\"dialog\">\n 1045→ <div class=\"prompts-head\">\n 1046→ <div class=\"prompts-title\">User prompts</div>\n 1047→ <button type=\"button\" class=\"btn\" id=\"prompts-close\" aria-label=\"Close prompts\">Close</button>\n 1048→ </div>\n 1049→ <textarea id=\"prompts-text\" readonly spellcheck=\"false\"></textarea>\n 1050→ <div class=\"prompts-actions\">\n 1051→ <button type=\"button\" class=\"btn primary\" id=\"prompts-copy\">Copy</button>\n 1052→ </div>\n 1053→ </div>\n 1054→ </div>\n 1055→\n 1056→ <script>\n 1057→ const els = {\n 1058→ dropzone: document.getElementById('dropzone'),\n 1059→ fileInput: document.getElementById('file-input'),\n 1060→ pasteInput: document.getElementById('paste-input'),"}]},"uuid":"c0aabd11-5c89-40d2-94ae-477a62625669","timestamp":"2025-12-18T05:52:17.104Z","toolUseResult":{"filePath":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","oldString":" <div id=\"image-modal\" class=\"image-modal\" role=\"dialog\" aria-modal=\"true\" aria-hidden=\"true\">\n <div class=\"dialog\">\n <button type=\"button\" class=\"close\" id=\"image-modal-close\" aria-label=\"Close image\">Close</button>\n <img id=\"image-modal-img\" alt=\"\" />\n </div>\n </div>\n\n <script>","newString":" <div id=\"image-modal\" class=\"image-modal\" role=\"dialog\" aria-modal=\"true\" aria-hidden=\"true\">\n <div class=\"dialog\">\n <button type=\"button\" class=\"close\" id=\"image-modal-close\" aria-label=\"Close image\">Close</button>\n <img id=\"image-modal-img\" alt=\"\" />\n </div>\n </div>\n\n <div id=\"prompts-modal\" class=\"prompts-modal\" role=\"dialog\" aria-modal=\"true\" aria-hidden=\"true\" aria-label=\"User prompts\">\n <div class=\"dialog\">\n <div class=\"prompts-head\">\n <div class=\"prompts-title\">User prompts</div>\n <button type=\"button\" class=\"btn\" id=\"prompts-close\" aria-label=\"Close prompts\">Close</button>\n </div>\n <textarea id=\"prompts-text\" readonly spellcheck=\"false\"></textarea>\n <div class=\"prompts-actions\">\n <button type=\"button\" class=\"btn primary\" id=\"prompts-copy\">Copy</button>\n </div>\n </div>\n </div>\n\n <script>","originalFile":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>Claude Code Timeline Viewer</title>\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js\" integrity=\"sha384-osZDKVu4ipZP703HmPOhWdyBajcFyjX2Psjk//TG1Rc0AdwEtuToaylrmcK3LdAl\" crossorigin=\"anonymous\"></script>\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/markdown-it/4.4.0/markdown-it.min.js\" integrity=\"sha384-cxGp+QDeMxACP73aA0Lj0Sc/h4z1X73+umcvCyI0Vbyh3OYxgrPI+0mxG9slsMTN\" crossorigin=\"anonymous\"></script>\n <style>\n :root {\n color-scheme: light dark;\n --bg: #0b0c0f;\n --panel: #12141a;\n --panel-2: #0f1116;\n --text: #e8eaf0;\n --muted: #a6adbf;\n --line: rgba(255, 255, 255, 0.10);\n --line-2: rgba(255, 255, 255, 0.16);\n --accent: #6aa9ff;\n --accent-2: #8a5cff;\n --good: #46d39a;\n --warn: #ffcc66;\n --bad: #ff6b6b;\n --shadow: 0 18px 50px rgba(0, 0, 0, 0.35);\n --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\",\n \"Courier New\", monospace;\n --sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial,\n \"Apple Color Emoji\", \"Segoe UI Emoji\";\n }\n\n @media (prefers-color-scheme: light) {\n :root {\n --bg: #f7f8fc;\n --panel: #ffffff;\n --panel-2: #ffffff;\n --text: #10121a;\n --muted: #5b647a;\n --line: rgba(16, 18, 26, 0.12);\n --line-2: rgba(16, 18, 26, 0.18);\n --shadow: 0 18px 50px rgba(16, 18, 26, 0.10);\n }\n }\n\n * { box-sizing: border-box; }\n html, body { height: 100%; }\n body {\n margin: 0;\n font-family: var(--sans);\n background: radial-gradient(1200px 600px at 10% -10%, rgba(106, 169, 255, 0.18), transparent 45%),\n radial-gradient(1200px 600px at 90% -20%, rgba(138, 92, 255, 0.16), transparent 50%),\n var(--bg);\n color: var(--text);\n }\n\n .wrap {\n max-width: none;\n margin: 0;\n padding: 22px 16px 42px;\n }\n\n header {\n display: grid;\n gap: 10px;\n margin-bottom: 14px;\n }\n\n h1 {\n margin: 0;\n font-size: clamp(22px, 3vw, 32px);\n letter-spacing: 0.2px;\n }\n\n .sub {\n margin: 6px 0 0;\n color: var(--muted);\n line-height: 1.35;\n max-width: 70ch;\n }\n\n .controls {\n display: flex;\n flex-wrap: wrap;\n gap: 10px;\n align-items: center;\n justify-content: flex-start;\n }\n\n .controls .field {\n display: grid;\n gap: 4px;\n min-width: 180px;\n }\n\n .label {\n font-size: 12px;\n color: var(--muted);\n }\n\n input[type=\"search\"], input[type=\"url\"], select, textarea {\n font: inherit;\n color: inherit;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n border-radius: 10px;\n padding: 10px 11px;\n outline: none;\n }\n\n input[type=\"search\"]:focus, input[type=\"url\"]:focus, select:focus, textarea:focus {\n border-color: color-mix(in srgb, var(--accent) 60%, var(--line));\n box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);\n }\n\n .segmented {\n display: inline-flex;\n border: 1px solid var(--line);\n border-radius: 999px;\n overflow: hidden;\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n }\n\n .segmented button {\n border: none;\n padding: 9px 12px;\n font: inherit;\n background: transparent;\n color: var(--muted);\n cursor: pointer;\n min-width: 74px;\n }\n\n .segmented button.active {\n background: color-mix(in srgb, var(--accent) 16%, transparent);\n color: var(--text);\n }\n\n .segmented button:focus-visible {\n outline: 2px solid color-mix(in srgb, var(--accent) 70%, transparent);\n outline-offset: -2px;\n }\n\n .btn {\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n color: var(--text);\n border-radius: 10px;\n padding: 10px 12px;\n font: inherit;\n cursor: pointer;\n transition: transform 80ms ease, border-color 120ms ease;\n }\n\n .btn:hover { border-color: var(--line-2); }\n .btn:active { transform: translateY(1px); }\n .btn.primary {\n border-color: color-mix(in srgb, var(--accent) 55%, var(--line));\n background: color-mix(in srgb, var(--accent) 14%, transparent);\n }\n\n .btn:disabled {\n opacity: 0.55;\n cursor: not-allowed;\n }\n\n .panel {\n background: var(--panel);\n border: 1px solid var(--line);\n border-radius: 16px;\n box-shadow: var(--shadow);\n }\n\n .input-panel {\n padding: 14px;\n margin-bottom: 14px;\n }\n\n .filters-panel {\n padding: 14px;\n margin-bottom: 14px;\n }\n\n .input-grid {\n display: grid;\n grid-template-columns: 1fr;\n gap: 14px;\n align-items: stretch;\n }\n\n @media (min-width: 920px) {\n .input-grid {\n grid-template-columns: 380px 1fr;\n }\n }\n\n .dropzone {\n border: 1px dashed color-mix(in srgb, var(--accent) 40%, var(--line));\n border-radius: 14px;\n background: linear-gradient(\n 135deg,\n color-mix(in srgb, var(--accent) 12%, transparent),\n transparent 45%,\n color-mix(in srgb, var(--accent-2) 10%, transparent)\n );\n padding: 16px;\n display: grid;\n gap: 6px;\n cursor: pointer;\n min-height: 124px;\n align-content: center;\n }\n\n .dropzone.dragover {\n border-style: solid;\n border-color: color-mix(in srgb, var(--accent) 70%, var(--line));\n box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);\n }\n\n .dropzone strong { font-size: 15px; }\n .muted { color: var(--muted); }\n .tiny { font-size: 12px; color: var(--muted); }\n\n .tabs {\n display: flex;\n gap: 8px;\n margin-bottom: 10px;\n flex-wrap: wrap;\n align-items: center;\n }\n\n .tab {\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel-2) 94%, transparent);\n color: var(--muted);\n border-radius: 999px;\n padding: 7px 10px;\n cursor: pointer;\n font: inherit;\n }\n\n .tab.active {\n color: var(--text);\n border-color: color-mix(in srgb, var(--accent) 55%, var(--line));\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n }\n\n textarea {\n width: 100%;\n min-height: 190px;\n resize: vertical;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.4;\n }\n\n .row {\n display: flex;\n gap: 10px;\n flex-wrap: wrap;\n align-items: center;\n margin-top: 10px;\n }\n\n .status {\n margin-top: 12px;\n padding: 10px 12px;\n border-radius: 12px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel-2) 94%, transparent);\n display: none;\n white-space: pre-wrap;\n word-break: break-word;\n }\n .status.visible { display: block; }\n .status.error { border-color: color-mix(in srgb, var(--bad) 60%, var(--line)); }\n .status.good { border-color: color-mix(in srgb, var(--good) 55%, var(--line)); }\n .status.warn { border-color: color-mix(in srgb, var(--warn) 55%, var(--line)); }\n\n main {\n display: grid;\n gap: 14px;\n grid-template-columns: 1fr;\n min-height: 560px;\n }\n\n @media (min-width: 1060px) {\n main {\n grid-template-columns: 1fr 520px;\n align-items: stretch;\n }\n }\n\n .timeline-panel {\n display: grid;\n grid-template-rows: auto 1fr;\n min-height: 560px;\n }\n\n .stats {\n display: flex;\n gap: 12px;\n flex-wrap: wrap;\n align-items: baseline;\n justify-content: space-between;\n padding: 12px 14px;\n border-bottom: 1px solid var(--line);\n }\n\n .stats .big {\n font-size: 14px;\n font-weight: 600;\n }\n\n .timeline {\n overflow: auto;\n padding: 8px 0;\n }\n\n .timeline:focus-visible {\n outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent);\n outline-offset: -2px;\n border-radius: 14px;\n }\n\n .day-sep {\n position: sticky;\n top: 0;\n z-index: 2;\n padding: 8px 14px;\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n border-top: 1px solid var(--line);\n border-bottom: 1px solid var(--line);\n font-size: 12px;\n color: var(--muted);\n backdrop-filter: blur(6px);\n }\n\n .event {\n display: grid;\n grid-template-columns: 176px 1fr;\n gap: 12px;\n padding: 10px 14px;\n border-bottom: 1px solid color-mix(in srgb, var(--line) 85%, transparent);\n cursor: pointer;\n }\n\n .event:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); }\n .event.selected {\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n outline: 2px solid color-mix(in srgb, var(--accent) 35%, transparent);\n outline-offset: -2px;\n }\n\n .time {\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.35;\n color: var(--muted);\n display: grid;\n gap: 3px;\n }\n\n .time .ts {\n color: var(--text);\n font-weight: 600;\n }\n\n .badges {\n display: flex;\n gap: 6px;\n flex-wrap: wrap;\n align-items: center;\n margin-bottom: 6px;\n }\n\n .badge {\n font-size: 11px;\n font-family: var(--mono);\n padding: 2px 7px;\n border-radius: 999px;\n border: 1px solid var(--line);\n color: var(--muted);\n background: color-mix(in srgb, var(--panel-2) 90%, transparent);\n }\n\n .badge.k-user { border-color: color-mix(in srgb, #00c8ff 55%, var(--line)); color: color-mix(in srgb, #00c8ff 78%, var(--text)); }\n .badge.k-assistant { border-color: color-mix(in srgb, #ff73c4 55%, var(--line)); color: color-mix(in srgb, #ff73c4 78%, var(--text)); }\n .badge.k-file-history-snapshot { border-color: color-mix(in srgb, var(--warn) 55%, var(--line)); color: color-mix(in srgb, var(--warn) 75%, var(--text)); }\n\n .badge.ct-text { border-color: color-mix(in srgb, var(--good) 55%, var(--line)); color: color-mix(in srgb, var(--good) 75%, var(--text)); }\n .badge.ct-thinking { border-color: color-mix(in srgb, var(--accent-2) 55%, var(--line)); color: color-mix(in srgb, var(--accent-2) 75%, var(--text)); }\n .badge.ct-tool_use { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); color: color-mix(in srgb, var(--accent) 75%, var(--text)); }\n .badge.ct-tool_result { border-color: color-mix(in srgb, #8690a8 55%, var(--line)); color: color-mix(in srgb, #8690a8 70%, var(--text)); }\n .badge.ct-image { border-color: color-mix(in srgb, #ff9f43 55%, var(--line)); color: color-mix(in srgb, #ff9f43 75%, var(--text)); }\n\n .badge.role-user { border-color: color-mix(in srgb, #00c8ff 55%, var(--line)); color: color-mix(in srgb, #00c8ff 78%, var(--text)); }\n .badge.role-assistant { border-color: color-mix(in srgb, #ff73c4 55%, var(--line)); color: color-mix(in srgb, #ff73c4 78%, var(--text)); }\n\n .summary {\n font-size: 13px;\n line-height: 1.35;\n color: var(--text);\n word-break: break-word;\n }\n\n .summary code {\n font-family: var(--mono);\n font-size: 12px;\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n padding: 2px 5px;\n border-radius: 7px;\n }\n\n .detail {\n display: grid;\n grid-template-rows: auto auto auto auto 1fr;\n min-height: 560px;\n }\n\n @media (min-width: 1060px) {\n .detail {\n position: sticky;\n top: 16px;\n align-self: start;\n max-height: calc(100vh - 32px);\n }\n }\n\n .detail.inline {\n position: static;\n top: auto;\n align-self: auto;\n max-height: none;\n min-height: 0;\n margin: 8px 14px 12px;\n box-shadow: none;\n }\n\n .detail.inline pre {\n max-height: 52vh;\n }\n\n .detail-head {\n padding: 12px 14px;\n border-bottom: 1px solid var(--line);\n display: grid;\n gap: 8px;\n }\n\n .detail-title {\n font-size: 14px;\n font-weight: 700;\n line-height: 1.25;\n margin: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .detail-actions {\n display: flex;\n gap: 8px;\n flex-wrap: wrap;\n align-items: center;\n justify-content: flex-start;\n }\n\n .check {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n color: var(--muted);\n font-size: 12px;\n user-select: none;\n }\n\n pre {\n margin: 0;\n padding: 12px 14px 16px;\n overflow: auto;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.45;\n color: color-mix(in srgb, var(--text) 92%, transparent);\n background: transparent;\n white-space: pre-wrap;\n word-break: break-word;\n }\n\n .detail-images {\n display: flex;\n gap: 10px;\n padding: 12px 14px;\n border-bottom: 1px solid var(--line);\n overflow-x: auto;\n overscroll-behavior-x: contain;\n }\n\n .thumb {\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel-2) 92%, transparent);\n border-radius: 12px;\n padding: 0;\n cursor: pointer;\n width: 78px;\n height: 58px;\n overflow: hidden;\n flex: 0 0 auto;\n display: grid;\n place-items: stretch;\n }\n\n .thumb:hover { border-color: var(--line-2); }\n\n .thumb img {\n width: 100%;\n height: 100%;\n display: block;\n object-fit: cover;\n }\n\n .image-modal {\n position: fixed;\n inset: 0;\n z-index: 9999;\n background: rgba(0, 0, 0, 0.68);\n display: none;\n padding: 18px;\n place-items: center;\n }\n\n .image-modal.open { display: grid; }\n\n .image-modal .dialog {\n position: relative;\n border-radius: 16px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 98%, transparent);\n box-shadow: var(--shadow);\n padding: 14px;\n max-width: min(1200px, 96vw);\n max-height: 92vh;\n display: grid;\n place-items: center;\n }\n\n .image-modal img {\n display: block;\n max-width: min(1140px, 92vw);\n max-height: 84vh;\n width: auto;\n height: auto;\n object-fit: contain;\n border-radius: 10px;\n background: color-mix(in srgb, var(--panel-2) 92%, transparent);\n }\n\n .image-modal .close {\n position: absolute;\n top: 10px;\n right: 10px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n color: var(--text);\n border-radius: 999px;\n padding: 8px 10px;\n font: inherit;\n cursor: pointer;\n }\n\n .image-modal .close:hover { border-color: var(--line-2); }\n\n .empty {\n padding: 14px;\n color: var(--muted);\n font-size: 13px;\n line-height: 1.35;\n }\n\n /* Pretty view styles */\n .detail-pretty {\n padding: 14px;\n border-bottom: 1px solid var(--line);\n overflow: auto;\n max-height: 50vh;\n }\n\n .detail.inline .detail-pretty {\n max-height: 40vh;\n }\n\n .pretty-section {\n margin-bottom: 14px;\n }\n\n .pretty-section:last-child {\n margin-bottom: 0;\n }\n\n .pretty-label {\n font-size: 11px;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n color: var(--muted);\n margin-bottom: 6px;\n }\n\n .pretty-content {\n font-size: 13px;\n line-height: 1.5;\n }\n\n .pretty-content p {\n margin: 0 0 10px;\n }\n\n .pretty-content p:last-child {\n margin-bottom: 0;\n }\n\n .pretty-content h1, .pretty-content h2, .pretty-content h3,\n .pretty-content h4, .pretty-content h5, .pretty-content h6 {\n margin: 14px 0 8px;\n font-weight: 600;\n line-height: 1.3;\n }\n\n .pretty-content h1:first-child, .pretty-content h2:first-child,\n .pretty-content h3:first-child {\n margin-top: 0;\n }\n\n .pretty-content h1 { font-size: 1.4em; }\n .pretty-content h2 { font-size: 1.25em; }\n .pretty-content h3 { font-size: 1.1em; }\n\n .pretty-content ul, .pretty-content ol {\n margin: 8px 0;\n padding-left: 1.5em;\n list-style-position: outside;\n }\n\n .pretty-content li {\n margin: 4px 0;\n }\n\n .thinking-block ul, .thinking-block ol {\n padding-left: 2em;\n }\n\n .pretty-content code {\n font-family: var(--mono);\n font-size: 0.9em;\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n padding: 2px 5px;\n border-radius: 5px;\n }\n\n .pretty-content pre {\n margin: 10px 0;\n padding: 12px;\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n border-radius: 10px;\n overflow-x: auto;\n }\n\n .pretty-content pre code {\n background: none;\n border: none;\n padding: 0;\n font-size: 12px;\n }\n\n .pretty-content blockquote {\n margin: 10px 0;\n padding: 8px 12px;\n border-left: 3px solid var(--accent);\n background: color-mix(in srgb, var(--accent) 8%, transparent);\n border-radius: 0 8px 8px 0;\n }\n\n .pretty-content a {\n color: var(--accent);\n text-decoration: none;\n }\n\n .pretty-content a:hover {\n text-decoration: underline;\n }\n\n .pretty-content strong {\n font-weight: 600;\n }\n\n .pretty-content em {\n font-style: italic;\n }\n\n .pretty-content table {\n border-collapse: collapse;\n margin: 10px 0;\n width: 100%;\n font-size: 13px;\n }\n\n .pretty-content th,\n .pretty-content td {\n border: 1px solid var(--line);\n padding: 8px 10px;\n text-align: left;\n }\n\n .pretty-content th {\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n font-weight: 600;\n }\n\n .pretty-content tr:hover {\n background: color-mix(in srgb, var(--accent) 6%, transparent);\n }\n\n .command-block {\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n border-radius: 10px;\n overflow: hidden;\n margin: 8px 0;\n }\n\n .command-header {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 8px 12px;\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n border-bottom: 1px solid var(--line);\n font-size: 12px;\n }\n\n .command-header .tool-name {\n font-weight: 600;\n color: var(--accent);\n }\n\n .command-header .tool-desc {\n color: var(--muted);\n }\n\n .command-input {\n padding: 10px 12px;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.4;\n white-space: pre-wrap;\n word-break: break-word;\n border-bottom: 1px solid var(--line);\n }\n\n .command-input::before {\n content: '$ ';\n color: var(--good);\n font-weight: 600;\n }\n\n .command-output {\n padding: 10px 12px;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.4;\n white-space: pre-wrap;\n word-break: break-word;\n max-height: 300px;\n overflow: auto;\n color: var(--muted);\n }\n\n .file-path {\n font-family: var(--mono);\n font-size: 12px;\n padding: 6px 10px;\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n border-radius: 8px;\n display: inline-block;\n margin: 4px 0;\n }\n\n .tool-params {\n display: grid;\n gap: 8px;\n margin-top: 8px;\n }\n\n .tool-param {\n display: grid;\n grid-template-columns: auto 1fr;\n gap: 8px;\n align-items: start;\n font-size: 12px;\n }\n\n .tool-param-name {\n font-family: var(--mono);\n font-weight: 600;\n color: var(--accent);\n padding: 2px 6px;\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n border-radius: 4px;\n }\n\n .tool-param-value {\n font-family: var(--mono);\n white-space: pre-wrap;\n word-break: break-word;\n padding: 2px 0;\n }\n\n .thinking-block {\n background: color-mix(in srgb, var(--accent-2) 10%, transparent);\n border: 1px solid color-mix(in srgb, var(--accent-2) 30%, var(--line));\n border-radius: 10px;\n padding: 12px 16px;\n font-size: 13px;\n line-height: 1.5;\n }\n\n .result-block {\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n border-radius: 10px;\n padding: 12px;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.4;\n white-space: pre-wrap;\n word-break: break-word;\n max-height: 400px;\n overflow: auto;\n }\n\n .result-error {\n border-color: color-mix(in srgb, var(--bad) 50%, var(--line));\n background: color-mix(in srgb, var(--bad) 8%, transparent);\n }\n\n .json-toggle {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 8px 14px;\n border-bottom: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel-2) 50%, transparent);\n }\n\n .json-toggle-label {\n font-size: 12px;\n color: var(--muted);\n font-weight: 500;\n }\n\n .prompts-modal {\n position: fixed;\n inset: 0;\n z-index: 80;\n background: rgba(0, 0, 0, 0.68);\n display: none;\n padding: 18px;\n place-items: center;\n }\n\n .prompts-modal.open { display: grid; }\n\n .prompts-modal .dialog {\n position: relative;\n border-radius: 16px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 98%, transparent);\n box-shadow: var(--shadow);\n padding: 14px;\n width: min(1100px, 96vw);\n height: min(86vh, 780px);\n display: grid;\n grid-template-rows: auto 1fr auto;\n gap: 10px;\n }\n\n .prompts-head {\n display: flex;\n gap: 10px;\n align-items: center;\n justify-content: space-between;\n }\n\n .prompts-title {\n font-weight: 700;\n letter-spacing: 0.2px;\n }\n\n .prompts-modal textarea {\n width: 100%;\n height: 100%;\n resize: none;\n border-radius: 12px;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.45;\n padding: 12px;\n }\n\n .prompts-actions {\n display: flex;\n gap: 10px;\n align-items: center;\n justify-content: flex-end;\n flex-wrap: wrap;\n }\n </style>\n</head>\n <body>\n <div class=\"wrap\">\n <header>\n <div>\n <h1>Claude Code Timeline Viewer</h1>\n <p class=\"sub\">Open a Claude Code session <code>.jsonl</code> file and explore it as an interactive timeline. Times are shown in your local timezone by default. Find sessions in <code>~/.claude/projects/</code>.</p>\n </div>\n </header>\n\n <section class=\"panel input-panel\" aria-label=\"Input\">\n <div class=\"input-grid\">\n <div>\n <div id=\"dropzone\" class=\"dropzone\" tabindex=\"0\">\n <strong>Drop a <code>.jsonl</code> file here</strong>\n <div class=\"muted\">or click to choose a file</div>\n <div class=\"tiny\">Supported: file picker, drag-and-drop, paste, or URL fetch (CORS-enabled).</div>\n <input id=\"file-input\" type=\"file\" accept=\".jsonl,application/json,text/plain\" hidden />\n </div>\n </div>\n <div>\n <div class=\"tabs\" role=\"tablist\" aria-label=\"Input tabs\">\n <button type=\"button\" class=\"tab active\" data-tab=\"paste\" aria-selected=\"true\">Paste</button>\n <button type=\"button\" class=\"tab\" data-tab=\"url\" aria-selected=\"false\">URL</button>\n </div>\n\n <div id=\"panel-paste\" data-panel=\"paste\">\n <textarea id=\"paste-input\" placeholder=\"Paste JSONL content here...\"></textarea>\n <div class=\"row\">\n <button type=\"button\" class=\"btn primary\" id=\"render-paste\">Render pasted content</button>\n <div class=\"tiny\">Tip: the timeline uses one JSON object per line.</div>\n </div>\n </div>\n\n <div id=\"panel-url\" data-panel=\"url\" hidden>\n <input id=\"url-input\" type=\"url\" placeholder=\"https://example.com/session.jsonl\" />\n <div class=\"row\">\n <button type=\"button\" class=\"btn primary\" id=\"fetch-url\">Fetch and render</button>\n <div class=\"tiny\">The URL must allow cross-origin requests (CORS).</div>\n </div>\n </div>\n </div>\n </div>\n\n <div id=\"status\" class=\"status\" role=\"status\" aria-live=\"polite\"></div>\n </section>\n\n <section class=\"panel filters-panel\" aria-label=\"Filters\">\n <div class=\"controls\" aria-label=\"Timeline controls\">\n <div class=\"field\" style=\"min-width: 170px\">\n <div class=\"label\">Timezone</div>\n <div class=\"segmented\" role=\"group\" aria-label=\"Timezone toggle\">\n <button type=\"button\" id=\"tz-local\" class=\"active\">Local</button>\n <button type=\"button\" id=\"tz-utc\">UTC</button>\n </div>\n </div>\n <div class=\"field\" style=\"min-width: 220px\">\n <div class=\"label\">Search</div>\n <input id=\"search\" type=\"search\" placeholder=\"message, tool name, cwd...\" autocomplete=\"off\" />\n </div>\n <div class=\"field\" style=\"min-width: 190px\">\n <div class=\"label\">Type</div>\n <select id=\"type-filter\"></select>\n </div>\n <div class=\"field\" style=\"min-width: 210px\">\n <div class=\"label\">Content Type</div>\n <select id=\"content-type-filter\"></select>\n </div>\n <div class=\"field\" style=\"min-width: 160px\">\n <div class=\"label\">Role</div>\n <select id=\"role-filter\"></select>\n </div>\n <label class=\"check\" title=\"Hide thinking blocks (can be verbose)\">\n <input type=\"checkbox\" id=\"hide-thinking\" />\n Hide thinking\n </label>\n <button type=\"button\" class=\"btn\" id=\"clear-filters\">Clear</button>\n </div>\n </section>\n\n <div class=\"controls\" style=\"margin-bottom: 14px;\">\n <button type=\"button\" class=\"btn\" id=\"show-prompts\" disabled>Show prompts</button>\n </div>\n\n <main>\n <section class=\"panel timeline-panel\" aria-label=\"Timeline\">\n <div class=\"stats\">\n <div class=\"big\"><span id=\"stats-showing\">0</span> <span class=\"muted\">showing</span> <span class=\"muted\">/</span> <span id=\"stats-total\">0</span> <span class=\"muted\">events</span></div>\n <div class=\"muted\" id=\"stats-range\">-</div>\n </div>\n <div id=\"timeline\" class=\"timeline\" role=\"list\" tabindex=\"0\" aria-label=\"Events list\"></div>\n </section>\n\n <aside class=\"panel detail\" aria-label=\"Details\">\n <div class=\"detail-head\">\n <div class=\"detail-title\" id=\"detail-title\">Select an event</div>\n <div class=\"detail-actions\">\n <button type=\"button\" class=\"btn\" id=\"copy-json\" disabled>Copy JSON</button>\n <button type=\"button\" class=\"btn\" id=\"copy-raw\" disabled>Copy raw line</button>\n <label class=\"check\" title=\"Truncate very long strings in the JSON preview\">\n <input type=\"checkbox\" id=\"truncate-strings\" checked />\n Truncate long strings\n </label>\n </div>\n </div>\n <div id=\"detail-images\" class=\"detail-images\" hidden aria-label=\"Event images\"></div>\n <div id=\"detail-pretty\" class=\"detail-pretty\" hidden aria-label=\"Formatted event view\"></div>\n <div class=\"json-toggle\">\n <span class=\"json-toggle-label\">Raw JSON</span>\n </div>\n <pre id=\"detail-json\" aria-label=\"Selected event JSON\" class=\"empty\">Load a file to get started.</pre>\n </aside>\n </main>\n </div>\n\n <div id=\"image-modal\" class=\"image-modal\" role=\"dialog\" aria-modal=\"true\" aria-hidden=\"true\">\n <div class=\"dialog\">\n <button type=\"button\" class=\"close\" id=\"image-modal-close\" aria-label=\"Close image\">Close</button>\n <img id=\"image-modal-img\" alt=\"\" />\n </div>\n </div>\n\n <script>\n const els = {\n dropzone: document.getElementById('dropzone'),\n fileInput: document.getElementById('file-input'),\n pasteInput: document.getElementById('paste-input'),\n renderPaste: document.getElementById('render-paste'),\n urlInput: document.getElementById('url-input'),\n fetchUrl: document.getElementById('fetch-url'),\n status: document.getElementById('status'),\n timeline: document.getElementById('timeline'),\n detailPanel: document.querySelector('aside.detail'),\n detailTitle: document.getElementById('detail-title'),\n detailImages: document.getElementById('detail-images'),\n detailPretty: document.getElementById('detail-pretty'),\n detailJson: document.getElementById('detail-json'),\n copyJson: document.getElementById('copy-json'),\n copyRaw: document.getElementById('copy-raw'),\n truncateStrings: document.getElementById('truncate-strings'),\n tzLocal: document.getElementById('tz-local'),\n tzUtc: document.getElementById('tz-utc'),\n search: document.getElementById('search'),\n typeFilter: document.getElementById('type-filter'),\n contentTypeFilter: document.getElementById('content-type-filter'),\n roleFilter: document.getElementById('role-filter'),\n hideThinking: document.getElementById('hide-thinking'),\n statsShowing: document.getElementById('stats-showing'),\n statsTotal: document.getElementById('stats-total'),\n statsRange: document.getElementById('stats-range'),\n clearFilters: document.getElementById('clear-filters'),\n imageModal: document.getElementById('image-modal'),\n imageModalImg: document.getElementById('image-modal-img'),\n imageModalClose: document.getElementById('image-modal-close'),\n copyPrompts: document.getElementById('copy-prompts'),\n };\n\n const state = {\n sourceLabel: null,\n tzMode: 'local',\n events: [],\n filtered: [],\n selectedId: null,\n selected: null,\n filters: {\n query: '',\n type: 'all',\n contentType: 'all',\n role: 'all',\n hideThinking: false,\n },\n };\n\n let lastActiveElementBeforeModal = null;\n let lastBodyOverflow = null;\n let suppressUrlStateSync = false;\n const wideLayoutMq = window.matchMedia('(min-width: 1060px)');\n const detailDock = document.createElement('div');\n detailDock.id = 'detail-dock';\n detailDock.style.display = 'none';\n els.detailPanel.parentElement.insertBefore(detailDock, els.detailPanel);\n\n function updateDetailPlacement() {\n const inline = !wideLayoutMq.matches;\n\n if (!inline) {\n if (els.detailPanel.previousSibling !== detailDock) detailDock.after(els.detailPanel);\n els.detailPanel.classList.remove('inline');\n return;\n }\n\n const selectedNode = els.timeline.querySelector('.event.selected');\n if (!selectedNode) {\n if (els.detailPanel.previousSibling !== detailDock) detailDock.after(els.detailPanel);\n els.detailPanel.classList.remove('inline');\n return;\n }\n\n if (selectedNode.nextSibling !== els.detailPanel) selectedNode.after(els.detailPanel);\n els.detailPanel.classList.add('inline');\n }\n\n function setStatus(message, kind = 'good') {\n if (!message) {\n els.status.className = 'status';\n els.status.textContent = '';\n return;\n }\n els.status.className = `status visible ${kind}`;\n els.status.textContent = message;\n }\n\n function pad2(number) {\n return String(number).padStart(2, '0');\n }\n\n function pad3(number) {\n return String(number).padStart(3, '0');\n }\n\n function getOffsetString(date) {\n const offsetMinutes = -date.getTimezoneOffset();\n const sign = offsetMinutes >= 0 ? '+' : '-';\n const abs = Math.abs(offsetMinutes);\n const hours = Math.floor(abs / 60);\n const minutes = abs % 60;\n return `${sign}${pad2(hours)}:${pad2(minutes)}`;\n }\n\n function formatTimestamp(date, tzMode) {\n if (!(date instanceof Date) || Number.isNaN(date.getTime())) return '-';\n if (tzMode === 'utc') {\n return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ` +\n `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}:${pad2(date.getUTCSeconds())}.` +\n `${pad3(date.getUTCMilliseconds())}Z`;\n }\n return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ` +\n `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}.` +\n `${pad3(date.getMilliseconds())} ${getOffsetString(date)}`;\n }\n\n function dayKey(date, tzMode) {\n if (!(date instanceof Date) || Number.isNaN(date.getTime())) return '-';\n if (tzMode === 'utc') {\n return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())}`;\n }\n return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;\n }\n\n function formatDelta(ms) {\n if (typeof ms !== 'number' || !Number.isFinite(ms)) return '';\n const abs = Math.abs(ms);\n const sign = ms < 0 ? '-' : '+';\n if (abs < 1000) return `${sign}${Math.round(abs)}ms`;\n if (abs < 60_000) return `${sign}${(abs / 1000).toFixed(abs < 10_000 ? 2 : 1)}s`;\n if (abs < 3_600_000) {\n const m = Math.floor(abs / 60_000);\n const s = Math.floor((abs % 60_000) / 1000);\n return `${sign}${m}m ${pad2(s)}s`;\n }\n if (abs < 86_400_000) {\n const h = Math.floor(abs / 3_600_000);\n const m = Math.floor((abs % 3_600_000) / 60_000);\n return `${sign}${h}h ${m}m`;\n }\n const d = Math.floor(abs / 86_400_000);\n const h = Math.floor((abs % 86_400_000) / 3_600_000);\n return `${sign}${d}d ${h}h`;\n }\n\n function safeJsonParse(text) {\n try {\n return { ok: true, value: JSON.parse(text) };\n } catch (error) {\n return { ok: false, error };\n }\n }\n\n function extractMessageText(message) {\n if (!message || typeof message !== 'object') return null;\n const content = message.content;\n if (typeof content === 'string') return content;\n if (!Array.isArray(content)) return null;\n const parts = [];\n for (const item of content) {\n if (!item || typeof item !== 'object') continue;\n if (typeof item.text === 'string') parts.push(item.text);\n else if (typeof item.thinking === 'string') parts.push(item.thinking);\n }\n const joined = parts.join('');\n return joined || null;\n }\n\n function getContentTypes(message) {\n if (!message || typeof message !== 'object') return [];\n const content = message.content;\n if (typeof content === 'string') return ['text'];\n if (!Array.isArray(content)) return [];\n const types = new Set();\n for (const item of content) {\n if (!item || typeof item !== 'object') continue;\n if (item.type) types.add(item.type);\n }\n return Array.from(types);\n }\n\n function firstLine(text) {\n if (typeof text !== 'string') return null;\n const trimmed = text.trim();\n if (!trimmed) return null;\n const idx = trimmed.indexOf('\\n');\n return idx === -1 ? trimmed : trimmed.slice(0, idx).trim();\n }\n\n function normalizeSummaryText(text, maxLen = 260) {\n if (typeof text !== 'string') return '';\n const singleLine = text.replace(/\\s+/g, ' ').trim();\n if (singleLine.length <= maxLen) return singleLine;\n return singleLine.slice(0, maxLen - 1) + '...';\n }\n\n function summarizeEvent(obj) {\n const kind = obj?.type || '';\n const message = obj?.message;\n\n if (kind === 'file-history-snapshot') {\n const msgId = obj?.messageId ? `msg=${obj.messageId.slice(0, 8)}...` : null;\n return normalizeSummaryText(msgId || 'file-history-snapshot');\n }\n\n if (kind === 'user' || kind === 'assistant') {\n const role = message?.role || kind;\n const content = message?.content;\n\n if (typeof content === 'string') {\n return normalizeSummaryText(`${role}: ${content}`);\n }\n\n if (Array.isArray(content) && content.length > 0) {\n for (const item of content) {\n if (item?.type === 'text' && item.text) {\n return normalizeSummaryText(`${role}: ${item.text}`);\n }\n if (item?.type === 'thinking' && item.thinking) {\n return normalizeSummaryText(`${role} (thinking): ${item.thinking}`);\n }\n if (item?.type === 'tool_use') {\n const name = item.name || 'tool';\n const input = item.input;\n if (name === 'Bash' && input?.command) {\n return normalizeSummaryText(`${name}: ${input.command}`);\n }\n if (name === 'Read' && input?.file_path) {\n return normalizeSummaryText(`${name}: ${input.file_path}`);\n }\n if (name === 'Write' && input?.file_path) {\n return normalizeSummaryText(`${name}: ${input.file_path}`);\n }\n if (name === 'Edit' && input?.file_path) {\n return normalizeSummaryText(`${name}: ${input.file_path}`);\n }\n if (name === 'Glob' && input?.pattern) {\n return normalizeSummaryText(`${name}: ${input.pattern}`);\n }\n if (name === 'Grep' && input?.pattern) {\n return normalizeSummaryText(`${name}: ${input.pattern}`);\n }\n if (name === 'Task' && input?.description) {\n return normalizeSummaryText(`${name}: ${input.description}`);\n }\n if (input && typeof input === 'object') {\n const keys = Object.keys(input).slice(0, 3);\n return normalizeSummaryText(`${name}(${keys.join(', ')}${Object.keys(input).length > 3 ? ', ...' : ''})`);\n }\n return normalizeSummaryText(`${name}()`);\n }\n if (item?.type === 'tool_result') {\n const toolUseId = item.tool_use_id ? item.tool_use_id.slice(0, 12) : 'result';\n const resultContent = item.content;\n if (typeof resultContent === 'string') {\n return normalizeSummaryText(`tool_result: ${firstLine(resultContent) || toolUseId}`);\n }\n if (Array.isArray(resultContent)) {\n for (const rc of resultContent) {\n if (rc?.type === 'text' && rc.text) {\n return normalizeSummaryText(`tool_result: ${firstLine(rc.text) || toolUseId}`);\n }\n if (rc?.type === 'image') {\n return normalizeSummaryText(`tool_result: [image]`);\n }\n }\n }\n return normalizeSummaryText(`tool_result: ${toolUseId}`);\n }\n if (item?.type === 'image') {\n return normalizeSummaryText(`${role}: [image]`);\n }\n }\n return normalizeSummaryText(`${role}: [${content.length} content items]`);\n }\n\n return kind;\n }\n\n return kind || 'event';\n }\n\n function buildSearchText(obj, summary) {\n const pieces = [];\n pieces.push(obj?.type || '');\n pieces.push(obj?.uuid || '');\n pieces.push(obj?.sessionId || '');\n pieces.push(obj?.cwd || '');\n pieces.push(obj?.gitBranch || '');\n pieces.push(obj?.slug || '');\n const message = obj?.message;\n if (message && typeof message === 'object') {\n pieces.push(message.role || '');\n const msg = extractMessageText(message);\n if (msg) pieces.push(msg);\n const content = message.content;\n if (Array.isArray(content)) {\n for (const item of content) {\n if (item?.type === 'tool_use') {\n pieces.push(item.name || '');\n if (item.input && typeof item.input === 'object') {\n pieces.push(JSON.stringify(item.input).slice(0, 500));\n }\n }\n if (item?.type === 'tool_result' && typeof item.content === 'string') {\n pieces.push(item.content.slice(0, 500));\n }\n }\n }\n }\n if (typeof summary === 'string') pieces.push(summary);\n return pieces.join('\\n').toLowerCase();\n }\n\n function stringifyJson(value, truncateLongStrings) {\n const maxLen = 8000;\n return JSON.stringify(value, (key, v) => {\n if (!truncateLongStrings) return v;\n if (typeof v === 'string' && v.length > maxLen) {\n return v.slice(0, maxLen) + `... [truncated ${v.length - maxLen} chars]`;\n }\n return v;\n }, 2);\n }\n\n function updateFilterOptions(events) {\n const typeSet = new Set();\n const contentTypeSet = new Set();\n const roleSet = new Set();\n for (const e of events) {\n if (e.kind) typeSet.add(e.kind);\n for (const ct of e.contentTypes) {\n contentTypeSet.add(ct);\n }\n if (!e.contentTypes.length) contentTypeSet.add('(none)');\n if (e.role) roleSet.add(e.role);\n }\n\n function setOptions(select, items, current) {\n select.innerHTML = '';\n const optAll = document.createElement('option');\n optAll.value = 'all';\n optAll.textContent = 'All';\n select.appendChild(optAll);\n for (const item of items) {\n const opt = document.createElement('option');\n opt.value = item;\n opt.textContent = item;\n select.appendChild(opt);\n }\n select.value = current;\n }\n\n setOptions(els.typeFilter, Array.from(typeSet).sort(), state.filters.type);\n setOptions(els.contentTypeFilter, Array.from(contentTypeSet).sort(), state.filters.contentType);\n setOptions(els.roleFilter, Array.from(roleSet).sort(), state.filters.role);\n\n if (events.length) {\n state.filters.type = els.typeFilter.value || 'all';\n state.filters.contentType = els.contentTypeFilter.value || 'all';\n state.filters.role = els.roleFilter.value || 'all';\n }\n }\n\n function readHashParams() {\n const raw = window.location.hash && window.location.hash.startsWith('#')\n ? window.location.hash.slice(1)\n : '';\n return new URLSearchParams(raw);\n }\n\n function writeHashParams(params) {\n const base = window.location.href.split('#')[0];\n const hash = params.toString();\n const next = base + (hash ? `#${hash}` : '');\n window.history.replaceState(null, '', next);\n }\n\n function syncUrlHashFromControls({ clearSel = false } = {}) {\n if (suppressUrlStateSync) return;\n const params = readHashParams();\n params.set('tz', state.tzMode);\n params.set('q', (els.search.value || '').trim());\n params.set('type', state.filters.type || 'all');\n params.set('ct', state.filters.contentType || 'all');\n params.set('role', state.filters.role || 'all');\n params.set('hide', state.filters.hideThinking ? '1' : '0');\n params.set('truncate', els.truncateStrings.checked ? '1' : '0');\n const selectedLine = state.selected?.line;\n if (typeof selectedLine === 'number' && Number.isFinite(selectedLine)) {\n params.set('sel', String(selectedLine));\n } else if (clearSel) {\n params.delete('sel');\n }\n writeHashParams(params);\n }\n\n function applySelectionFromHash() {\n if (!state.events.length) return;\n const params = readHashParams();\n const raw = params.get('sel');\n if (!raw) return;\n const line = Number(raw);\n if (!Number.isFinite(line)) return;\n const found = state.events.find(e => e.line === line);\n if (!found) return;\n if (state.selectedId === found.id) return;\n suppressUrlStateSync = true;\n try {\n selectEvent(found.id, { scroll: true });\n } finally {\n suppressUrlStateSync = false;\n }\n }\n\n function applyControlsFromHash() {\n const params = readHashParams();\n suppressUrlStateSync = true;\n try {\n const q = params.get('q');\n if (q !== null) {\n els.search.value = q;\n state.filters.query = q.trim().toLowerCase();\n }\n\n const type = params.get('type');\n if (type !== null) state.filters.type = type;\n const ct = params.get('ct');\n if (ct !== null) state.filters.contentType = ct;\n const role = params.get('role');\n if (role !== null) state.filters.role = role;\n\n const hide = params.get('hide');\n if (hide !== null) {\n els.hideThinking.checked = hide === '1' || hide === 'true';\n state.filters.hideThinking = els.hideThinking.checked;\n }\n\n const truncate = params.get('truncate');\n if (truncate !== null) {\n els.truncateStrings.checked = truncate === '1' || truncate === 'true';\n }\n\n const tz = params.get('tz');\n if (tz === 'utc' || tz === 'local') {\n state.tzMode = tz;\n els.tzLocal.classList.toggle('active', tz === 'local');\n els.tzUtc.classList.toggle('active', tz === 'utc');\n }\n } finally {\n suppressUrlStateSync = false;\n }\n }\n\n function applyFilters() {\n const query = state.filters.query;\n const type = state.filters.type;\n const contentType = state.filters.contentType;\n const role = state.filters.role;\n const hideThinking = state.filters.hideThinking;\n\n const out = [];\n for (const e of state.events) {\n if (hideThinking && e.contentTypes.includes('thinking') && e.contentTypes.length === 1) continue;\n if (type !== 'all' && e.kind !== type) continue;\n if (contentType !== 'all') {\n const ct = e.contentTypes.length ? e.contentTypes : ['(none)'];\n if (!ct.includes(contentType)) continue;\n }\n if (role !== 'all' && e.role !== role) continue;\n if (query && !e.search.includes(query)) continue;\n out.push(e);\n }\n\n state.filtered = out;\n els.statsShowing.textContent = String(out.length);\n els.statsTotal.textContent = String(state.events.length);\n\n const start = out.find(e => e.date instanceof Date && !Number.isNaN(e.date.getTime()))?.date;\n const end = [...out].reverse().find(e => e.date instanceof Date && !Number.isNaN(e.date.getTime()))?.date;\n if (start && end) {\n els.statsRange.textContent = `${formatTimestamp(start, state.tzMode)} -> ${formatTimestamp(end, state.tzMode)}`;\n } else {\n els.statsRange.textContent = '-';\n }\n\n renderTimeline();\n }\n\n function clearSelection({ syncHash = true } = {}) {\n state.selectedId = null;\n state.selected = null;\n els.detailTitle.textContent = 'Select an event';\n renderDetailImages(null);\n renderPrettyView(null);\n els.detailJson.textContent = state.events.length ? 'Select an event from the timeline.' : 'Load a file to get started.';\n els.detailJson.classList.add('empty');\n els.copyJson.disabled = true;\n els.copyRaw.disabled = true;\n for (const node of els.timeline.querySelectorAll('.event.selected')) {\n node.classList.remove('selected');\n }\n updateDetailPlacement();\n if (syncHash) syncUrlHashFromControls({ clearSel: true });\n }\n\n function renderTimeline() {\n els.timeline.innerHTML = '';\n\n if (!state.filtered.length) {\n const empty = document.createElement('div');\n empty.className = 'empty';\n empty.textContent = state.events.length ? 'No events match your filters.' : 'Load a file to see a timeline.';\n els.timeline.appendChild(empty);\n clearSelection();\n return;\n }\n\n const fragment = document.createDocumentFragment();\n let lastDay = null;\n let prevDate = null;\n\n for (const e of state.filtered) {\n const day = dayKey(e.date, state.tzMode);\n if (day !== lastDay) {\n lastDay = day;\n const sep = document.createElement('div');\n sep.className = 'day-sep';\n sep.textContent = day;\n fragment.appendChild(sep);\n }\n\n const row = document.createElement('div');\n row.className = 'event';\n row.setAttribute('role', 'listitem');\n row.dataset.eventId = String(e.id);\n\n const time = document.createElement('div');\n time.className = 'time';\n\n const ts = document.createElement('div');\n ts.className = 'ts';\n ts.textContent = formatTimestamp(e.date, state.tzMode);\n ts.dataset.kind = 'timestamp';\n\n const meta = document.createElement('div');\n meta.textContent = [\n `#${e.line}`,\n (prevDate && e.date) ? formatDelta(e.date.getTime() - prevDate.getTime()) : null,\n ].filter(Boolean).join(' . ');\n\n time.appendChild(ts);\n time.appendChild(meta);\n\n const body = document.createElement('div');\n\n const badges = document.createElement('div');\n badges.className = 'badges';\n const badgeKind = document.createElement('span');\n badgeKind.className = `badge k-${e.kind}`;\n badgeKind.textContent = e.kind;\n badges.appendChild(badgeKind);\n\n for (const ct of e.contentTypes.slice(0, 2)) {\n const badgeCt = document.createElement('span');\n badgeCt.className = `badge ct-${ct}`;\n badgeCt.textContent = ct;\n badges.appendChild(badgeCt);\n }\n if (e.contentTypes.length > 2) {\n const badgeMore = document.createElement('span');\n badgeMore.className = 'badge';\n badgeMore.textContent = `+${e.contentTypes.length - 2}`;\n badges.appendChild(badgeMore);\n }\n\n if (e.role) {\n const badgeRole = document.createElement('span');\n badgeRole.className = `badge role-${e.role}`;\n badgeRole.textContent = e.role;\n badges.appendChild(badgeRole);\n }\n\n const summary = document.createElement('div');\n summary.className = 'summary';\n summary.textContent = e.summary;\n\n body.appendChild(badges);\n body.appendChild(summary);\n\n row.appendChild(time);\n row.appendChild(body);\n\n row.addEventListener('click', () => selectEvent(e.id, { focusTimeline: true }));\n fragment.appendChild(row);\n\n if (e.date instanceof Date && !Number.isNaN(e.date.getTime())) prevDate = e.date;\n }\n\n els.timeline.appendChild(fragment);\n\n if (state.selectedId !== null) {\n const stillThere = state.filtered.some(e => e.id === state.selectedId);\n if (!stillThere) clearSelection();\n else selectEvent(state.selectedId, { scroll: false });\n } else {\n clearSelection({ syncHash: false });\n }\n }\n\n function selectEvent(id, { scroll = false, focusTimeline = false } = {}) {\n const e = state.filtered.find(item => item.id === id) || state.events.find(item => item.id === id);\n if (!e) return;\n state.selectedId = id;\n state.selected = e;\n\n if (focusTimeline) {\n els.timeline.focus({ preventScroll: true });\n }\n\n for (const node of els.timeline.querySelectorAll('.event.selected')) {\n node.classList.remove('selected');\n }\n const node = els.timeline.querySelector(`.event[data-event-id=\"${CSS.escape(String(id))}\"]`);\n if (node) {\n node.classList.add('selected');\n updateDetailPlacement();\n if (scroll) node.scrollIntoView({ block: 'center' });\n }\n\n els.detailJson.classList.remove('empty');\n const titleParts = [\n formatTimestamp(e.date, state.tzMode),\n `#${e.line}`,\n e.kind,\n e.role ? `(${e.role})` : null,\n ].filter(Boolean);\n els.detailTitle.textContent = titleParts.join(' ');\n\n const shouldTruncate = els.truncateStrings.checked;\n renderDetailImages(e.obj);\n renderPrettyView(e.obj);\n els.detailJson.textContent = stringifyJson(e.obj, shouldTruncate) || '';\n els.copyJson.disabled = false;\n els.copyRaw.disabled = false;\n updateDetailPlacement();\n syncUrlHashFromControls();\n }\n\n function extractImageUrls(obj) {\n const urls = [];\n const message = obj?.message;\n if (!message || typeof message !== 'object') return urls;\n const content = message.content;\n if (!Array.isArray(content)) return urls;\n\n for (const item of content) {\n if (!item || typeof item !== 'object') continue;\n\n // Direct image in content\n if (item.type === 'image' && item.source?.type === 'base64' && item.source?.data) {\n const mediaType = item.source.media_type || 'image/png';\n urls.push(`data:${mediaType};base64,${item.source.data}`);\n }\n\n // Image in tool_result content\n if (item.type === 'tool_result' && Array.isArray(item.content)) {\n for (const rc of item.content) {\n if (rc?.type === 'image' && rc.source?.type === 'base64' && rc.source?.data) {\n const mediaType = rc.source.media_type || 'image/png';\n urls.push(`data:${mediaType};base64,${rc.source.data}`);\n }\n }\n }\n }\n\n return urls;\n }\n\n function escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n\n // Initialize markdown-it with GFM-like settings\n const md = window.markdownit({\n html: false, // Disable raw HTML input\n linkify: true, // Auto-convert URLs to links\n typographer: true, // Smart quotes, dashes\n breaks: true // Convert \\n to <br>\n });\n\n function renderMarkdown(text) {\n if (typeof text !== 'string') return '';\n\n // Render markdown to HTML\n const html = md.render(text);\n\n // Sanitize with DOMPurify to prevent XSS\n return DOMPurify.sanitize(html);\n }\n\n function renderPrettyView(obj) {\n els.detailPretty.innerHTML = '';\n\n if (!obj || typeof obj !== 'object') {\n els.detailPretty.hidden = true;\n return;\n }\n\n const message = obj.message;\n if (!message || typeof message !== 'object') {\n els.detailPretty.hidden = true;\n return;\n }\n\n const content = message.content;\n const fragment = document.createDocumentFragment();\n let hasContent = false;\n\n // Handle string content (simple text message)\n if (typeof content === 'string') {\n const section = document.createElement('div');\n section.className = 'pretty-section';\n const label = document.createElement('div');\n label.className = 'pretty-label';\n label.textContent = message.role || 'Message';\n const body = document.createElement('div');\n body.className = 'pretty-content';\n body.innerHTML = renderMarkdown(content);\n section.appendChild(label);\n section.appendChild(body);\n fragment.appendChild(section);\n hasContent = true;\n }\n\n // Handle array content\n if (Array.isArray(content)) {\n for (const item of content) {\n if (!item || typeof item !== 'object') continue;\n\n // Text content\n if (item.type === 'text' && item.text) {\n const section = document.createElement('div');\n section.className = 'pretty-section';\n const label = document.createElement('div');\n label.className = 'pretty-label';\n label.textContent = message.role === 'assistant' ? 'Response' : 'Message';\n const body = document.createElement('div');\n body.className = 'pretty-content';\n body.innerHTML = renderMarkdown(item.text);\n section.appendChild(label);\n section.appendChild(body);\n fragment.appendChild(section);\n hasContent = true;\n }\n\n // Thinking content\n if (item.type === 'thinking' && item.thinking) {\n const section = document.createElement('div');\n section.className = 'pretty-section';\n const label = document.createElement('div');\n label.className = 'pretty-label';\n label.textContent = 'Thinking';\n const body = document.createElement('div');\n body.className = 'thinking-block pretty-content';\n body.innerHTML = renderMarkdown(item.thinking);\n section.appendChild(label);\n section.appendChild(body);\n fragment.appendChild(section);\n hasContent = true;\n }\n\n // Tool use\n if (item.type === 'tool_use') {\n const section = document.createElement('div');\n section.className = 'pretty-section';\n const label = document.createElement('div');\n label.className = 'pretty-label';\n label.textContent = 'Tool Call';\n\n const toolName = item.name || 'Unknown Tool';\n const input = item.input || {};\n\n // Special handling for Bash command\n if (toolName === 'Bash' && input.command) {\n const block = document.createElement('div');\n block.className = 'command-block';\n\n const header = document.createElement('div');\n header.className = 'command-header';\n const nameSpan = document.createElement('span');\n nameSpan.className = 'tool-name';\n nameSpan.textContent = toolName;\n header.appendChild(nameSpan);\n if (input.description) {\n const descSpan = document.createElement('span');\n descSpan.className = 'tool-desc';\n descSpan.textContent = input.description;\n header.appendChild(descSpan);\n }\n\n const cmdInput = document.createElement('div');\n cmdInput.className = 'command-input';\n cmdInput.textContent = input.command;\n\n block.appendChild(header);\n block.appendChild(cmdInput);\n section.appendChild(label);\n section.appendChild(block);\n }\n // Special handling for file operations\n else if (['Read', 'Write', 'Edit', 'Glob', 'Grep'].includes(toolName)) {\n const block = document.createElement('div');\n block.className = 'command-block';\n\n const header = document.createElement('div');\n header.className = 'command-header';\n const nameSpan = document.createElement('span');\n nameSpan.className = 'tool-name';\n nameSpan.textContent = toolName;\n header.appendChild(nameSpan);\n\n block.appendChild(header);\n\n const params = document.createElement('div');\n params.className = 'tool-params';\n params.style.padding = '10px 12px';\n\n for (const [key, value] of Object.entries(input)) {\n const param = document.createElement('div');\n param.className = 'tool-param';\n const paramName = document.createElement('span');\n paramName.className = 'tool-param-name';\n paramName.textContent = key;\n const paramValue = document.createElement('span');\n paramValue.className = 'tool-param-value';\n if (typeof value === 'string') {\n paramValue.textContent = value.length > 500 ? value.slice(0, 500) + '...' : value;\n } else {\n paramValue.textContent = JSON.stringify(value, null, 2);\n }\n param.appendChild(paramName);\n param.appendChild(paramValue);\n params.appendChild(param);\n }\n\n block.appendChild(params);\n section.appendChild(label);\n section.appendChild(block);\n }\n // Generic tool display\n else {\n const block = document.createElement('div');\n block.className = 'command-block';\n\n const header = document.createElement('div');\n header.className = 'command-header';\n const nameSpan = document.createElement('span');\n nameSpan.className = 'tool-name';\n nameSpan.textContent = toolName;\n header.appendChild(nameSpan);\n\n block.appendChild(header);\n\n if (Object.keys(input).length > 0) {\n const params = document.createElement('div');\n params.className = 'tool-params';\n params.style.padding = '10px 12px';\n\n for (const [key, value] of Object.entries(input)) {\n const param = document.createElement('div');\n param.className = 'tool-param';\n const paramName = document.createElement('span');\n paramName.className = 'tool-param-name';\n paramName.textContent = key;\n const paramValue = document.createElement('span');\n paramValue.className = 'tool-param-value';\n if (typeof value === 'string') {\n paramValue.textContent = value.length > 1000 ? value.slice(0, 1000) + '...' : value;\n } else {\n paramValue.textContent = JSON.stringify(value, null, 2);\n }\n param.appendChild(paramName);\n param.appendChild(paramValue);\n params.appendChild(param);\n }\n\n block.appendChild(params);\n }\n\n section.appendChild(label);\n section.appendChild(block);\n }\n\n fragment.appendChild(section);\n hasContent = true;\n }\n\n // Tool result\n if (item.type === 'tool_result') {\n const section = document.createElement('div');\n section.className = 'pretty-section';\n const label = document.createElement('div');\n label.className = 'pretty-label';\n label.textContent = item.is_error ? 'Tool Error' : 'Tool Result';\n\n const resultContent = item.content;\n let resultText = '';\n\n if (typeof resultContent === 'string') {\n resultText = resultContent;\n } else if (Array.isArray(resultContent)) {\n for (const rc of resultContent) {\n if (rc?.type === 'text' && rc.text) {\n resultText += rc.text;\n }\n }\n }\n\n if (resultText) {\n const block = document.createElement('div');\n block.className = 'pretty-content' + (item.is_error ? ' result-error' : '');\n const displayText = resultText.length > 5000 ? resultText.slice(0, 5000) + '\\n... [truncated]' : resultText;\n block.innerHTML = renderMarkdown(displayText);\n section.appendChild(label);\n section.appendChild(block);\n fragment.appendChild(section);\n hasContent = true;\n }\n }\n }\n }\n\n if (hasContent) {\n els.detailPretty.appendChild(fragment);\n els.detailPretty.hidden = false;\n } else {\n els.detailPretty.hidden = true;\n }\n }\n\n function openImageModal(src, altText = '') {\n if (!src) return;\n lastActiveElementBeforeModal = document.activeElement instanceof HTMLElement ? document.activeElement : null;\n lastBodyOverflow = document.body.style.overflow;\n document.body.style.overflow = 'hidden';\n\n els.imageModalImg.src = src;\n els.imageModalImg.alt = altText;\n els.imageModal.classList.add('open');\n els.imageModal.setAttribute('aria-hidden', 'false');\n els.imageModalClose.focus();\n }\n\n function closeImageModal() {\n if (!els.imageModal.classList.contains('open')) return;\n els.imageModal.classList.remove('open');\n els.imageModal.setAttribute('aria-hidden', 'true');\n els.imageModalImg.removeAttribute('src');\n els.imageModalImg.alt = '';\n document.body.style.overflow = lastBodyOverflow ?? '';\n\n if (lastActiveElementBeforeModal) {\n lastActiveElementBeforeModal.focus();\n }\n lastActiveElementBeforeModal = null;\n lastBodyOverflow = null;\n }\n\n function renderDetailImages(obj) {\n const urls = extractImageUrls(obj);\n els.detailImages.innerHTML = '';\n if (!urls.length) {\n els.detailImages.hidden = true;\n return;\n }\n const fragment = document.createDocumentFragment();\n urls.forEach((src, idx) => {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'thumb';\n btn.title = `Open image ${idx + 1}`;\n const img = document.createElement('img');\n img.loading = 'lazy';\n img.src = src;\n img.alt = `Image ${idx + 1}`;\n btn.appendChild(img);\n btn.addEventListener('click', () => openImageModal(src, `Image ${idx + 1}`));\n fragment.appendChild(btn);\n });\n els.detailImages.appendChild(fragment);\n els.detailImages.hidden = false;\n }\n\n function isEditableElement(el) {\n if (!(el instanceof HTMLElement)) return false;\n if (el.isContentEditable) return true;\n const tag = el.tagName;\n return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';\n }\n\n function moveSelection(delta) {\n if (!state.filtered.length) return;\n const idx = state.filtered.findIndex(e => e.id === state.selectedId);\n if (idx === -1) {\n selectEvent(state.filtered[0].id, { scroll: true });\n return;\n }\n const nextIdx = Math.max(0, Math.min(state.filtered.length - 1, idx + delta));\n if (nextIdx === idx) return;\n selectEvent(state.filtered[nextIdx].id, { scroll: true });\n }\n\n async function copyText(text) {\n if (typeof text !== 'string') return;\n try {\n await navigator.clipboard.writeText(text);\n setStatus('Copied to clipboard.', 'good');\n } catch (error) {\n const ta = document.createElement('textarea');\n ta.value = text;\n ta.style.position = 'fixed';\n ta.style.left = '-9999px';\n document.body.appendChild(ta);\n ta.select();\n document.execCommand('copy');\n document.body.removeChild(ta);\n setStatus('Copied to clipboard.', 'good');\n }\n }\n\n function extractUserPrompts() {\n const prompts = [];\n for (const e of state.events) {\n // Filter: type=user, content-type includes text, role=user\n if (e.kind !== 'user') continue;\n if (e.role !== 'user') continue;\n if (!e.contentTypes.includes('text')) continue;\n\n const message = e.obj?.message;\n if (!message) continue;\n\n const content = message.content;\n if (typeof content === 'string') {\n prompts.push(content);\n } else if (Array.isArray(content)) {\n for (const item of content) {\n if (item?.type === 'text' && item.text) {\n prompts.push(item.text);\n }\n }\n }\n }\n return prompts.join('\\n\\n');\n }\n\n async function copyUserPrompts() {\n const text = extractUserPrompts();\n if (!text) {\n setStatus('No user prompts found.', 'warn');\n return;\n }\n await copyText(text);\n const originalText = els.copyPrompts.textContent;\n els.copyPrompts.textContent = 'Copied!';\n setTimeout(() => {\n els.copyPrompts.textContent = originalText;\n }, 2000);\n }\n\n function setTimezoneMode(mode) {\n state.tzMode = mode;\n els.tzLocal.classList.toggle('active', mode === 'local');\n els.tzUtc.classList.toggle('active', mode === 'utc');\n applyFilters();\n if (state.selectedId !== null) selectEvent(state.selectedId);\n syncUrlHashFromControls();\n }\n\n els.imageModalClose.addEventListener('click', closeImageModal);\n els.imageModal.addEventListener('click', (event) => {\n if (event.target === els.imageModal) closeImageModal();\n });\n document.addEventListener('keydown', (event) => {\n if (event.key === 'Escape') closeImageModal();\n });\n\n document.addEventListener('keydown', (event) => {\n if (els.imageModal.classList.contains('open')) return;\n if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;\n if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;\n if (isEditableElement(document.activeElement)) return;\n if (document.activeElement !== els.timeline && !els.timeline.contains(document.activeElement)) return;\n if (state.selectedId === null) return;\n event.preventDefault();\n moveSelection(event.key === 'ArrowUp' ? -1 : 1);\n });\n\n if (typeof wideLayoutMq.addEventListener === 'function') {\n wideLayoutMq.addEventListener('change', updateDetailPlacement);\n } else if (typeof wideLayoutMq.addListener === 'function') {\n wideLayoutMq.addListener(updateDetailPlacement);\n }\n\n function setTab(tabName) {\n for (const tab of document.querySelectorAll('.tab')) {\n const active = tab.dataset.tab === tabName;\n tab.classList.toggle('active', active);\n tab.setAttribute('aria-selected', active ? 'true' : 'false');\n }\n for (const panel of document.querySelectorAll('[data-panel]')) {\n panel.hidden = panel.dataset.panel !== tabName;\n }\n }\n\n function setShareUrlParam(url) {\n const current = new URL(window.location.href);\n if (url) current.searchParams.set('url', url);\n else current.searchParams.delete('url');\n window.history.replaceState(null, '', current);\n }\n\n async function fetchAndRenderUrl(url, { syncUrl = true } = {}) {\n const trimmed = (url || '').trim();\n if (!trimmed) {\n setStatus('Enter a URL first.', 'error');\n return;\n }\n if (syncUrl) setShareUrlParam(trimmed);\n setStatus(`Fetching ${trimmed}...`, 'warn');\n try {\n const resp = await fetch(trimmed, { mode: 'cors' });\n if (!resp.ok) throw new Error(`HTTP ${resp.status}`);\n const text = await resp.text();\n await loadText(text, trimmed);\n } catch (error) {\n setStatus(`Failed to fetch URL: ${String(error)}`, 'error');\n }\n }\n\n async function loadText(text, sourceLabel) {\n const started = performance.now();\n state.sourceLabel = sourceLabel || null;\n setStatus(`Parsing ${sourceLabel || 'content'}...`, 'warn');\n clearSelection({ syncHash: false });\n\n const lines = text.split(/\\r?\\n/);\n const events = [];\n const errors = [];\n let idCounter = 0;\n\n for (let i = 0; i < lines.length; i++) {\n const line = lines[i];\n if (!line.trim()) continue;\n const parsed = safeJsonParse(line);\n if (!parsed.ok) {\n errors.push({ line: i + 1, error: parsed.error });\n continue;\n }\n const obj = parsed.value;\n const ts = typeof obj.timestamp === 'string' ? new Date(obj.timestamp) : null;\n const kind = obj.type || '(unknown)';\n const message = obj.message && typeof obj.message === 'object' ? obj.message : null;\n const contentTypes = message ? getContentTypes(message) : [];\n const role = message?.role ?? null;\n\n const summary = summarizeEvent(obj);\n events.push({\n id: ++idCounter,\n line: i + 1,\n raw: line,\n obj,\n date: ts,\n kind,\n contentTypes,\n role,\n summary,\n search: buildSearchText(obj, summary),\n });\n }\n\n state.events = events;\n updateFilterOptions(events);\n applyFilters();\n applySelectionFromHash();\n syncUrlHashFromControls();\n\n const durationMs = Math.round(performance.now() - started);\n if (errors.length) {\n const first = errors[0];\n setStatus(\n `Loaded ${events.length} events from ${sourceLabel || 'content'} in ${durationMs}ms.\\n` +\n `Could not parse ${errors.length} line(s). First error at line ${first.line}: ${String(first.error)}`,\n 'warn'\n );\n } else {\n setStatus(`Loaded ${events.length} events from ${sourceLabel || 'content'} in ${durationMs}ms.`, 'good');\n }\n }\n\n async function handleFile(file) {\n if (!file) return;\n setShareUrlParam(null);\n try {\n const text = await file.text();\n await loadText(text, file.name);\n } catch (error) {\n setStatus(`Failed to read file: ${String(error)}`, 'error');\n }\n }\n\n els.dropzone.addEventListener('click', () => els.fileInput.click());\n els.dropzone.addEventListener('keydown', (e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n els.fileInput.click();\n }\n });\n els.fileInput.addEventListener('change', () => handleFile(els.fileInput.files?.[0]));\n\n els.dropzone.addEventListener('dragover', (e) => {\n e.preventDefault();\n els.dropzone.classList.add('dragover');\n });\n els.dropzone.addEventListener('dragleave', () => els.dropzone.classList.remove('dragover'));\n els.dropzone.addEventListener('drop', (e) => {\n e.preventDefault();\n els.dropzone.classList.remove('dragover');\n const file = e.dataTransfer?.files?.[0];\n if (file) handleFile(file);\n });\n\n els.renderPaste.addEventListener('click', async () => {\n const text = els.pasteInput.value || '';\n if (!text.trim()) {\n setStatus('Paste some JSONL content first.', 'error');\n return;\n }\n setShareUrlParam(null);\n await loadText(text, 'pasted content');\n });\n\n els.fetchUrl.addEventListener('click', async () => {\n await fetchAndRenderUrl(els.urlInput.value, { syncUrl: true });\n });\n\n for (const tab of document.querySelectorAll('.tab')) {\n tab.addEventListener('click', () => setTab(tab.dataset.tab));\n }\n\n els.tzLocal.addEventListener('click', () => setTimezoneMode('local'));\n els.tzUtc.addEventListener('click', () => setTimezoneMode('utc'));\n\n els.search.addEventListener('input', () => {\n state.filters.query = els.search.value.trim().toLowerCase();\n applyFilters();\n syncUrlHashFromControls();\n });\n els.typeFilter.addEventListener('change', () => {\n state.filters.type = els.typeFilter.value;\n applyFilters();\n syncUrlHashFromControls();\n });\n els.contentTypeFilter.addEventListener('change', () => {\n state.filters.contentType = els.contentTypeFilter.value;\n applyFilters();\n syncUrlHashFromControls();\n });\n els.roleFilter.addEventListener('change', () => {\n state.filters.role = els.roleFilter.value;\n applyFilters();\n syncUrlHashFromControls();\n });\n els.hideThinking.addEventListener('change', () => {\n state.filters.hideThinking = els.hideThinking.checked;\n applyFilters();\n syncUrlHashFromControls();\n });\n els.truncateStrings.addEventListener('change', () => {\n if (state.selectedId !== null) selectEvent(state.selectedId);\n syncUrlHashFromControls();\n });\n\n els.clearFilters.addEventListener('click', () => {\n els.search.value = '';\n state.filters.query = '';\n state.filters.type = 'all';\n state.filters.contentType = 'all';\n state.filters.role = 'all';\n state.filters.hideThinking = false;\n els.hideThinking.checked = false;\n updateFilterOptions(state.events);\n applyFilters();\n syncUrlHashFromControls();\n });\n\n els.copyJson.addEventListener('click', () => {\n if (!state.selected) return;\n copyText(stringifyJson(state.selected.obj, false));\n });\n els.copyRaw.addEventListener('click', () => {\n if (!state.selected) return;\n copyText(state.selected.raw);\n });\n els.copyPrompts.addEventListener('click', copyUserPrompts);\n\n async function initFromQuery() {\n const url = new URLSearchParams(window.location.search).get('url');\n if (!url) return;\n setTab('url');\n els.urlInput.value = url;\n await fetchAndRenderUrl(url, { syncUrl: false });\n }\n\n // Initialize select options for the empty state.\n applyControlsFromHash();\n updateFilterOptions([]);\n syncUrlHashFromControls();\n window.addEventListener('hashchange', () => {\n applyControlsFromHash();\n updateFilterOptions(state.events);\n applyFilters();\n applySelectionFromHash();\n });\n void initFromQuery();\n </script>\n</body>\n</html>\n","structuredPatch":[{"oldStart":1040,"oldLines":6,"newStart":1040,"newLines":19,"lines":[" </div>"," </div>"," ","+ <div id=\"prompts-modal\" class=\"prompts-modal\" role=\"dialog\" aria-modal=\"true\" aria-hidden=\"true\" aria-label=\"User prompts\">","+ <div class=\"dialog\">","+ <div class=\"prompts-head\">","+ <div class=\"prompts-title\">User prompts</div>","+ <button type=\"button\" class=\"btn\" id=\"prompts-close\" aria-label=\"Close prompts\">Close</button>","+ </div>","+ <textarea id=\"prompts-text\" readonly spellcheck=\"false\"></textarea>","+ <div class=\"prompts-actions\">","+ <button type=\"button\" class=\"btn primary\" id=\"prompts-copy\">Copy</button>","+ </div>","+ </div>","+ </div>","+"," <script>"," const els = {"," dropzone: document.getElementById('dropzone'),"]}],"userModified":false,"replaceAll":false}}
{"parentUuid":"c0aabd11-5c89-40d2-94ae-477a62625669","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","slug":"glittery-skipping-rivest","message":{"model":"claude-haiku-4-5-20251001","id":"msg_012yvR96cAGjG4JBAtBnJYmG","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Good. Now I need to update the els object to include the prompts-related elements. Let me find where copyPrompts is defined and update it.","signature":"ErMCCkYIChgCKkAyDxkAVEfKgN2gm0sxIXF82rbOnlxpVVXvrYZEL6dXDTNyibdQ78KYr0uI8cEKncLw4Y0QABNMD6ksfXWnzXRhEgxFLdL3RLS6KLwM7BYaDLaBbKGiQgL3DD+2wiIwhkxeGx9mcBdy6FFJKeCPfoYJMV7r1L3aPrQMNARZK1bcHsPXX1xM6C0AK+URxBH5KpoBNvALFfF1aSfr52VlUJmXvhqyh+Swnwvvnz2P3qJxMb8LAnwBbTV3FMomQXOqH/wJixZlAfDcXGe9abOGC8bsUrphwvRmFVgeXT45VKoQVsr/QUnnJ8XquPuR/nTbjhEWKvJKghUAyPHbbONXzyFlZWpoaAC81tbt8oS5nAglst9aJhplqRhHqSnSJYAdpW6R92qSZdBqqc/F3xgB"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":1176,"cache_read_input_tokens":87167,"cache_creation":{"ephemeral_5m_input_tokens":1176,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWDdbSnrsALMe6tUrfBvJ","type":"assistant","uuid":"ec13052e-6b0c-4f93-9fcf-6777c258c2a9","timestamp":"2025-12-18T05:52:18.869Z"}
{"parentUuid":"ec13052e-6b0c-4f93-9fcf-6777c258c2a9","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","slug":"glittery-skipping-rivest","message":{"model":"claude-haiku-4-5-20251001","id":"msg_012yvR96cAGjG4JBAtBnJYmG","type":"message","role":"assistant","content":[{"type":"text","text":"Now I'll update the els object to include the prompts modal elements:"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":1176,"cache_read_input_tokens":87167,"cache_creation":{"ephemeral_5m_input_tokens":1176,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWDdbSnrsALMe6tUrfBvJ","type":"assistant","uuid":"1dd0c65c-feef-4449-b080-6754d8722f43","timestamp":"2025-12-18T05:52:18.938Z"}
{"parentUuid":"1dd0c65c-feef-4449-b080-6754d8722f43","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","slug":"glittery-skipping-rivest","message":{"model":"claude-haiku-4-5-20251001","id":"msg_012yvR96cAGjG4JBAtBnJYmG","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_018JGsUenTTYDJhN1nA3y1nN","name":"Edit","input":{"file_path":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","old_string":" clearFilters: document.getElementById('clear-filters'),\n imageModal: document.getElementById('image-modal'),\n imageModalImg: document.getElementById('image-modal-img'),\n imageModalClose: document.getElementById('image-modal-close'),\n copyPrompts: document.getElementById('copy-prompts'),\n };","new_string":" clearFilters: document.getElementById('clear-filters'),\n imageModal: document.getElementById('image-modal'),\n imageModalImg: document.getElementById('image-modal-img'),\n imageModalClose: document.getElementById('image-modal-close'),\n showPrompts: document.getElementById('show-prompts'),\n promptsModal: document.getElementById('prompts-modal'),\n promptsClose: document.getElementById('prompts-close'),\n promptsText: document.getElementById('prompts-text'),\n promptsCopy: document.getElementById('prompts-copy'),\n };"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":1176,"cache_read_input_tokens":87167,"cache_creation":{"ephemeral_5m_input_tokens":1176,"ephemeral_1h_input_tokens":0},"output_tokens":376,"service_tier":"standard"}},"requestId":"req_011CWDdbSnrsALMe6tUrfBvJ","type":"assistant","uuid":"1e428703-ea4e-4264-981b-a7cbcc4483d6","timestamp":"2025-12-18T05:52:20.347Z"}
{"parentUuid":"1e428703-ea4e-4264-981b-a7cbcc4483d6","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","slug":"glittery-skipping-rivest","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_018JGsUenTTYDJhN1nA3y1nN","type":"tool_result","content":"The file /Users/simon/Dropbox/dev/tools/claude-code-timeline.html has been updated. Here's the result of running `cat -n` on a snippet of the edited file:\n 1080→ hideThinking: document.getElementById('hide-thinking'),\n 1081→ statsShowing: document.getElementById('stats-showing'),\n 1082→ statsTotal: document.getElementById('stats-total'),\n 1083→ statsRange: document.getElementById('stats-range'),\n 1084→ clearFilters: document.getElementById('clear-filters'),\n 1085→ imageModal: document.getElementById('image-modal'),\n 1086→ imageModalImg: document.getElementById('image-modal-img'),\n 1087→ imageModalClose: document.getElementById('image-modal-close'),\n 1088→ showPrompts: document.getElementById('show-prompts'),\n 1089→ promptsModal: document.getElementById('prompts-modal'),\n 1090→ promptsClose: document.getElementById('prompts-close'),\n 1091→ promptsText: document.getElementById('prompts-text'),\n 1092→ promptsCopy: document.getElementById('prompts-copy'),\n 1093→ };\n 1094→\n 1095→ const state = {\n 1096→ sourceLabel: null,\n 1097→ tzMode: 'local',"}]},"uuid":"7fd83add-e527-45f0-9105-26c3aa344aae","timestamp":"2025-12-18T05:52:20.385Z","toolUseResult":{"filePath":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","oldString":" clearFilters: document.getElementById('clear-filters'),\n imageModal: document.getElementById('image-modal'),\n imageModalImg: document.getElementById('image-modal-img'),\n imageModalClose: document.getElementById('image-modal-close'),\n copyPrompts: document.getElementById('copy-prompts'),\n };","newString":" clearFilters: document.getElementById('clear-filters'),\n imageModal: document.getElementById('image-modal'),\n imageModalImg: document.getElementById('image-modal-img'),\n imageModalClose: document.getElementById('image-modal-close'),\n showPrompts: document.getElementById('show-prompts'),\n promptsModal: document.getElementById('prompts-modal'),\n promptsClose: document.getElementById('prompts-close'),\n promptsText: document.getElementById('prompts-text'),\n promptsCopy: document.getElementById('prompts-copy'),\n };","originalFile":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>Claude Code Timeline Viewer</title>\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js\" integrity=\"sha384-osZDKVu4ipZP703HmPOhWdyBajcFyjX2Psjk//TG1Rc0AdwEtuToaylrmcK3LdAl\" crossorigin=\"anonymous\"></script>\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/markdown-it/4.4.0/markdown-it.min.js\" integrity=\"sha384-cxGp+QDeMxACP73aA0Lj0Sc/h4z1X73+umcvCyI0Vbyh3OYxgrPI+0mxG9slsMTN\" crossorigin=\"anonymous\"></script>\n <style>\n :root {\n color-scheme: light dark;\n --bg: #0b0c0f;\n --panel: #12141a;\n --panel-2: #0f1116;\n --text: #e8eaf0;\n --muted: #a6adbf;\n --line: rgba(255, 255, 255, 0.10);\n --line-2: rgba(255, 255, 255, 0.16);\n --accent: #6aa9ff;\n --accent-2: #8a5cff;\n --good: #46d39a;\n --warn: #ffcc66;\n --bad: #ff6b6b;\n --shadow: 0 18px 50px rgba(0, 0, 0, 0.35);\n --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\",\n \"Courier New\", monospace;\n --sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial,\n \"Apple Color Emoji\", \"Segoe UI Emoji\";\n }\n\n @media (prefers-color-scheme: light) {\n :root {\n --bg: #f7f8fc;\n --panel: #ffffff;\n --panel-2: #ffffff;\n --text: #10121a;\n --muted: #5b647a;\n --line: rgba(16, 18, 26, 0.12);\n --line-2: rgba(16, 18, 26, 0.18);\n --shadow: 0 18px 50px rgba(16, 18, 26, 0.10);\n }\n }\n\n * { box-sizing: border-box; }\n html, body { height: 100%; }\n body {\n margin: 0;\n font-family: var(--sans);\n background: radial-gradient(1200px 600px at 10% -10%, rgba(106, 169, 255, 0.18), transparent 45%),\n radial-gradient(1200px 600px at 90% -20%, rgba(138, 92, 255, 0.16), transparent 50%),\n var(--bg);\n color: var(--text);\n }\n\n .wrap {\n max-width: none;\n margin: 0;\n padding: 22px 16px 42px;\n }\n\n header {\n display: grid;\n gap: 10px;\n margin-bottom: 14px;\n }\n\n h1 {\n margin: 0;\n font-size: clamp(22px, 3vw, 32px);\n letter-spacing: 0.2px;\n }\n\n .sub {\n margin: 6px 0 0;\n color: var(--muted);\n line-height: 1.35;\n max-width: 70ch;\n }\n\n .controls {\n display: flex;\n flex-wrap: wrap;\n gap: 10px;\n align-items: center;\n justify-content: flex-start;\n }\n\n .controls .field {\n display: grid;\n gap: 4px;\n min-width: 180px;\n }\n\n .label {\n font-size: 12px;\n color: var(--muted);\n }\n\n input[type=\"search\"], input[type=\"url\"], select, textarea {\n font: inherit;\n color: inherit;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n border-radius: 10px;\n padding: 10px 11px;\n outline: none;\n }\n\n input[type=\"search\"]:focus, input[type=\"url\"]:focus, select:focus, textarea:focus {\n border-color: color-mix(in srgb, var(--accent) 60%, var(--line));\n box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);\n }\n\n .segmented {\n display: inline-flex;\n border: 1px solid var(--line);\n border-radius: 999px;\n overflow: hidden;\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n }\n\n .segmented button {\n border: none;\n padding: 9px 12px;\n font: inherit;\n background: transparent;\n color: var(--muted);\n cursor: pointer;\n min-width: 74px;\n }\n\n .segmented button.active {\n background: color-mix(in srgb, var(--accent) 16%, transparent);\n color: var(--text);\n }\n\n .segmented button:focus-visible {\n outline: 2px solid color-mix(in srgb, var(--accent) 70%, transparent);\n outline-offset: -2px;\n }\n\n .btn {\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n color: var(--text);\n border-radius: 10px;\n padding: 10px 12px;\n font: inherit;\n cursor: pointer;\n transition: transform 80ms ease, border-color 120ms ease;\n }\n\n .btn:hover { border-color: var(--line-2); }\n .btn:active { transform: translateY(1px); }\n .btn.primary {\n border-color: color-mix(in srgb, var(--accent) 55%, var(--line));\n background: color-mix(in srgb, var(--accent) 14%, transparent);\n }\n\n .btn:disabled {\n opacity: 0.55;\n cursor: not-allowed;\n }\n\n .panel {\n background: var(--panel);\n border: 1px solid var(--line);\n border-radius: 16px;\n box-shadow: var(--shadow);\n }\n\n .input-panel {\n padding: 14px;\n margin-bottom: 14px;\n }\n\n .filters-panel {\n padding: 14px;\n margin-bottom: 14px;\n }\n\n .input-grid {\n display: grid;\n grid-template-columns: 1fr;\n gap: 14px;\n align-items: stretch;\n }\n\n @media (min-width: 920px) {\n .input-grid {\n grid-template-columns: 380px 1fr;\n }\n }\n\n .dropzone {\n border: 1px dashed color-mix(in srgb, var(--accent) 40%, var(--line));\n border-radius: 14px;\n background: linear-gradient(\n 135deg,\n color-mix(in srgb, var(--accent) 12%, transparent),\n transparent 45%,\n color-mix(in srgb, var(--accent-2) 10%, transparent)\n );\n padding: 16px;\n display: grid;\n gap: 6px;\n cursor: pointer;\n min-height: 124px;\n align-content: center;\n }\n\n .dropzone.dragover {\n border-style: solid;\n border-color: color-mix(in srgb, var(--accent) 70%, var(--line));\n box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);\n }\n\n .dropzone strong { font-size: 15px; }\n .muted { color: var(--muted); }\n .tiny { font-size: 12px; color: var(--muted); }\n\n .tabs {\n display: flex;\n gap: 8px;\n margin-bottom: 10px;\n flex-wrap: wrap;\n align-items: center;\n }\n\n .tab {\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel-2) 94%, transparent);\n color: var(--muted);\n border-radius: 999px;\n padding: 7px 10px;\n cursor: pointer;\n font: inherit;\n }\n\n .tab.active {\n color: var(--text);\n border-color: color-mix(in srgb, var(--accent) 55%, var(--line));\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n }\n\n textarea {\n width: 100%;\n min-height: 190px;\n resize: vertical;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.4;\n }\n\n .row {\n display: flex;\n gap: 10px;\n flex-wrap: wrap;\n align-items: center;\n margin-top: 10px;\n }\n\n .status {\n margin-top: 12px;\n padding: 10px 12px;\n border-radius: 12px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel-2) 94%, transparent);\n display: none;\n white-space: pre-wrap;\n word-break: break-word;\n }\n .status.visible { display: block; }\n .status.error { border-color: color-mix(in srgb, var(--bad) 60%, var(--line)); }\n .status.good { border-color: color-mix(in srgb, var(--good) 55%, var(--line)); }\n .status.warn { border-color: color-mix(in srgb, var(--warn) 55%, var(--line)); }\n\n main {\n display: grid;\n gap: 14px;\n grid-template-columns: 1fr;\n min-height: 560px;\n }\n\n @media (min-width: 1060px) {\n main {\n grid-template-columns: 1fr 520px;\n align-items: stretch;\n }\n }\n\n .timeline-panel {\n display: grid;\n grid-template-rows: auto 1fr;\n min-height: 560px;\n }\n\n .stats {\n display: flex;\n gap: 12px;\n flex-wrap: wrap;\n align-items: baseline;\n justify-content: space-between;\n padding: 12px 14px;\n border-bottom: 1px solid var(--line);\n }\n\n .stats .big {\n font-size: 14px;\n font-weight: 600;\n }\n\n .timeline {\n overflow: auto;\n padding: 8px 0;\n }\n\n .timeline:focus-visible {\n outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent);\n outline-offset: -2px;\n border-radius: 14px;\n }\n\n .day-sep {\n position: sticky;\n top: 0;\n z-index: 2;\n padding: 8px 14px;\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n border-top: 1px solid var(--line);\n border-bottom: 1px solid var(--line);\n font-size: 12px;\n color: var(--muted);\n backdrop-filter: blur(6px);\n }\n\n .event {\n display: grid;\n grid-template-columns: 176px 1fr;\n gap: 12px;\n padding: 10px 14px;\n border-bottom: 1px solid color-mix(in srgb, var(--line) 85%, transparent);\n cursor: pointer;\n }\n\n .event:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); }\n .event.selected {\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n outline: 2px solid color-mix(in srgb, var(--accent) 35%, transparent);\n outline-offset: -2px;\n }\n\n .time {\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.35;\n color: var(--muted);\n display: grid;\n gap: 3px;\n }\n\n .time .ts {\n color: var(--text);\n font-weight: 600;\n }\n\n .badges {\n display: flex;\n gap: 6px;\n flex-wrap: wrap;\n align-items: center;\n margin-bottom: 6px;\n }\n\n .badge {\n font-size: 11px;\n font-family: var(--mono);\n padding: 2px 7px;\n border-radius: 999px;\n border: 1px solid var(--line);\n color: var(--muted);\n background: color-mix(in srgb, var(--panel-2) 90%, transparent);\n }\n\n .badge.k-user { border-color: color-mix(in srgb, #00c8ff 55%, var(--line)); color: color-mix(in srgb, #00c8ff 78%, var(--text)); }\n .badge.k-assistant { border-color: color-mix(in srgb, #ff73c4 55%, var(--line)); color: color-mix(in srgb, #ff73c4 78%, var(--text)); }\n .badge.k-file-history-snapshot { border-color: color-mix(in srgb, var(--warn) 55%, var(--line)); color: color-mix(in srgb, var(--warn) 75%, var(--text)); }\n\n .badge.ct-text { border-color: color-mix(in srgb, var(--good) 55%, var(--line)); color: color-mix(in srgb, var(--good) 75%, var(--text)); }\n .badge.ct-thinking { border-color: color-mix(in srgb, var(--accent-2) 55%, var(--line)); color: color-mix(in srgb, var(--accent-2) 75%, var(--text)); }\n .badge.ct-tool_use { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); color: color-mix(in srgb, var(--accent) 75%, var(--text)); }\n .badge.ct-tool_result { border-color: color-mix(in srgb, #8690a8 55%, var(--line)); color: color-mix(in srgb, #8690a8 70%, var(--text)); }\n .badge.ct-image { border-color: color-mix(in srgb, #ff9f43 55%, var(--line)); color: color-mix(in srgb, #ff9f43 75%, var(--text)); }\n\n .badge.role-user { border-color: color-mix(in srgb, #00c8ff 55%, var(--line)); color: color-mix(in srgb, #00c8ff 78%, var(--text)); }\n .badge.role-assistant { border-color: color-mix(in srgb, #ff73c4 55%, var(--line)); color: color-mix(in srgb, #ff73c4 78%, var(--text)); }\n\n .summary {\n font-size: 13px;\n line-height: 1.35;\n color: var(--text);\n word-break: break-word;\n }\n\n .summary code {\n font-family: var(--mono);\n font-size: 12px;\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n padding: 2px 5px;\n border-radius: 7px;\n }\n\n .detail {\n display: grid;\n grid-template-rows: auto auto auto auto 1fr;\n min-height: 560px;\n }\n\n @media (min-width: 1060px) {\n .detail {\n position: sticky;\n top: 16px;\n align-self: start;\n max-height: calc(100vh - 32px);\n }\n }\n\n .detail.inline {\n position: static;\n top: auto;\n align-self: auto;\n max-height: none;\n min-height: 0;\n margin: 8px 14px 12px;\n box-shadow: none;\n }\n\n .detail.inline pre {\n max-height: 52vh;\n }\n\n .detail-head {\n padding: 12px 14px;\n border-bottom: 1px solid var(--line);\n display: grid;\n gap: 8px;\n }\n\n .detail-title {\n font-size: 14px;\n font-weight: 700;\n line-height: 1.25;\n margin: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .detail-actions {\n display: flex;\n gap: 8px;\n flex-wrap: wrap;\n align-items: center;\n justify-content: flex-start;\n }\n\n .check {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n color: var(--muted);\n font-size: 12px;\n user-select: none;\n }\n\n pre {\n margin: 0;\n padding: 12px 14px 16px;\n overflow: auto;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.45;\n color: color-mix(in srgb, var(--text) 92%, transparent);\n background: transparent;\n white-space: pre-wrap;\n word-break: break-word;\n }\n\n .detail-images {\n display: flex;\n gap: 10px;\n padding: 12px 14px;\n border-bottom: 1px solid var(--line);\n overflow-x: auto;\n overscroll-behavior-x: contain;\n }\n\n .thumb {\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel-2) 92%, transparent);\n border-radius: 12px;\n padding: 0;\n cursor: pointer;\n width: 78px;\n height: 58px;\n overflow: hidden;\n flex: 0 0 auto;\n display: grid;\n place-items: stretch;\n }\n\n .thumb:hover { border-color: var(--line-2); }\n\n .thumb img {\n width: 100%;\n height: 100%;\n display: block;\n object-fit: cover;\n }\n\n .image-modal {\n position: fixed;\n inset: 0;\n z-index: 9999;\n background: rgba(0, 0, 0, 0.68);\n display: none;\n padding: 18px;\n place-items: center;\n }\n\n .image-modal.open { display: grid; }\n\n .image-modal .dialog {\n position: relative;\n border-radius: 16px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 98%, transparent);\n box-shadow: var(--shadow);\n padding: 14px;\n max-width: min(1200px, 96vw);\n max-height: 92vh;\n display: grid;\n place-items: center;\n }\n\n .image-modal img {\n display: block;\n max-width: min(1140px, 92vw);\n max-height: 84vh;\n width: auto;\n height: auto;\n object-fit: contain;\n border-radius: 10px;\n background: color-mix(in srgb, var(--panel-2) 92%, transparent);\n }\n\n .image-modal .close {\n position: absolute;\n top: 10px;\n right: 10px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n color: var(--text);\n border-radius: 999px;\n padding: 8px 10px;\n font: inherit;\n cursor: pointer;\n }\n\n .image-modal .close:hover { border-color: var(--line-2); }\n\n .empty {\n padding: 14px;\n color: var(--muted);\n font-size: 13px;\n line-height: 1.35;\n }\n\n /* Pretty view styles */\n .detail-pretty {\n padding: 14px;\n border-bottom: 1px solid var(--line);\n overflow: auto;\n max-height: 50vh;\n }\n\n .detail.inline .detail-pretty {\n max-height: 40vh;\n }\n\n .pretty-section {\n margin-bottom: 14px;\n }\n\n .pretty-section:last-child {\n margin-bottom: 0;\n }\n\n .pretty-label {\n font-size: 11px;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n color: var(--muted);\n margin-bottom: 6px;\n }\n\n .pretty-content {\n font-size: 13px;\n line-height: 1.5;\n }\n\n .pretty-content p {\n margin: 0 0 10px;\n }\n\n .pretty-content p:last-child {\n margin-bottom: 0;\n }\n\n .pretty-content h1, .pretty-content h2, .pretty-content h3,\n .pretty-content h4, .pretty-content h5, .pretty-content h6 {\n margin: 14px 0 8px;\n font-weight: 600;\n line-height: 1.3;\n }\n\n .pretty-content h1:first-child, .pretty-content h2:first-child,\n .pretty-content h3:first-child {\n margin-top: 0;\n }\n\n .pretty-content h1 { font-size: 1.4em; }\n .pretty-content h2 { font-size: 1.25em; }\n .pretty-content h3 { font-size: 1.1em; }\n\n .pretty-content ul, .pretty-content ol {\n margin: 8px 0;\n padding-left: 1.5em;\n list-style-position: outside;\n }\n\n .pretty-content li {\n margin: 4px 0;\n }\n\n .thinking-block ul, .thinking-block ol {\n padding-left: 2em;\n }\n\n .pretty-content code {\n font-family: var(--mono);\n font-size: 0.9em;\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n padding: 2px 5px;\n border-radius: 5px;\n }\n\n .pretty-content pre {\n margin: 10px 0;\n padding: 12px;\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n border-radius: 10px;\n overflow-x: auto;\n }\n\n .pretty-content pre code {\n background: none;\n border: none;\n padding: 0;\n font-size: 12px;\n }\n\n .pretty-content blockquote {\n margin: 10px 0;\n padding: 8px 12px;\n border-left: 3px solid var(--accent);\n background: color-mix(in srgb, var(--accent) 8%, transparent);\n border-radius: 0 8px 8px 0;\n }\n\n .pretty-content a {\n color: var(--accent);\n text-decoration: none;\n }\n\n .pretty-content a:hover {\n text-decoration: underline;\n }\n\n .pretty-content strong {\n font-weight: 600;\n }\n\n .pretty-content em {\n font-style: italic;\n }\n\n .pretty-content table {\n border-collapse: collapse;\n margin: 10px 0;\n width: 100%;\n font-size: 13px;\n }\n\n .pretty-content th,\n .pretty-content td {\n border: 1px solid var(--line);\n padding: 8px 10px;\n text-align: left;\n }\n\n .pretty-content th {\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n font-weight: 600;\n }\n\n .pretty-content tr:hover {\n background: color-mix(in srgb, var(--accent) 6%, transparent);\n }\n\n .command-block {\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n border-radius: 10px;\n overflow: hidden;\n margin: 8px 0;\n }\n\n .command-header {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 8px 12px;\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n border-bottom: 1px solid var(--line);\n font-size: 12px;\n }\n\n .command-header .tool-name {\n font-weight: 600;\n color: var(--accent);\n }\n\n .command-header .tool-desc {\n color: var(--muted);\n }\n\n .command-input {\n padding: 10px 12px;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.4;\n white-space: pre-wrap;\n word-break: break-word;\n border-bottom: 1px solid var(--line);\n }\n\n .command-input::before {\n content: '$ ';\n color: var(--good);\n font-weight: 600;\n }\n\n .command-output {\n padding: 10px 12px;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.4;\n white-space: pre-wrap;\n word-break: break-word;\n max-height: 300px;\n overflow: auto;\n color: var(--muted);\n }\n\n .file-path {\n font-family: var(--mono);\n font-size: 12px;\n padding: 6px 10px;\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n border-radius: 8px;\n display: inline-block;\n margin: 4px 0;\n }\n\n .tool-params {\n display: grid;\n gap: 8px;\n margin-top: 8px;\n }\n\n .tool-param {\n display: grid;\n grid-template-columns: auto 1fr;\n gap: 8px;\n align-items: start;\n font-size: 12px;\n }\n\n .tool-param-name {\n font-family: var(--mono);\n font-weight: 600;\n color: var(--accent);\n padding: 2px 6px;\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n border-radius: 4px;\n }\n\n .tool-param-value {\n font-family: var(--mono);\n white-space: pre-wrap;\n word-break: break-word;\n padding: 2px 0;\n }\n\n .thinking-block {\n background: color-mix(in srgb, var(--accent-2) 10%, transparent);\n border: 1px solid color-mix(in srgb, var(--accent-2) 30%, var(--line));\n border-radius: 10px;\n padding: 12px 16px;\n font-size: 13px;\n line-height: 1.5;\n }\n\n .result-block {\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n border-radius: 10px;\n padding: 12px;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.4;\n white-space: pre-wrap;\n word-break: break-word;\n max-height: 400px;\n overflow: auto;\n }\n\n .result-error {\n border-color: color-mix(in srgb, var(--bad) 50%, var(--line));\n background: color-mix(in srgb, var(--bad) 8%, transparent);\n }\n\n .json-toggle {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 8px 14px;\n border-bottom: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel-2) 50%, transparent);\n }\n\n .json-toggle-label {\n font-size: 12px;\n color: var(--muted);\n font-weight: 500;\n }\n\n .prompts-modal {\n position: fixed;\n inset: 0;\n z-index: 80;\n background: rgba(0, 0, 0, 0.68);\n display: none;\n padding: 18px;\n place-items: center;\n }\n\n .prompts-modal.open { display: grid; }\n\n .prompts-modal .dialog {\n position: relative;\n border-radius: 16px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 98%, transparent);\n box-shadow: var(--shadow);\n padding: 14px;\n width: min(1100px, 96vw);\n height: min(86vh, 780px);\n display: grid;\n grid-template-rows: auto 1fr auto;\n gap: 10px;\n }\n\n .prompts-head {\n display: flex;\n gap: 10px;\n align-items: center;\n justify-content: space-between;\n }\n\n .prompts-title {\n font-weight: 700;\n letter-spacing: 0.2px;\n }\n\n .prompts-modal textarea {\n width: 100%;\n height: 100%;\n resize: none;\n border-radius: 12px;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.45;\n padding: 12px;\n }\n\n .prompts-actions {\n display: flex;\n gap: 10px;\n align-items: center;\n justify-content: flex-end;\n flex-wrap: wrap;\n }\n </style>\n</head>\n <body>\n <div class=\"wrap\">\n <header>\n <div>\n <h1>Claude Code Timeline Viewer</h1>\n <p class=\"sub\">Open a Claude Code session <code>.jsonl</code> file and explore it as an interactive timeline. Times are shown in your local timezone by default. Find sessions in <code>~/.claude/projects/</code>.</p>\n </div>\n </header>\n\n <section class=\"panel input-panel\" aria-label=\"Input\">\n <div class=\"input-grid\">\n <div>\n <div id=\"dropzone\" class=\"dropzone\" tabindex=\"0\">\n <strong>Drop a <code>.jsonl</code> file here</strong>\n <div class=\"muted\">or click to choose a file</div>\n <div class=\"tiny\">Supported: file picker, drag-and-drop, paste, or URL fetch (CORS-enabled).</div>\n <input id=\"file-input\" type=\"file\" accept=\".jsonl,application/json,text/plain\" hidden />\n </div>\n </div>\n <div>\n <div class=\"tabs\" role=\"tablist\" aria-label=\"Input tabs\">\n <button type=\"button\" class=\"tab active\" data-tab=\"paste\" aria-selected=\"true\">Paste</button>\n <button type=\"button\" class=\"tab\" data-tab=\"url\" aria-selected=\"false\">URL</button>\n </div>\n\n <div id=\"panel-paste\" data-panel=\"paste\">\n <textarea id=\"paste-input\" placeholder=\"Paste JSONL content here...\"></textarea>\n <div class=\"row\">\n <button type=\"button\" class=\"btn primary\" id=\"render-paste\">Render pasted content</button>\n <div class=\"tiny\">Tip: the timeline uses one JSON object per line.</div>\n </div>\n </div>\n\n <div id=\"panel-url\" data-panel=\"url\" hidden>\n <input id=\"url-input\" type=\"url\" placeholder=\"https://example.com/session.jsonl\" />\n <div class=\"row\">\n <button type=\"button\" class=\"btn primary\" id=\"fetch-url\">Fetch and render</button>\n <div class=\"tiny\">The URL must allow cross-origin requests (CORS).</div>\n </div>\n </div>\n </div>\n </div>\n\n <div id=\"status\" class=\"status\" role=\"status\" aria-live=\"polite\"></div>\n </section>\n\n <section class=\"panel filters-panel\" aria-label=\"Filters\">\n <div class=\"controls\" aria-label=\"Timeline controls\">\n <div class=\"field\" style=\"min-width: 170px\">\n <div class=\"label\">Timezone</div>\n <div class=\"segmented\" role=\"group\" aria-label=\"Timezone toggle\">\n <button type=\"button\" id=\"tz-local\" class=\"active\">Local</button>\n <button type=\"button\" id=\"tz-utc\">UTC</button>\n </div>\n </div>\n <div class=\"field\" style=\"min-width: 220px\">\n <div class=\"label\">Search</div>\n <input id=\"search\" type=\"search\" placeholder=\"message, tool name, cwd...\" autocomplete=\"off\" />\n </div>\n <div class=\"field\" style=\"min-width: 190px\">\n <div class=\"label\">Type</div>\n <select id=\"type-filter\"></select>\n </div>\n <div class=\"field\" style=\"min-width: 210px\">\n <div class=\"label\">Content Type</div>\n <select id=\"content-type-filter\"></select>\n </div>\n <div class=\"field\" style=\"min-width: 160px\">\n <div class=\"label\">Role</div>\n <select id=\"role-filter\"></select>\n </div>\n <label class=\"check\" title=\"Hide thinking blocks (can be verbose)\">\n <input type=\"checkbox\" id=\"hide-thinking\" />\n Hide thinking\n </label>\n <button type=\"button\" class=\"btn\" id=\"clear-filters\">Clear</button>\n </div>\n </section>\n\n <div class=\"controls\" style=\"margin-bottom: 14px;\">\n <button type=\"button\" class=\"btn\" id=\"show-prompts\" disabled>Show prompts</button>\n </div>\n\n <main>\n <section class=\"panel timeline-panel\" aria-label=\"Timeline\">\n <div class=\"stats\">\n <div class=\"big\"><span id=\"stats-showing\">0</span> <span class=\"muted\">showing</span> <span class=\"muted\">/</span> <span id=\"stats-total\">0</span> <span class=\"muted\">events</span></div>\n <div class=\"muted\" id=\"stats-range\">-</div>\n </div>\n <div id=\"timeline\" class=\"timeline\" role=\"list\" tabindex=\"0\" aria-label=\"Events list\"></div>\n </section>\n\n <aside class=\"panel detail\" aria-label=\"Details\">\n <div class=\"detail-head\">\n <div class=\"detail-title\" id=\"detail-title\">Select an event</div>\n <div class=\"detail-actions\">\n <button type=\"button\" class=\"btn\" id=\"copy-json\" disabled>Copy JSON</button>\n <button type=\"button\" class=\"btn\" id=\"copy-raw\" disabled>Copy raw line</button>\n <label class=\"check\" title=\"Truncate very long strings in the JSON preview\">\n <input type=\"checkbox\" id=\"truncate-strings\" checked />\n Truncate long strings\n </label>\n </div>\n </div>\n <div id=\"detail-images\" class=\"detail-images\" hidden aria-label=\"Event images\"></div>\n <div id=\"detail-pretty\" class=\"detail-pretty\" hidden aria-label=\"Formatted event view\"></div>\n <div class=\"json-toggle\">\n <span class=\"json-toggle-label\">Raw JSON</span>\n </div>\n <pre id=\"detail-json\" aria-label=\"Selected event JSON\" class=\"empty\">Load a file to get started.</pre>\n </aside>\n </main>\n </div>\n\n <div id=\"image-modal\" class=\"image-modal\" role=\"dialog\" aria-modal=\"true\" aria-hidden=\"true\">\n <div class=\"dialog\">\n <button type=\"button\" class=\"close\" id=\"image-modal-close\" aria-label=\"Close image\">Close</button>\n <img id=\"image-modal-img\" alt=\"\" />\n </div>\n </div>\n\n <div id=\"prompts-modal\" class=\"prompts-modal\" role=\"dialog\" aria-modal=\"true\" aria-hidden=\"true\" aria-label=\"User prompts\">\n <div class=\"dialog\">\n <div class=\"prompts-head\">\n <div class=\"prompts-title\">User prompts</div>\n <button type=\"button\" class=\"btn\" id=\"prompts-close\" aria-label=\"Close prompts\">Close</button>\n </div>\n <textarea id=\"prompts-text\" readonly spellcheck=\"false\"></textarea>\n <div class=\"prompts-actions\">\n <button type=\"button\" class=\"btn primary\" id=\"prompts-copy\">Copy</button>\n </div>\n </div>\n </div>\n\n <script>\n const els = {\n dropzone: document.getElementById('dropzone'),\n fileInput: document.getElementById('file-input'),\n pasteInput: document.getElementById('paste-input'),\n renderPaste: document.getElementById('render-paste'),\n urlInput: document.getElementById('url-input'),\n fetchUrl: document.getElementById('fetch-url'),\n status: document.getElementById('status'),\n timeline: document.getElementById('timeline'),\n detailPanel: document.querySelector('aside.detail'),\n detailTitle: document.getElementById('detail-title'),\n detailImages: document.getElementById('detail-images'),\n detailPretty: document.getElementById('detail-pretty'),\n detailJson: document.getElementById('detail-json'),\n copyJson: document.getElementById('copy-json'),\n copyRaw: document.getElementById('copy-raw'),\n truncateStrings: document.getElementById('truncate-strings'),\n tzLocal: document.getElementById('tz-local'),\n tzUtc: document.getElementById('tz-utc'),\n search: document.getElementById('search'),\n typeFilter: document.getElementById('type-filter'),\n contentTypeFilter: document.getElementById('content-type-filter'),\n roleFilter: document.getElementById('role-filter'),\n hideThinking: document.getElementById('hide-thinking'),\n statsShowing: document.getElementById('stats-showing'),\n statsTotal: document.getElementById('stats-total'),\n statsRange: document.getElementById('stats-range'),\n clearFilters: document.getElementById('clear-filters'),\n imageModal: document.getElementById('image-modal'),\n imageModalImg: document.getElementById('image-modal-img'),\n imageModalClose: document.getElementById('image-modal-close'),\n copyPrompts: document.getElementById('copy-prompts'),\n };\n\n const state = {\n sourceLabel: null,\n tzMode: 'local',\n events: [],\n filtered: [],\n selectedId: null,\n selected: null,\n filters: {\n query: '',\n type: 'all',\n contentType: 'all',\n role: 'all',\n hideThinking: false,\n },\n };\n\n let lastActiveElementBeforeModal = null;\n let lastBodyOverflow = null;\n let suppressUrlStateSync = false;\n const wideLayoutMq = window.matchMedia('(min-width: 1060px)');\n const detailDock = document.createElement('div');\n detailDock.id = 'detail-dock';\n detailDock.style.display = 'none';\n els.detailPanel.parentElement.insertBefore(detailDock, els.detailPanel);\n\n function updateDetailPlacement() {\n const inline = !wideLayoutMq.matches;\n\n if (!inline) {\n if (els.detailPanel.previousSibling !== detailDock) detailDock.after(els.detailPanel);\n els.detailPanel.classList.remove('inline');\n return;\n }\n\n const selectedNode = els.timeline.querySelector('.event.selected');\n if (!selectedNode) {\n if (els.detailPanel.previousSibling !== detailDock) detailDock.after(els.detailPanel);\n els.detailPanel.classList.remove('inline');\n return;\n }\n\n if (selectedNode.nextSibling !== els.detailPanel) selectedNode.after(els.detailPanel);\n els.detailPanel.classList.add('inline');\n }\n\n function setStatus(message, kind = 'good') {\n if (!message) {\n els.status.className = 'status';\n els.status.textContent = '';\n return;\n }\n els.status.className = `status visible ${kind}`;\n els.status.textContent = message;\n }\n\n function pad2(number) {\n return String(number).padStart(2, '0');\n }\n\n function pad3(number) {\n return String(number).padStart(3, '0');\n }\n\n function getOffsetString(date) {\n const offsetMinutes = -date.getTimezoneOffset();\n const sign = offsetMinutes >= 0 ? '+' : '-';\n const abs = Math.abs(offsetMinutes);\n const hours = Math.floor(abs / 60);\n const minutes = abs % 60;\n return `${sign}${pad2(hours)}:${pad2(minutes)}`;\n }\n\n function formatTimestamp(date, tzMode) {\n if (!(date instanceof Date) || Number.isNaN(date.getTime())) return '-';\n if (tzMode === 'utc') {\n return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ` +\n `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}:${pad2(date.getUTCSeconds())}.` +\n `${pad3(date.getUTCMilliseconds())}Z`;\n }\n return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ` +\n `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}.` +\n `${pad3(date.getMilliseconds())} ${getOffsetString(date)}`;\n }\n\n function dayKey(date, tzMode) {\n if (!(date instanceof Date) || Number.isNaN(date.getTime())) return '-';\n if (tzMode === 'utc') {\n return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())}`;\n }\n return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;\n }\n\n function formatDelta(ms) {\n if (typeof ms !== 'number' || !Number.isFinite(ms)) return '';\n const abs = Math.abs(ms);\n const sign = ms < 0 ? '-' : '+';\n if (abs < 1000) return `${sign}${Math.round(abs)}ms`;\n if (abs < 60_000) return `${sign}${(abs / 1000).toFixed(abs < 10_000 ? 2 : 1)}s`;\n if (abs < 3_600_000) {\n const m = Math.floor(abs / 60_000);\n const s = Math.floor((abs % 60_000) / 1000);\n return `${sign}${m}m ${pad2(s)}s`;\n }\n if (abs < 86_400_000) {\n const h = Math.floor(abs / 3_600_000);\n const m = Math.floor((abs % 3_600_000) / 60_000);\n return `${sign}${h}h ${m}m`;\n }\n const d = Math.floor(abs / 86_400_000);\n const h = Math.floor((abs % 86_400_000) / 3_600_000);\n return `${sign}${d}d ${h}h`;\n }\n\n function safeJsonParse(text) {\n try {\n return { ok: true, value: JSON.parse(text) };\n } catch (error) {\n return { ok: false, error };\n }\n }\n\n function extractMessageText(message) {\n if (!message || typeof message !== 'object') return null;\n const content = message.content;\n if (typeof content === 'string') return content;\n if (!Array.isArray(content)) return null;\n const parts = [];\n for (const item of content) {\n if (!item || typeof item !== 'object') continue;\n if (typeof item.text === 'string') parts.push(item.text);\n else if (typeof item.thinking === 'string') parts.push(item.thinking);\n }\n const joined = parts.join('');\n return joined || null;\n }\n\n function getContentTypes(message) {\n if (!message || typeof message !== 'object') return [];\n const content = message.content;\n if (typeof content === 'string') return ['text'];\n if (!Array.isArray(content)) return [];\n const types = new Set();\n for (const item of content) {\n if (!item || typeof item !== 'object') continue;\n if (item.type) types.add(item.type);\n }\n return Array.from(types);\n }\n\n function firstLine(text) {\n if (typeof text !== 'string') return null;\n const trimmed = text.trim();\n if (!trimmed) return null;\n const idx = trimmed.indexOf('\\n');\n return idx === -1 ? trimmed : trimmed.slice(0, idx).trim();\n }\n\n function normalizeSummaryText(text, maxLen = 260) {\n if (typeof text !== 'string') return '';\n const singleLine = text.replace(/\\s+/g, ' ').trim();\n if (singleLine.length <= maxLen) return singleLine;\n return singleLine.slice(0, maxLen - 1) + '...';\n }\n\n function summarizeEvent(obj) {\n const kind = obj?.type || '';\n const message = obj?.message;\n\n if (kind === 'file-history-snapshot') {\n const msgId = obj?.messageId ? `msg=${obj.messageId.slice(0, 8)}...` : null;\n return normalizeSummaryText(msgId || 'file-history-snapshot');\n }\n\n if (kind === 'user' || kind === 'assistant') {\n const role = message?.role || kind;\n const content = message?.content;\n\n if (typeof content === 'string') {\n return normalizeSummaryText(`${role}: ${content}`);\n }\n\n if (Array.isArray(content) && content.length > 0) {\n for (const item of content) {\n if (item?.type === 'text' && item.text) {\n return normalizeSummaryText(`${role}: ${item.text}`);\n }\n if (item?.type === 'thinking' && item.thinking) {\n return normalizeSummaryText(`${role} (thinking): ${item.thinking}`);\n }\n if (item?.type === 'tool_use') {\n const name = item.name || 'tool';\n const input = item.input;\n if (name === 'Bash' && input?.command) {\n return normalizeSummaryText(`${name}: ${input.command}`);\n }\n if (name === 'Read' && input?.file_path) {\n return normalizeSummaryText(`${name}: ${input.file_path}`);\n }\n if (name === 'Write' && input?.file_path) {\n return normalizeSummaryText(`${name}: ${input.file_path}`);\n }\n if (name === 'Edit' && input?.file_path) {\n return normalizeSummaryText(`${name}: ${input.file_path}`);\n }\n if (name === 'Glob' && input?.pattern) {\n return normalizeSummaryText(`${name}: ${input.pattern}`);\n }\n if (name === 'Grep' && input?.pattern) {\n return normalizeSummaryText(`${name}: ${input.pattern}`);\n }\n if (name === 'Task' && input?.description) {\n return normalizeSummaryText(`${name}: ${input.description}`);\n }\n if (input && typeof input === 'object') {\n const keys = Object.keys(input).slice(0, 3);\n return normalizeSummaryText(`${name}(${keys.join(', ')}${Object.keys(input).length > 3 ? ', ...' : ''})`);\n }\n return normalizeSummaryText(`${name}()`);\n }\n if (item?.type === 'tool_result') {\n const toolUseId = item.tool_use_id ? item.tool_use_id.slice(0, 12) : 'result';\n const resultContent = item.content;\n if (typeof resultContent === 'string') {\n return normalizeSummaryText(`tool_result: ${firstLine(resultContent) || toolUseId}`);\n }\n if (Array.isArray(resultContent)) {\n for (const rc of resultContent) {\n if (rc?.type === 'text' && rc.text) {\n return normalizeSummaryText(`tool_result: ${firstLine(rc.text) || toolUseId}`);\n }\n if (rc?.type === 'image') {\n return normalizeSummaryText(`tool_result: [image]`);\n }\n }\n }\n return normalizeSummaryText(`tool_result: ${toolUseId}`);\n }\n if (item?.type === 'image') {\n return normalizeSummaryText(`${role}: [image]`);\n }\n }\n return normalizeSummaryText(`${role}: [${content.length} content items]`);\n }\n\n return kind;\n }\n\n return kind || 'event';\n }\n\n function buildSearchText(obj, summary) {\n const pieces = [];\n pieces.push(obj?.type || '');\n pieces.push(obj?.uuid || '');\n pieces.push(obj?.sessionId || '');\n pieces.push(obj?.cwd || '');\n pieces.push(obj?.gitBranch || '');\n pieces.push(obj?.slug || '');\n const message = obj?.message;\n if (message && typeof message === 'object') {\n pieces.push(message.role || '');\n const msg = extractMessageText(message);\n if (msg) pieces.push(msg);\n const content = message.content;\n if (Array.isArray(content)) {\n for (const item of content) {\n if (item?.type === 'tool_use') {\n pieces.push(item.name || '');\n if (item.input && typeof item.input === 'object') {\n pieces.push(JSON.stringify(item.input).slice(0, 500));\n }\n }\n if (item?.type === 'tool_result' && typeof item.content === 'string') {\n pieces.push(item.content.slice(0, 500));\n }\n }\n }\n }\n if (typeof summary === 'string') pieces.push(summary);\n return pieces.join('\\n').toLowerCase();\n }\n\n function stringifyJson(value, truncateLongStrings) {\n const maxLen = 8000;\n return JSON.stringify(value, (key, v) => {\n if (!truncateLongStrings) return v;\n if (typeof v === 'string' && v.length > maxLen) {\n return v.slice(0, maxLen) + `... [truncated ${v.length - maxLen} chars]`;\n }\n return v;\n }, 2);\n }\n\n function updateFilterOptions(events) {\n const typeSet = new Set();\n const contentTypeSet = new Set();\n const roleSet = new Set();\n for (const e of events) {\n if (e.kind) typeSet.add(e.kind);\n for (const ct of e.contentTypes) {\n contentTypeSet.add(ct);\n }\n if (!e.contentTypes.length) contentTypeSet.add('(none)');\n if (e.role) roleSet.add(e.role);\n }\n\n function setOptions(select, items, current) {\n select.innerHTML = '';\n const optAll = document.createElement('option');\n optAll.value = 'all';\n optAll.textContent = 'All';\n select.appendChild(optAll);\n for (const item of items) {\n const opt = document.createElement('option');\n opt.value = item;\n opt.textContent = item;\n select.appendChild(opt);\n }\n select.value = current;\n }\n\n setOptions(els.typeFilter, Array.from(typeSet).sort(), state.filters.type);\n setOptions(els.contentTypeFilter, Array.from(contentTypeSet).sort(), state.filters.contentType);\n setOptions(els.roleFilter, Array.from(roleSet).sort(), state.filters.role);\n\n if (events.length) {\n state.filters.type = els.typeFilter.value || 'all';\n state.filters.contentType = els.contentTypeFilter.value || 'all';\n state.filters.role = els.roleFilter.value || 'all';\n }\n }\n\n function readHashParams() {\n const raw = window.location.hash && window.location.hash.startsWith('#')\n ? window.location.hash.slice(1)\n : '';\n return new URLSearchParams(raw);\n }\n\n function writeHashParams(params) {\n const base = window.location.href.split('#')[0];\n const hash = params.toString();\n const next = base + (hash ? `#${hash}` : '');\n window.history.replaceState(null, '', next);\n }\n\n function syncUrlHashFromControls({ clearSel = false } = {}) {\n if (suppressUrlStateSync) return;\n const params = readHashParams();\n params.set('tz', state.tzMode);\n params.set('q', (els.search.value || '').trim());\n params.set('type', state.filters.type || 'all');\n params.set('ct', state.filters.contentType || 'all');\n params.set('role', state.filters.role || 'all');\n params.set('hide', state.filters.hideThinking ? '1' : '0');\n params.set('truncate', els.truncateStrings.checked ? '1' : '0');\n const selectedLine = state.selected?.line;\n if (typeof selectedLine === 'number' && Number.isFinite(selectedLine)) {\n params.set('sel', String(selectedLine));\n } else if (clearSel) {\n params.delete('sel');\n }\n writeHashParams(params);\n }\n\n function applySelectionFromHash() {\n if (!state.events.length) return;\n const params = readHashParams();\n const raw = params.get('sel');\n if (!raw) return;\n const line = Number(raw);\n if (!Number.isFinite(line)) return;\n const found = state.events.find(e => e.line === line);\n if (!found) return;\n if (state.selectedId === found.id) return;\n suppressUrlStateSync = true;\n try {\n selectEvent(found.id, { scroll: true });\n } finally {\n suppressUrlStateSync = false;\n }\n }\n\n function applyControlsFromHash() {\n const params = readHashParams();\n suppressUrlStateSync = true;\n try {\n const q = params.get('q');\n if (q !== null) {\n els.search.value = q;\n state.filters.query = q.trim().toLowerCase();\n }\n\n const type = params.get('type');\n if (type !== null) state.filters.type = type;\n const ct = params.get('ct');\n if (ct !== null) state.filters.contentType = ct;\n const role = params.get('role');\n if (role !== null) state.filters.role = role;\n\n const hide = params.get('hide');\n if (hide !== null) {\n els.hideThinking.checked = hide === '1' || hide === 'true';\n state.filters.hideThinking = els.hideThinking.checked;\n }\n\n const truncate = params.get('truncate');\n if (truncate !== null) {\n els.truncateStrings.checked = truncate === '1' || truncate === 'true';\n }\n\n const tz = params.get('tz');\n if (tz === 'utc' || tz === 'local') {\n state.tzMode = tz;\n els.tzLocal.classList.toggle('active', tz === 'local');\n els.tzUtc.classList.toggle('active', tz === 'utc');\n }\n } finally {\n suppressUrlStateSync = false;\n }\n }\n\n function applyFilters() {\n const query = state.filters.query;\n const type = state.filters.type;\n const contentType = state.filters.contentType;\n const role = state.filters.role;\n const hideThinking = state.filters.hideThinking;\n\n const out = [];\n for (const e of state.events) {\n if (hideThinking && e.contentTypes.includes('thinking') && e.contentTypes.length === 1) continue;\n if (type !== 'all' && e.kind !== type) continue;\n if (contentType !== 'all') {\n const ct = e.contentTypes.length ? e.contentTypes : ['(none)'];\n if (!ct.includes(contentType)) continue;\n }\n if (role !== 'all' && e.role !== role) continue;\n if (query && !e.search.includes(query)) continue;\n out.push(e);\n }\n\n state.filtered = out;\n els.statsShowing.textContent = String(out.length);\n els.statsTotal.textContent = String(state.events.length);\n\n const start = out.find(e => e.date instanceof Date && !Number.isNaN(e.date.getTime()))?.date;\n const end = [...out].reverse().find(e => e.date instanceof Date && !Number.isNaN(e.date.getTime()))?.date;\n if (start && end) {\n els.statsRange.textContent = `${formatTimestamp(start, state.tzMode)} -> ${formatTimestamp(end, state.tzMode)}`;\n } else {\n els.statsRange.textContent = '-';\n }\n\n renderTimeline();\n }\n\n function clearSelection({ syncHash = true } = {}) {\n state.selectedId = null;\n state.selected = null;\n els.detailTitle.textContent = 'Select an event';\n renderDetailImages(null);\n renderPrettyView(null);\n els.detailJson.textContent = state.events.length ? 'Select an event from the timeline.' : 'Load a file to get started.';\n els.detailJson.classList.add('empty');\n els.copyJson.disabled = true;\n els.copyRaw.disabled = true;\n for (const node of els.timeline.querySelectorAll('.event.selected')) {\n node.classList.remove('selected');\n }\n updateDetailPlacement();\n if (syncHash) syncUrlHashFromControls({ clearSel: true });\n }\n\n function renderTimeline() {\n els.timeline.innerHTML = '';\n\n if (!state.filtered.length) {\n const empty = document.createElement('div');\n empty.className = 'empty';\n empty.textContent = state.events.length ? 'No events match your filters.' : 'Load a file to see a timeline.';\n els.timeline.appendChild(empty);\n clearSelection();\n return;\n }\n\n const fragment = document.createDocumentFragment();\n let lastDay = null;\n let prevDate = null;\n\n for (const e of state.filtered) {\n const day = dayKey(e.date, state.tzMode);\n if (day !== lastDay) {\n lastDay = day;\n const sep = document.createElement('div');\n sep.className = 'day-sep';\n sep.textContent = day;\n fragment.appendChild(sep);\n }\n\n const row = document.createElement('div');\n row.className = 'event';\n row.setAttribute('role', 'listitem');\n row.dataset.eventId = String(e.id);\n\n const time = document.createElement('div');\n time.className = 'time';\n\n const ts = document.createElement('div');\n ts.className = 'ts';\n ts.textContent = formatTimestamp(e.date, state.tzMode);\n ts.dataset.kind = 'timestamp';\n\n const meta = document.createElement('div');\n meta.textContent = [\n `#${e.line}`,\n (prevDate && e.date) ? formatDelta(e.date.getTime() - prevDate.getTime()) : null,\n ].filter(Boolean).join(' . ');\n\n time.appendChild(ts);\n time.appendChild(meta);\n\n const body = document.createElement('div');\n\n const badges = document.createElement('div');\n badges.className = 'badges';\n const badgeKind = document.createElement('span');\n badgeKind.className = `badge k-${e.kind}`;\n badgeKind.textContent = e.kind;\n badges.appendChild(badgeKind);\n\n for (const ct of e.contentTypes.slice(0, 2)) {\n const badgeCt = document.createElement('span');\n badgeCt.className = `badge ct-${ct}`;\n badgeCt.textContent = ct;\n badges.appendChild(badgeCt);\n }\n if (e.contentTypes.length > 2) {\n const badgeMore = document.createElement('span');\n badgeMore.className = 'badge';\n badgeMore.textContent = `+${e.contentTypes.length - 2}`;\n badges.appendChild(badgeMore);\n }\n\n if (e.role) {\n const badgeRole = document.createElement('span');\n badgeRole.className = `badge role-${e.role}`;\n badgeRole.textContent = e.role;\n badges.appendChild(badgeRole);\n }\n\n const summary = document.createElement('div');\n summary.className = 'summary';\n summary.textContent = e.summary;\n\n body.appendChild(badges);\n body.appendChild(summary);\n\n row.appendChild(time);\n row.appendChild(body);\n\n row.addEventListener('click', () => selectEvent(e.id, { focusTimeline: true }));\n fragment.appendChild(row);\n\n if (e.date instanceof Date && !Number.isNaN(e.date.getTime())) prevDate = e.date;\n }\n\n els.timeline.appendChild(fragment);\n\n if (state.selectedId !== null) {\n const stillThere = state.filtered.some(e => e.id === state.selectedId);\n if (!stillThere) clearSelection();\n else selectEvent(state.selectedId, { scroll: false });\n } else {\n clearSelection({ syncHash: false });\n }\n }\n\n function selectEvent(id, { scroll = false, focusTimeline = false } = {}) {\n const e = state.filtered.find(item => item.id === id) || state.events.find(item => item.id === id);\n if (!e) return;\n state.selectedId = id;\n state.selected = e;\n\n if (focusTimeline) {\n els.timeline.focus({ preventScroll: true });\n }\n\n for (const node of els.timeline.querySelectorAll('.event.selected')) {\n node.classList.remove('selected');\n }\n const node = els.timeline.querySelector(`.event[data-event-id=\"${CSS.escape(String(id))}\"]`);\n if (node) {\n node.classList.add('selected');\n updateDetailPlacement();\n if (scroll) node.scrollIntoView({ block: 'center' });\n }\n\n els.detailJson.classList.remove('empty');\n const titleParts = [\n formatTimestamp(e.date, state.tzMode),\n `#${e.line}`,\n e.kind,\n e.role ? `(${e.role})` : null,\n ].filter(Boolean);\n els.detailTitle.textContent = titleParts.join(' ');\n\n const shouldTruncate = els.truncateStrings.checked;\n renderDetailImages(e.obj);\n renderPrettyView(e.obj);\n els.detailJson.textContent = stringifyJson(e.obj, shouldTruncate) || '';\n els.copyJson.disabled = false;\n els.copyRaw.disabled = false;\n updateDetailPlacement();\n syncUrlHashFromControls();\n }\n\n function extractImageUrls(obj) {\n const urls = [];\n const message = obj?.message;\n if (!message || typeof message !== 'object') return urls;\n const content = message.content;\n if (!Array.isArray(content)) return urls;\n\n for (const item of content) {\n if (!item || typeof item !== 'object') continue;\n\n // Direct image in content\n if (item.type === 'image' && item.source?.type === 'base64' && item.source?.data) {\n const mediaType = item.source.media_type || 'image/png';\n urls.push(`data:${mediaType};base64,${item.source.data}`);\n }\n\n // Image in tool_result content\n if (item.type === 'tool_result' && Array.isArray(item.content)) {\n for (const rc of item.content) {\n if (rc?.type === 'image' && rc.source?.type === 'base64' && rc.source?.data) {\n const mediaType = rc.source.media_type || 'image/png';\n urls.push(`data:${mediaType};base64,${rc.source.data}`);\n }\n }\n }\n }\n\n return urls;\n }\n\n function escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n\n // Initialize markdown-it with GFM-like settings\n const md = window.markdownit({\n html: false, // Disable raw HTML input\n linkify: true, // Auto-convert URLs to links\n typographer: true, // Smart quotes, dashes\n breaks: true // Convert \\n to <br>\n });\n\n function renderMarkdown(text) {\n if (typeof text !== 'string') return '';\n\n // Render markdown to HTML\n const html = md.render(text);\n\n // Sanitize with DOMPurify to prevent XSS\n return DOMPurify.sanitize(html);\n }\n\n function renderPrettyView(obj) {\n els.detailPretty.innerHTML = '';\n\n if (!obj || typeof obj !== 'object') {\n els.detailPretty.hidden = true;\n return;\n }\n\n const message = obj.message;\n if (!message || typeof message !== 'object') {\n els.detailPretty.hidden = true;\n return;\n }\n\n const content = message.content;\n const fragment = document.createDocumentFragment();\n let hasContent = false;\n\n // Handle string content (simple text message)\n if (typeof content === 'string') {\n const section = document.createElement('div');\n section.className = 'pretty-section';\n const label = document.createElement('div');\n label.className = 'pretty-label';\n label.textContent = message.role || 'Message';\n const body = document.createElement('div');\n body.className = 'pretty-content';\n body.innerHTML = renderMarkdown(content);\n section.appendChild(label);\n section.appendChild(body);\n fragment.appendChild(section);\n hasContent = true;\n }\n\n // Handle array content\n if (Array.isArray(content)) {\n for (const item of content) {\n if (!item || typeof item !== 'object') continue;\n\n // Text content\n if (item.type === 'text' && item.text) {\n const section = document.createElement('div');\n section.className = 'pretty-section';\n const label = document.createElement('div');\n label.className = 'pretty-label';\n label.textContent = message.role === 'assistant' ? 'Response' : 'Message';\n const body = document.createElement('div');\n body.className = 'pretty-content';\n body.innerHTML = renderMarkdown(item.text);\n section.appendChild(label);\n section.appendChild(body);\n fragment.appendChild(section);\n hasContent = true;\n }\n\n // Thinking content\n if (item.type === 'thinking' && item.thinking) {\n const section = document.createElement('div');\n section.className = 'pretty-section';\n const label = document.createElement('div');\n label.className = 'pretty-label';\n label.textContent = 'Thinking';\n const body = document.createElement('div');\n body.className = 'thinking-block pretty-content';\n body.innerHTML = renderMarkdown(item.thinking);\n section.appendChild(label);\n section.appendChild(body);\n fragment.appendChild(section);\n hasContent = true;\n }\n\n // Tool use\n if (item.type === 'tool_use') {\n const section = document.createElement('div');\n section.className = 'pretty-section';\n const label = document.createElement('div');\n label.className = 'pretty-label';\n label.textContent = 'Tool Call';\n\n const toolName = item.name || 'Unknown Tool';\n const input = item.input || {};\n\n // Special handling for Bash command\n if (toolName === 'Bash' && input.command) {\n const block = document.createElement('div');\n block.className = 'command-block';\n\n const header = document.createElement('div');\n header.className = 'command-header';\n const nameSpan = document.createElement('span');\n nameSpan.className = 'tool-name';\n nameSpan.textContent = toolName;\n header.appendChild(nameSpan);\n if (input.description) {\n const descSpan = document.createElement('span');\n descSpan.className = 'tool-desc';\n descSpan.textContent = input.description;\n header.appendChild(descSpan);\n }\n\n const cmdInput = document.createElement('div');\n cmdInput.className = 'command-input';\n cmdInput.textContent = input.command;\n\n block.appendChild(header);\n block.appendChild(cmdInput);\n section.appendChild(label);\n section.appendChild(block);\n }\n // Special handling for file operations\n else if (['Read', 'Write', 'Edit', 'Glob', 'Grep'].includes(toolName)) {\n const block = document.createElement('div');\n block.className = 'command-block';\n\n const header = document.createElement('div');\n header.className = 'command-header';\n const nameSpan = document.createElement('span');\n nameSpan.className = 'tool-name';\n nameSpan.textContent = toolName;\n header.appendChild(nameSpan);\n\n block.appendChild(header);\n\n const params = document.createElement('div');\n params.className = 'tool-params';\n params.style.padding = '10px 12px';\n\n for (const [key, value] of Object.entries(input)) {\n const param = document.createElement('div');\n param.className = 'tool-param';\n const paramName = document.createElement('span');\n paramName.className = 'tool-param-name';\n paramName.textContent = key;\n const paramValue = document.createElement('span');\n paramValue.className = 'tool-param-value';\n if (typeof value === 'string') {\n paramValue.textContent = value.length > 500 ? value.slice(0, 500) + '...' : value;\n } else {\n paramValue.textContent = JSON.stringify(value, null, 2);\n }\n param.appendChild(paramName);\n param.appendChild(paramValue);\n params.appendChild(param);\n }\n\n block.appendChild(params);\n section.appendChild(label);\n section.appendChild(block);\n }\n // Generic tool display\n else {\n const block = document.createElement('div');\n block.className = 'command-block';\n\n const header = document.createElement('div');\n header.className = 'command-header';\n const nameSpan = document.createElement('span');\n nameSpan.className = 'tool-name';\n nameSpan.textContent = toolName;\n header.appendChild(nameSpan);\n\n block.appendChild(header);\n\n if (Object.keys(input).length > 0) {\n const params = document.createElement('div');\n params.className = 'tool-params';\n params.style.padding = '10px 12px';\n\n for (const [key, value] of Object.entries(input)) {\n const param = document.createElement('div');\n param.className = 'tool-param';\n const paramName = document.createElement('span');\n paramName.className = 'tool-param-name';\n paramName.textContent = key;\n const paramValue = document.createElement('span');\n paramValue.className = 'tool-param-value';\n if (typeof value === 'string') {\n paramValue.textContent = value.length > 1000 ? value.slice(0, 1000) + '...' : value;\n } else {\n paramValue.textContent = JSON.stringify(value, null, 2);\n }\n param.appendChild(paramName);\n param.appendChild(paramValue);\n params.appendChild(param);\n }\n\n block.appendChild(params);\n }\n\n section.appendChild(label);\n section.appendChild(block);\n }\n\n fragment.appendChild(section);\n hasContent = true;\n }\n\n // Tool result\n if (item.type === 'tool_result') {\n const section = document.createElement('div');\n section.className = 'pretty-section';\n const label = document.createElement('div');\n label.className = 'pretty-label';\n label.textContent = item.is_error ? 'Tool Error' : 'Tool Result';\n\n const resultContent = item.content;\n let resultText = '';\n\n if (typeof resultContent === 'string') {\n resultText = resultContent;\n } else if (Array.isArray(resultContent)) {\n for (const rc of resultContent) {\n if (rc?.type === 'text' && rc.text) {\n resultText += rc.text;\n }\n }\n }\n\n if (resultText) {\n const block = document.createElement('div');\n block.className = 'pretty-content' + (item.is_error ? ' result-error' : '');\n const displayText = resultText.length > 5000 ? resultText.slice(0, 5000) + '\\n... [truncated]' : resultText;\n block.innerHTML = renderMarkdown(displayText);\n section.appendChild(label);\n section.appendChild(block);\n fragment.appendChild(section);\n hasContent = true;\n }\n }\n }\n }\n\n if (hasContent) {\n els.detailPretty.appendChild(fragment);\n els.detailPretty.hidden = false;\n } else {\n els.detailPretty.hidden = true;\n }\n }\n\n function openImageModal(src, altText = '') {\n if (!src) return;\n lastActiveElementBeforeModal = document.activeElement instanceof HTMLElement ? document.activeElement : null;\n lastBodyOverflow = document.body.style.overflow;\n document.body.style.overflow = 'hidden';\n\n els.imageModalImg.src = src;\n els.imageModalImg.alt = altText;\n els.imageModal.classList.add('open');\n els.imageModal.setAttribute('aria-hidden', 'false');\n els.imageModalClose.focus();\n }\n\n function closeImageModal() {\n if (!els.imageModal.classList.contains('open')) return;\n els.imageModal.classList.remove('open');\n els.imageModal.setAttribute('aria-hidden', 'true');\n els.imageModalImg.removeAttribute('src');\n els.imageModalImg.alt = '';\n document.body.style.overflow = lastBodyOverflow ?? '';\n\n if (lastActiveElementBeforeModal) {\n lastActiveElementBeforeModal.focus();\n }\n lastActiveElementBeforeModal = null;\n lastBodyOverflow = null;\n }\n\n function renderDetailImages(obj) {\n const urls = extractImageUrls(obj);\n els.detailImages.innerHTML = '';\n if (!urls.length) {\n els.detailImages.hidden = true;\n return;\n }\n const fragment = document.createDocumentFragment();\n urls.forEach((src, idx) => {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'thumb';\n btn.title = `Open image ${idx + 1}`;\n const img = document.createElement('img');\n img.loading = 'lazy';\n img.src = src;\n img.alt = `Image ${idx + 1}`;\n btn.appendChild(img);\n btn.addEventListener('click', () => openImageModal(src, `Image ${idx + 1}`));\n fragment.appendChild(btn);\n });\n els.detailImages.appendChild(fragment);\n els.detailImages.hidden = false;\n }\n\n function isEditableElement(el) {\n if (!(el instanceof HTMLElement)) return false;\n if (el.isContentEditable) return true;\n const tag = el.tagName;\n return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';\n }\n\n function moveSelection(delta) {\n if (!state.filtered.length) return;\n const idx = state.filtered.findIndex(e => e.id === state.selectedId);\n if (idx === -1) {\n selectEvent(state.filtered[0].id, { scroll: true });\n return;\n }\n const nextIdx = Math.max(0, Math.min(state.filtered.length - 1, idx + delta));\n if (nextIdx === idx) return;\n selectEvent(state.filtered[nextIdx].id, { scroll: true });\n }\n\n async function copyText(text) {\n if (typeof text !== 'string') return;\n try {\n await navigator.clipboard.writeText(text);\n setStatus('Copied to clipboard.', 'good');\n } catch (error) {\n const ta = document.createElement('textarea');\n ta.value = text;\n ta.style.position = 'fixed';\n ta.style.left = '-9999px';\n document.body.appendChild(ta);\n ta.select();\n document.execCommand('copy');\n document.body.removeChild(ta);\n setStatus('Copied to clipboard.', 'good');\n }\n }\n\n function extractUserPrompts() {\n const prompts = [];\n for (const e of state.events) {\n // Filter: type=user, content-type includes text, role=user\n if (e.kind !== 'user') continue;\n if (e.role !== 'user') continue;\n if (!e.contentTypes.includes('text')) continue;\n\n const message = e.obj?.message;\n if (!message) continue;\n\n const content = message.content;\n if (typeof content === 'string') {\n prompts.push(content);\n } else if (Array.isArray(content)) {\n for (const item of content) {\n if (item?.type === 'text' && item.text) {\n prompts.push(item.text);\n }\n }\n }\n }\n return prompts.join('\\n\\n');\n }\n\n async function copyUserPrompts() {\n const text = extractUserPrompts();\n if (!text) {\n setStatus('No user prompts found.', 'warn');\n return;\n }\n await copyText(text);\n const originalText = els.copyPrompts.textContent;\n els.copyPrompts.textContent = 'Copied!';\n setTimeout(() => {\n els.copyPrompts.textContent = originalText;\n }, 2000);\n }\n\n function setTimezoneMode(mode) {\n state.tzMode = mode;\n els.tzLocal.classList.toggle('active', mode === 'local');\n els.tzUtc.classList.toggle('active', mode === 'utc');\n applyFilters();\n if (state.selectedId !== null) selectEvent(state.selectedId);\n syncUrlHashFromControls();\n }\n\n els.imageModalClose.addEventListener('click', closeImageModal);\n els.imageModal.addEventListener('click', (event) => {\n if (event.target === els.imageModal) closeImageModal();\n });\n document.addEventListener('keydown', (event) => {\n if (event.key === 'Escape') closeImageModal();\n });\n\n document.addEventListener('keydown', (event) => {\n if (els.imageModal.classList.contains('open')) return;\n if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;\n if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;\n if (isEditableElement(document.activeElement)) return;\n if (document.activeElement !== els.timeline && !els.timeline.contains(document.activeElement)) return;\n if (state.selectedId === null) return;\n event.preventDefault();\n moveSelection(event.key === 'ArrowUp' ? -1 : 1);\n });\n\n if (typeof wideLayoutMq.addEventListener === 'function') {\n wideLayoutMq.addEventListener('change', updateDetailPlacement);\n } else if (typeof wideLayoutMq.addListener === 'function') {\n wideLayoutMq.addListener(updateDetailPlacement);\n }\n\n function setTab(tabName) {\n for (const tab of document.querySelectorAll('.tab')) {\n const active = tab.dataset.tab === tabName;\n tab.classList.toggle('active', active);\n tab.setAttribute('aria-selected', active ? 'true' : 'false');\n }\n for (const panel of document.querySelectorAll('[data-panel]')) {\n panel.hidden = panel.dataset.panel !== tabName;\n }\n }\n\n function setShareUrlParam(url) {\n const current = new URL(window.location.href);\n if (url) current.searchParams.set('url', url);\n else current.searchParams.delete('url');\n window.history.replaceState(null, '', current);\n }\n\n async function fetchAndRenderUrl(url, { syncUrl = true } = {}) {\n const trimmed = (url || '').trim();\n if (!trimmed) {\n setStatus('Enter a URL first.', 'error');\n return;\n }\n if (syncUrl) setShareUrlParam(trimmed);\n setStatus(`Fetching ${trimmed}...`, 'warn');\n try {\n const resp = await fetch(trimmed, { mode: 'cors' });\n if (!resp.ok) throw new Error(`HTTP ${resp.status}`);\n const text = await resp.text();\n await loadText(text, trimmed);\n } catch (error) {\n setStatus(`Failed to fetch URL: ${String(error)}`, 'error');\n }\n }\n\n async function loadText(text, sourceLabel) {\n const started = performance.now();\n state.sourceLabel = sourceLabel || null;\n setStatus(`Parsing ${sourceLabel || 'content'}...`, 'warn');\n clearSelection({ syncHash: false });\n\n const lines = text.split(/\\r?\\n/);\n const events = [];\n const errors = [];\n let idCounter = 0;\n\n for (let i = 0; i < lines.length; i++) {\n const line = lines[i];\n if (!line.trim()) continue;\n const parsed = safeJsonParse(line);\n if (!parsed.ok) {\n errors.push({ line: i + 1, error: parsed.error });\n continue;\n }\n const obj = parsed.value;\n const ts = typeof obj.timestamp === 'string' ? new Date(obj.timestamp) : null;\n const kind = obj.type || '(unknown)';\n const message = obj.message && typeof obj.message === 'object' ? obj.message : null;\n const contentTypes = message ? getContentTypes(message) : [];\n const role = message?.role ?? null;\n\n const summary = summarizeEvent(obj);\n events.push({\n id: ++idCounter,\n line: i + 1,\n raw: line,\n obj,\n date: ts,\n kind,\n contentTypes,\n role,\n summary,\n search: buildSearchText(obj, summary),\n });\n }\n\n state.events = events;\n updateFilterOptions(events);\n applyFilters();\n applySelectionFromHash();\n syncUrlHashFromControls();\n\n const durationMs = Math.round(performance.now() - started);\n if (errors.length) {\n const first = errors[0];\n setStatus(\n `Loaded ${events.length} events from ${sourceLabel || 'content'} in ${durationMs}ms.\\n` +\n `Could not parse ${errors.length} line(s). First error at line ${first.line}: ${String(first.error)}`,\n 'warn'\n );\n } else {\n setStatus(`Loaded ${events.length} events from ${sourceLabel || 'content'} in ${durationMs}ms.`, 'good');\n }\n }\n\n async function handleFile(file) {\n if (!file) return;\n setShareUrlParam(null);\n try {\n const text = await file.text();\n await loadText(text, file.name);\n } catch (error) {\n setStatus(`Failed to read file: ${String(error)}`, 'error');\n }\n }\n\n els.dropzone.addEventListener('click', () => els.fileInput.click());\n els.dropzone.addEventListener('keydown', (e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n els.fileInput.click();\n }\n });\n els.fileInput.addEventListener('change', () => handleFile(els.fileInput.files?.[0]));\n\n els.dropzone.addEventListener('dragover', (e) => {\n e.preventDefault();\n els.dropzone.classList.add('dragover');\n });\n els.dropzone.addEventListener('dragleave', () => els.dropzone.classList.remove('dragover'));\n els.dropzone.addEventListener('drop', (e) => {\n e.preventDefault();\n els.dropzone.classList.remove('dragover');\n const file = e.dataTransfer?.files?.[0];\n if (file) handleFile(file);\n });\n\n els.renderPaste.addEventListener('click', async () => {\n const text = els.pasteInput.value || '';\n if (!text.trim()) {\n setStatus('Paste some JSONL content first.', 'error');\n return;\n }\n setShareUrlParam(null);\n await loadText(text, 'pasted content');\n });\n\n els.fetchUrl.addEventListener('click', async () => {\n await fetchAndRenderUrl(els.urlInput.value, { syncUrl: true });\n });\n\n for (const tab of document.querySelectorAll('.tab')) {\n tab.addEventListener('click', () => setTab(tab.dataset.tab));\n }\n\n els.tzLocal.addEventListener('click', () => setTimezoneMode('local'));\n els.tzUtc.addEventListener('click', () => setTimezoneMode('utc'));\n\n els.search.addEventListener('input', () => {\n state.filters.query = els.search.value.trim().toLowerCase();\n applyFilters();\n syncUrlHashFromControls();\n });\n els.typeFilter.addEventListener('change', () => {\n state.filters.type = els.typeFilter.value;\n applyFilters();\n syncUrlHashFromControls();\n });\n els.contentTypeFilter.addEventListener('change', () => {\n state.filters.contentType = els.contentTypeFilter.value;\n applyFilters();\n syncUrlHashFromControls();\n });\n els.roleFilter.addEventListener('change', () => {\n state.filters.role = els.roleFilter.value;\n applyFilters();\n syncUrlHashFromControls();\n });\n els.hideThinking.addEventListener('change', () => {\n state.filters.hideThinking = els.hideThinking.checked;\n applyFilters();\n syncUrlHashFromControls();\n });\n els.truncateStrings.addEventListener('change', () => {\n if (state.selectedId !== null) selectEvent(state.selectedId);\n syncUrlHashFromControls();\n });\n\n els.clearFilters.addEventListener('click', () => {\n els.search.value = '';\n state.filters.query = '';\n state.filters.type = 'all';\n state.filters.contentType = 'all';\n state.filters.role = 'all';\n state.filters.hideThinking = false;\n els.hideThinking.checked = false;\n updateFilterOptions(state.events);\n applyFilters();\n syncUrlHashFromControls();\n });\n\n els.copyJson.addEventListener('click', () => {\n if (!state.selected) return;\n copyText(stringifyJson(state.selected.obj, false));\n });\n els.copyRaw.addEventListener('click', () => {\n if (!state.selected) return;\n copyText(state.selected.raw);\n });\n els.copyPrompts.addEventListener('click', copyUserPrompts);\n\n async function initFromQuery() {\n const url = new URLSearchParams(window.location.search).get('url');\n if (!url) return;\n setTab('url');\n els.urlInput.value = url;\n await fetchAndRenderUrl(url, { syncUrl: false });\n }\n\n // Initialize select options for the empty state.\n applyControlsFromHash();\n updateFilterOptions([]);\n syncUrlHashFromControls();\n window.addEventListener('hashchange', () => {\n applyControlsFromHash();\n updateFilterOptions(state.events);\n applyFilters();\n applySelectionFromHash();\n });\n void initFromQuery();\n </script>\n</body>\n</html>\n","structuredPatch":[{"oldStart":1085,"oldLines":7,"newStart":1085,"newLines":11,"lines":[" imageModal: document.getElementById('image-modal'),"," imageModalImg: document.getElementById('image-modal-img'),"," imageModalClose: document.getElementById('image-modal-close'),","- copyPrompts: document.getElementById('copy-prompts'),","+ showPrompts: document.getElementById('show-prompts'),","+ promptsModal: document.getElementById('prompts-modal'),","+ promptsClose: document.getElementById('prompts-close'),","+ promptsText: document.getElementById('prompts-text'),","+ promptsCopy: document.getElementById('prompts-copy'),"," };"," "," const state = {"]}],"userModified":false,"replaceAll":false}}
{"parentUuid":"7fd83add-e527-45f0-9105-26c3aa344aae","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","slug":"glittery-skipping-rivest","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01QAqKRXvUUwbPppV4JcmRMj","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Good. Now I need to add the modal state variables and the functions for opening/closing the prompts modal. Let me find where lastActiveElementBeforeModal and lastBodyOverflow are defined and add the new state variables.","signature":"EoQDCkYIChgCKkC5NB5M8ukVF7l8FFTo9BkkEuijNbb4PsnPZjA0tTm9XuvZaPUEQS/47liemoo68D6kfKp1HhLAEGcc+uAj/F+3Egys/E5ju5tXz8QWlMUaDIGal1MP96Ultwd6ciIwHPOJuAx9PxPoKb90awt7m6vtnKsJ5ri15XIXaw8tivGb2povHzqu07Y1XNJw5SVXKusB6sPT5Fd/LMzPrNXdaKB7ZDenNqi7VypflyvBugpx+QuRE1OpsGrfuUpb4DoBWnHuiTP5hFC259Eixocj/uMeO/4fNeurpDrPXL/d1Swxubd1OQTk8/z/757FtZYOyXCsn2PorAjuKyhCECQuaGLSe46WmtJIjUYctGbatyf7gtnX9LwHZ25j8wMZ3t5MazxYAvZm4njxkjXjbRUBhJGnuG3Amc7gCZGXL6COsH1iruykIBP5AGBV/dpmvh2znuC3okKg2zTWrmiNYUoUxltxmNDFJzUpH6O6ubm51TxqdSVIE3PyI4kg8AYoTBgB"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":726,"cache_read_input_tokens":88343,"cache_creation":{"ephemeral_5m_input_tokens":726,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWDdbgoDqMhj1iGLPiTKy","type":"assistant","uuid":"0075ec4a-176d-4db7-9587-c75a80c30616","timestamp":"2025-12-18T05:52:23.138Z"}
{"parentUuid":"0075ec4a-176d-4db7-9587-c75a80c30616","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","slug":"glittery-skipping-rivest","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01QAqKRXvUUwbPppV4JcmRMj","type":"message","role":"assistant","content":[{"type":"text","text":"Now I'll add the modal state variables:"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":726,"cache_read_input_tokens":88343,"cache_creation":{"ephemeral_5m_input_tokens":726,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWDdbgoDqMhj1iGLPiTKy","type":"assistant","uuid":"164d2757-33ba-40fe-9616-0b4c46ae66af","timestamp":"2025-12-18T05:52:23.226Z"}
{"parentUuid":"164d2757-33ba-40fe-9616-0b4c46ae66af","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","slug":"glittery-skipping-rivest","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01QAqKRXvUUwbPppV4JcmRMj","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01SKSDd2kismy26xfUL2cMtk","name":"Edit","input":{"file_path":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","old_string":" let lastActiveElementBeforeModal = null;\n let lastBodyOverflow = null;\n let suppressUrlStateSync = false;","new_string":" let lastActiveElementBeforeModal = null;\n let lastBodyOverflow = null;\n let lastActiveElementBeforePrompts = null;\n let lastBodyOverflowBeforePrompts = null;\n let promptsCopiedTimer = null;\n let suppressUrlStateSync = false;"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":726,"cache_read_input_tokens":88343,"cache_creation":{"ephemeral_5m_input_tokens":726,"ephemeral_1h_input_tokens":0},"output_tokens":272,"service_tier":"standard"}},"requestId":"req_011CWDdbgoDqMhj1iGLPiTKy","type":"assistant","uuid":"bf1017e1-b7b1-427b-b610-c49b00beea15","timestamp":"2025-12-18T05:52:24.230Z"}
{"parentUuid":"bf1017e1-b7b1-427b-b610-c49b00beea15","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","slug":"glittery-skipping-rivest","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01SKSDd2kismy26xfUL2cMtk","type":"tool_result","content":"The file /Users/simon/Dropbox/dev/tools/claude-code-timeline.html has been updated. Here's the result of running `cat -n` on a snippet of the edited file:\n 1107→ hideThinking: false,\n 1108→ },\n 1109→ };\n 1110→\n 1111→ let lastActiveElementBeforeModal = null;\n 1112→ let lastBodyOverflow = null;\n 1113→ let lastActiveElementBeforePrompts = null;\n 1114→ let lastBodyOverflowBeforePrompts = null;\n 1115→ let promptsCopiedTimer = null;\n 1116→ let suppressUrlStateSync = false;\n 1117→ const wideLayoutMq = window.matchMedia('(min-width: 1060px)');\n 1118→ const detailDock = document.createElement('div');\n 1119→ detailDock.id = 'detail-dock';\n 1120→ detailDock.style.display = 'none';"}]},"uuid":"6e64025b-f071-4610-ae89-2d2b73f7a379","timestamp":"2025-12-18T05:52:24.268Z","toolUseResult":{"filePath":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","oldString":" let lastActiveElementBeforeModal = null;\n let lastBodyOverflow = null;\n let suppressUrlStateSync = false;","newString":" let lastActiveElementBeforeModal = null;\n let lastBodyOverflow = null;\n let lastActiveElementBeforePrompts = null;\n let lastBodyOverflowBeforePrompts = null;\n let promptsCopiedTimer = null;\n let suppressUrlStateSync = false;","originalFile":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>Claude Code Timeline Viewer</title>\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js\" integrity=\"sha384-osZDKVu4ipZP703HmPOhWdyBajcFyjX2Psjk//TG1Rc0AdwEtuToaylrmcK3LdAl\" crossorigin=\"anonymous\"></script>\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/markdown-it/4.4.0/markdown-it.min.js\" integrity=\"sha384-cxGp+QDeMxACP73aA0Lj0Sc/h4z1X73+umcvCyI0Vbyh3OYxgrPI+0mxG9slsMTN\" crossorigin=\"anonymous\"></script>\n <style>\n :root {\n color-scheme: light dark;\n --bg: #0b0c0f;\n --panel: #12141a;\n --panel-2: #0f1116;\n --text: #e8eaf0;\n --muted: #a6adbf;\n --line: rgba(255, 255, 255, 0.10);\n --line-2: rgba(255, 255, 255, 0.16);\n --accent: #6aa9ff;\n --accent-2: #8a5cff;\n --good: #46d39a;\n --warn: #ffcc66;\n --bad: #ff6b6b;\n --shadow: 0 18px 50px rgba(0, 0, 0, 0.35);\n --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\",\n \"Courier New\", monospace;\n --sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial,\n \"Apple Color Emoji\", \"Segoe UI Emoji\";\n }\n\n @media (prefers-color-scheme: light) {\n :root {\n --bg: #f7f8fc;\n --panel: #ffffff;\n --panel-2: #ffffff;\n --text: #10121a;\n --muted: #5b647a;\n --line: rgba(16, 18, 26, 0.12);\n --line-2: rgba(16, 18, 26, 0.18);\n --shadow: 0 18px 50px rgba(16, 18, 26, 0.10);\n }\n }\n\n * { box-sizing: border-box; }\n html, body { height: 100%; }\n body {\n margin: 0;\n font-family: var(--sans);\n background: radial-gradient(1200px 600px at 10% -10%, rgba(106, 169, 255, 0.18), transparent 45%),\n radial-gradient(1200px 600px at 90% -20%, rgba(138, 92, 255, 0.16), transparent 50%),\n var(--bg);\n color: var(--text);\n }\n\n .wrap {\n max-width: none;\n margin: 0;\n padding: 22px 16px 42px;\n }\n\n header {\n display: grid;\n gap: 10px;\n margin-bottom: 14px;\n }\n\n h1 {\n margin: 0;\n font-size: clamp(22px, 3vw, 32px);\n letter-spacing: 0.2px;\n }\n\n .sub {\n margin: 6px 0 0;\n color: var(--muted);\n line-height: 1.35;\n max-width: 70ch;\n }\n\n .controls {\n display: flex;\n flex-wrap: wrap;\n gap: 10px;\n align-items: center;\n justify-content: flex-start;\n }\n\n .controls .field {\n display: grid;\n gap: 4px;\n min-width: 180px;\n }\n\n .label {\n font-size: 12px;\n color: var(--muted);\n }\n\n input[type=\"search\"], input[type=\"url\"], select, textarea {\n font: inherit;\n color: inherit;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n border-radius: 10px;\n padding: 10px 11px;\n outline: none;\n }\n\n input[type=\"search\"]:focus, input[type=\"url\"]:focus, select:focus, textarea:focus {\n border-color: color-mix(in srgb, var(--accent) 60%, var(--line));\n box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);\n }\n\n .segmented {\n display: inline-flex;\n border: 1px solid var(--line);\n border-radius: 999px;\n overflow: hidden;\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n }\n\n .segmented button {\n border: none;\n padding: 9px 12px;\n font: inherit;\n background: transparent;\n color: var(--muted);\n cursor: pointer;\n min-width: 74px;\n }\n\n .segmented button.active {\n background: color-mix(in srgb, var(--accent) 16%, transparent);\n color: var(--text);\n }\n\n .segmented button:focus-visible {\n outline: 2px solid color-mix(in srgb, var(--accent) 70%, transparent);\n outline-offset: -2px;\n }\n\n .btn {\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n color: var(--text);\n border-radius: 10px;\n padding: 10px 12px;\n font: inherit;\n cursor: pointer;\n transition: transform 80ms ease, border-color 120ms ease;\n }\n\n .btn:hover { border-color: var(--line-2); }\n .btn:active { transform: translateY(1px); }\n .btn.primary {\n border-color: color-mix(in srgb, var(--accent) 55%, var(--line));\n background: color-mix(in srgb, var(--accent) 14%, transparent);\n }\n\n .btn:disabled {\n opacity: 0.55;\n cursor: not-allowed;\n }\n\n .panel {\n background: var(--panel);\n border: 1px solid var(--line);\n border-radius: 16px;\n box-shadow: var(--shadow);\n }\n\n .input-panel {\n padding: 14px;\n margin-bottom: 14px;\n }\n\n .filters-panel {\n padding: 14px;\n margin-bottom: 14px;\n }\n\n .input-grid {\n display: grid;\n grid-template-columns: 1fr;\n gap: 14px;\n align-items: stretch;\n }\n\n @media (min-width: 920px) {\n .input-grid {\n grid-template-columns: 380px 1fr;\n }\n }\n\n .dropzone {\n border: 1px dashed color-mix(in srgb, var(--accent) 40%, var(--line));\n border-radius: 14px;\n background: linear-gradient(\n 135deg,\n color-mix(in srgb, var(--accent) 12%, transparent),\n transparent 45%,\n color-mix(in srgb, var(--accent-2) 10%, transparent)\n );\n padding: 16px;\n display: grid;\n gap: 6px;\n cursor: pointer;\n min-height: 124px;\n align-content: center;\n }\n\n .dropzone.dragover {\n border-style: solid;\n border-color: color-mix(in srgb, var(--accent) 70%, var(--line));\n box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);\n }\n\n .dropzone strong { font-size: 15px; }\n .muted { color: var(--muted); }\n .tiny { font-size: 12px; color: var(--muted); }\n\n .tabs {\n display: flex;\n gap: 8px;\n margin-bottom: 10px;\n flex-wrap: wrap;\n align-items: center;\n }\n\n .tab {\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel-2) 94%, transparent);\n color: var(--muted);\n border-radius: 999px;\n padding: 7px 10px;\n cursor: pointer;\n font: inherit;\n }\n\n .tab.active {\n color: var(--text);\n border-color: color-mix(in srgb, var(--accent) 55%, var(--line));\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n }\n\n textarea {\n width: 100%;\n min-height: 190px;\n resize: vertical;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.4;\n }\n\n .row {\n display: flex;\n gap: 10px;\n flex-wrap: wrap;\n align-items: center;\n margin-top: 10px;\n }\n\n .status {\n margin-top: 12px;\n padding: 10px 12px;\n border-radius: 12px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel-2) 94%, transparent);\n display: none;\n white-space: pre-wrap;\n word-break: break-word;\n }\n .status.visible { display: block; }\n .status.error { border-color: color-mix(in srgb, var(--bad) 60%, var(--line)); }\n .status.good { border-color: color-mix(in srgb, var(--good) 55%, var(--line)); }\n .status.warn { border-color: color-mix(in srgb, var(--warn) 55%, var(--line)); }\n\n main {\n display: grid;\n gap: 14px;\n grid-template-columns: 1fr;\n min-height: 560px;\n }\n\n @media (min-width: 1060px) {\n main {\n grid-template-columns: 1fr 520px;\n align-items: stretch;\n }\n }\n\n .timeline-panel {\n display: grid;\n grid-template-rows: auto 1fr;\n min-height: 560px;\n }\n\n .stats {\n display: flex;\n gap: 12px;\n flex-wrap: wrap;\n align-items: baseline;\n justify-content: space-between;\n padding: 12px 14px;\n border-bottom: 1px solid var(--line);\n }\n\n .stats .big {\n font-size: 14px;\n font-weight: 600;\n }\n\n .timeline {\n overflow: auto;\n padding: 8px 0;\n }\n\n .timeline:focus-visible {\n outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent);\n outline-offset: -2px;\n border-radius: 14px;\n }\n\n .day-sep {\n position: sticky;\n top: 0;\n z-index: 2;\n padding: 8px 14px;\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n border-top: 1px solid var(--line);\n border-bottom: 1px solid var(--line);\n font-size: 12px;\n color: var(--muted);\n backdrop-filter: blur(6px);\n }\n\n .event {\n display: grid;\n grid-template-columns: 176px 1fr;\n gap: 12px;\n padding: 10px 14px;\n border-bottom: 1px solid color-mix(in srgb, var(--line) 85%, transparent);\n cursor: pointer;\n }\n\n .event:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); }\n .event.selected {\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n outline: 2px solid color-mix(in srgb, var(--accent) 35%, transparent);\n outline-offset: -2px;\n }\n\n .time {\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.35;\n color: var(--muted);\n display: grid;\n gap: 3px;\n }\n\n .time .ts {\n color: var(--text);\n font-weight: 600;\n }\n\n .badges {\n display: flex;\n gap: 6px;\n flex-wrap: wrap;\n align-items: center;\n margin-bottom: 6px;\n }\n\n .badge {\n font-size: 11px;\n font-family: var(--mono);\n padding: 2px 7px;\n border-radius: 999px;\n border: 1px solid var(--line);\n color: var(--muted);\n background: color-mix(in srgb, var(--panel-2) 90%, transparent);\n }\n\n .badge.k-user { border-color: color-mix(in srgb, #00c8ff 55%, var(--line)); color: color-mix(in srgb, #00c8ff 78%, var(--text)); }\n .badge.k-assistant { border-color: color-mix(in srgb, #ff73c4 55%, var(--line)); color: color-mix(in srgb, #ff73c4 78%, var(--text)); }\n .badge.k-file-history-snapshot { border-color: color-mix(in srgb, var(--warn) 55%, var(--line)); color: color-mix(in srgb, var(--warn) 75%, var(--text)); }\n\n .badge.ct-text { border-color: color-mix(in srgb, var(--good) 55%, var(--line)); color: color-mix(in srgb, var(--good) 75%, var(--text)); }\n .badge.ct-thinking { border-color: color-mix(in srgb, var(--accent-2) 55%, var(--line)); color: color-mix(in srgb, var(--accent-2) 75%, var(--text)); }\n .badge.ct-tool_use { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); color: color-mix(in srgb, var(--accent) 75%, var(--text)); }\n .badge.ct-tool_result { border-color: color-mix(in srgb, #8690a8 55%, var(--line)); color: color-mix(in srgb, #8690a8 70%, var(--text)); }\n .badge.ct-image { border-color: color-mix(in srgb, #ff9f43 55%, var(--line)); color: color-mix(in srgb, #ff9f43 75%, var(--text)); }\n\n .badge.role-user { border-color: color-mix(in srgb, #00c8ff 55%, var(--line)); color: color-mix(in srgb, #00c8ff 78%, var(--text)); }\n .badge.role-assistant { border-color: color-mix(in srgb, #ff73c4 55%, var(--line)); color: color-mix(in srgb, #ff73c4 78%, var(--text)); }\n\n .summary {\n font-size: 13px;\n line-height: 1.35;\n color: var(--text);\n word-break: break-word;\n }\n\n .summary code {\n font-family: var(--mono);\n font-size: 12px;\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n padding: 2px 5px;\n border-radius: 7px;\n }\n\n .detail {\n display: grid;\n grid-template-rows: auto auto auto auto 1fr;\n min-height: 560px;\n }\n\n @media (min-width: 1060px) {\n .detail {\n position: sticky;\n top: 16px;\n align-self: start;\n max-height: calc(100vh - 32px);\n }\n }\n\n .detail.inline {\n position: static;\n top: auto;\n align-self: auto;\n max-height: none;\n min-height: 0;\n margin: 8px 14px 12px;\n box-shadow: none;\n }\n\n .detail.inline pre {\n max-height: 52vh;\n }\n\n .detail-head {\n padding: 12px 14px;\n border-bottom: 1px solid var(--line);\n display: grid;\n gap: 8px;\n }\n\n .detail-title {\n font-size: 14px;\n font-weight: 700;\n line-height: 1.25;\n margin: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .detail-actions {\n display: flex;\n gap: 8px;\n flex-wrap: wrap;\n align-items: center;\n justify-content: flex-start;\n }\n\n .check {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n color: var(--muted);\n font-size: 12px;\n user-select: none;\n }\n\n pre {\n margin: 0;\n padding: 12px 14px 16px;\n overflow: auto;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.45;\n color: color-mix(in srgb, var(--text) 92%, transparent);\n background: transparent;\n white-space: pre-wrap;\n word-break: break-word;\n }\n\n .detail-images {\n display: flex;\n gap: 10px;\n padding: 12px 14px;\n border-bottom: 1px solid var(--line);\n overflow-x: auto;\n overscroll-behavior-x: contain;\n }\n\n .thumb {\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel-2) 92%, transparent);\n border-radius: 12px;\n padding: 0;\n cursor: pointer;\n width: 78px;\n height: 58px;\n overflow: hidden;\n flex: 0 0 auto;\n display: grid;\n place-items: stretch;\n }\n\n .thumb:hover { border-color: var(--line-2); }\n\n .thumb img {\n width: 100%;\n height: 100%;\n display: block;\n object-fit: cover;\n }\n\n .image-modal {\n position: fixed;\n inset: 0;\n z-index: 9999;\n background: rgba(0, 0, 0, 0.68);\n display: none;\n padding: 18px;\n place-items: center;\n }\n\n .image-modal.open { display: grid; }\n\n .image-modal .dialog {\n position: relative;\n border-radius: 16px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 98%, transparent);\n box-shadow: var(--shadow);\n padding: 14px;\n max-width: min(1200px, 96vw);\n max-height: 92vh;\n display: grid;\n place-items: center;\n }\n\n .image-modal img {\n display: block;\n max-width: min(1140px, 92vw);\n max-height: 84vh;\n width: auto;\n height: auto;\n object-fit: contain;\n border-radius: 10px;\n background: color-mix(in srgb, var(--panel-2) 92%, transparent);\n }\n\n .image-modal .close {\n position: absolute;\n top: 10px;\n right: 10px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n color: var(--text);\n border-radius: 999px;\n padding: 8px 10px;\n font: inherit;\n cursor: pointer;\n }\n\n .image-modal .close:hover { border-color: var(--line-2); }\n\n .empty {\n padding: 14px;\n color: var(--muted);\n font-size: 13px;\n line-height: 1.35;\n }\n\n /* Pretty view styles */\n .detail-pretty {\n padding: 14px;\n border-bottom: 1px solid var(--line);\n overflow: auto;\n max-height: 50vh;\n }\n\n .detail.inline .detail-pretty {\n max-height: 40vh;\n }\n\n .pretty-section {\n margin-bottom: 14px;\n }\n\n .pretty-section:last-child {\n margin-bottom: 0;\n }\n\n .pretty-label {\n font-size: 11px;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n color: var(--muted);\n margin-bottom: 6px;\n }\n\n .pretty-content {\n font-size: 13px;\n line-height: 1.5;\n }\n\n .pretty-content p {\n margin: 0 0 10px;\n }\n\n .pretty-content p:last-child {\n margin-bottom: 0;\n }\n\n .pretty-content h1, .pretty-content h2, .pretty-content h3,\n .pretty-content h4, .pretty-content h5, .pretty-content h6 {\n margin: 14px 0 8px;\n font-weight: 600;\n line-height: 1.3;\n }\n\n .pretty-content h1:first-child, .pretty-content h2:first-child,\n .pretty-content h3:first-child {\n margin-top: 0;\n }\n\n .pretty-content h1 { font-size: 1.4em; }\n .pretty-content h2 { font-size: 1.25em; }\n .pretty-content h3 { font-size: 1.1em; }\n\n .pretty-content ul, .pretty-content ol {\n margin: 8px 0;\n padding-left: 1.5em;\n list-style-position: outside;\n }\n\n .pretty-content li {\n margin: 4px 0;\n }\n\n .thinking-block ul, .thinking-block ol {\n padding-left: 2em;\n }\n\n .pretty-content code {\n font-family: var(--mono);\n font-size: 0.9em;\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n padding: 2px 5px;\n border-radius: 5px;\n }\n\n .pretty-content pre {\n margin: 10px 0;\n padding: 12px;\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n border-radius: 10px;\n overflow-x: auto;\n }\n\n .pretty-content pre code {\n background: none;\n border: none;\n padding: 0;\n font-size: 12px;\n }\n\n .pretty-content blockquote {\n margin: 10px 0;\n padding: 8px 12px;\n border-left: 3px solid var(--accent);\n background: color-mix(in srgb, var(--accent) 8%, transparent);\n border-radius: 0 8px 8px 0;\n }\n\n .pretty-content a {\n color: var(--accent);\n text-decoration: none;\n }\n\n .pretty-content a:hover {\n text-decoration: underline;\n }\n\n .pretty-content strong {\n font-weight: 600;\n }\n\n .pretty-content em {\n font-style: italic;\n }\n\n .pretty-content table {\n border-collapse: collapse;\n margin: 10px 0;\n width: 100%;\n font-size: 13px;\n }\n\n .pretty-content th,\n .pretty-content td {\n border: 1px solid var(--line);\n padding: 8px 10px;\n text-align: left;\n }\n\n .pretty-content th {\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n font-weight: 600;\n }\n\n .pretty-content tr:hover {\n background: color-mix(in srgb, var(--accent) 6%, transparent);\n }\n\n .command-block {\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n border-radius: 10px;\n overflow: hidden;\n margin: 8px 0;\n }\n\n .command-header {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 8px 12px;\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n border-bottom: 1px solid var(--line);\n font-size: 12px;\n }\n\n .command-header .tool-name {\n font-weight: 600;\n color: var(--accent);\n }\n\n .command-header .tool-desc {\n color: var(--muted);\n }\n\n .command-input {\n padding: 10px 12px;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.4;\n white-space: pre-wrap;\n word-break: break-word;\n border-bottom: 1px solid var(--line);\n }\n\n .command-input::before {\n content: '$ ';\n color: var(--good);\n font-weight: 600;\n }\n\n .command-output {\n padding: 10px 12px;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.4;\n white-space: pre-wrap;\n word-break: break-word;\n max-height: 300px;\n overflow: auto;\n color: var(--muted);\n }\n\n .file-path {\n font-family: var(--mono);\n font-size: 12px;\n padding: 6px 10px;\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n border-radius: 8px;\n display: inline-block;\n margin: 4px 0;\n }\n\n .tool-params {\n display: grid;\n gap: 8px;\n margin-top: 8px;\n }\n\n .tool-param {\n display: grid;\n grid-template-columns: auto 1fr;\n gap: 8px;\n align-items: start;\n font-size: 12px;\n }\n\n .tool-param-name {\n font-family: var(--mono);\n font-weight: 600;\n color: var(--accent);\n padding: 2px 6px;\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n border-radius: 4px;\n }\n\n .tool-param-value {\n font-family: var(--mono);\n white-space: pre-wrap;\n word-break: break-word;\n padding: 2px 0;\n }\n\n .thinking-block {\n background: color-mix(in srgb, var(--accent-2) 10%, transparent);\n border: 1px solid color-mix(in srgb, var(--accent-2) 30%, var(--line));\n border-radius: 10px;\n padding: 12px 16px;\n font-size: 13px;\n line-height: 1.5;\n }\n\n .result-block {\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n border-radius: 10px;\n padding: 12px;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.4;\n white-space: pre-wrap;\n word-break: break-word;\n max-height: 400px;\n overflow: auto;\n }\n\n .result-error {\n border-color: color-mix(in srgb, var(--bad) 50%, var(--line));\n background: color-mix(in srgb, var(--bad) 8%, transparent);\n }\n\n .json-toggle {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 8px 14px;\n border-bottom: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel-2) 50%, transparent);\n }\n\n .json-toggle-label {\n font-size: 12px;\n color: var(--muted);\n font-weight: 500;\n }\n\n .prompts-modal {\n position: fixed;\n inset: 0;\n z-index: 80;\n background: rgba(0, 0, 0, 0.68);\n display: none;\n padding: 18px;\n place-items: center;\n }\n\n .prompts-modal.open { display: grid; }\n\n .prompts-modal .dialog {\n position: relative;\n border-radius: 16px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 98%, transparent);\n box-shadow: var(--shadow);\n padding: 14px;\n width: min(1100px, 96vw);\n height: min(86vh, 780px);\n display: grid;\n grid-template-rows: auto 1fr auto;\n gap: 10px;\n }\n\n .prompts-head {\n display: flex;\n gap: 10px;\n align-items: center;\n justify-content: space-between;\n }\n\n .prompts-title {\n font-weight: 700;\n letter-spacing: 0.2px;\n }\n\n .prompts-modal textarea {\n width: 100%;\n height: 100%;\n resize: none;\n border-radius: 12px;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.45;\n padding: 12px;\n }\n\n .prompts-actions {\n display: flex;\n gap: 10px;\n align-items: center;\n justify-content: flex-end;\n flex-wrap: wrap;\n }\n </style>\n</head>\n <body>\n <div class=\"wrap\">\n <header>\n <div>\n <h1>Claude Code Timeline Viewer</h1>\n <p class=\"sub\">Open a Claude Code session <code>.jsonl</code> file and explore it as an interactive timeline. Times are shown in your local timezone by default. Find sessions in <code>~/.claude/projects/</code>.</p>\n </div>\n </header>\n\n <section class=\"panel input-panel\" aria-label=\"Input\">\n <div class=\"input-grid\">\n <div>\n <div id=\"dropzone\" class=\"dropzone\" tabindex=\"0\">\n <strong>Drop a <code>.jsonl</code> file here</strong>\n <div class=\"muted\">or click to choose a file</div>\n <div class=\"tiny\">Supported: file picker, drag-and-drop, paste, or URL fetch (CORS-enabled).</div>\n <input id=\"file-input\" type=\"file\" accept=\".jsonl,application/json,text/plain\" hidden />\n </div>\n </div>\n <div>\n <div class=\"tabs\" role=\"tablist\" aria-label=\"Input tabs\">\n <button type=\"button\" class=\"tab active\" data-tab=\"paste\" aria-selected=\"true\">Paste</button>\n <button type=\"button\" class=\"tab\" data-tab=\"url\" aria-selected=\"false\">URL</button>\n </div>\n\n <div id=\"panel-paste\" data-panel=\"paste\">\n <textarea id=\"paste-input\" placeholder=\"Paste JSONL content here...\"></textarea>\n <div class=\"row\">\n <button type=\"button\" class=\"btn primary\" id=\"render-paste\">Render pasted content</button>\n <div class=\"tiny\">Tip: the timeline uses one JSON object per line.</div>\n </div>\n </div>\n\n <div id=\"panel-url\" data-panel=\"url\" hidden>\n <input id=\"url-input\" type=\"url\" placeholder=\"https://example.com/session.jsonl\" />\n <div class=\"row\">\n <button type=\"button\" class=\"btn primary\" id=\"fetch-url\">Fetch and render</button>\n <div class=\"tiny\">The URL must allow cross-origin requests (CORS).</div>\n </div>\n </div>\n </div>\n </div>\n\n <div id=\"status\" class=\"status\" role=\"status\" aria-live=\"polite\"></div>\n </section>\n\n <section class=\"panel filters-panel\" aria-label=\"Filters\">\n <div class=\"controls\" aria-label=\"Timeline controls\">\n <div class=\"field\" style=\"min-width: 170px\">\n <div class=\"label\">Timezone</div>\n <div class=\"segmented\" role=\"group\" aria-label=\"Timezone toggle\">\n <button type=\"button\" id=\"tz-local\" class=\"active\">Local</button>\n <button type=\"button\" id=\"tz-utc\">UTC</button>\n </div>\n </div>\n <div class=\"field\" style=\"min-width: 220px\">\n <div class=\"label\">Search</div>\n <input id=\"search\" type=\"search\" placeholder=\"message, tool name, cwd...\" autocomplete=\"off\" />\n </div>\n <div class=\"field\" style=\"min-width: 190px\">\n <div class=\"label\">Type</div>\n <select id=\"type-filter\"></select>\n </div>\n <div class=\"field\" style=\"min-width: 210px\">\n <div class=\"label\">Content Type</div>\n <select id=\"content-type-filter\"></select>\n </div>\n <div class=\"field\" style=\"min-width: 160px\">\n <div class=\"label\">Role</div>\n <select id=\"role-filter\"></select>\n </div>\n <label class=\"check\" title=\"Hide thinking blocks (can be verbose)\">\n <input type=\"checkbox\" id=\"hide-thinking\" />\n Hide thinking\n </label>\n <button type=\"button\" class=\"btn\" id=\"clear-filters\">Clear</button>\n </div>\n </section>\n\n <div class=\"controls\" style=\"margin-bottom: 14px;\">\n <button type=\"button\" class=\"btn\" id=\"show-prompts\" disabled>Show prompts</button>\n </div>\n\n <main>\n <section class=\"panel timeline-panel\" aria-label=\"Timeline\">\n <div class=\"stats\">\n <div class=\"big\"><span id=\"stats-showing\">0</span> <span class=\"muted\">showing</span> <span class=\"muted\">/</span> <span id=\"stats-total\">0</span> <span class=\"muted\">events</span></div>\n <div class=\"muted\" id=\"stats-range\">-</div>\n </div>\n <div id=\"timeline\" class=\"timeline\" role=\"list\" tabindex=\"0\" aria-label=\"Events list\"></div>\n </section>\n\n <aside class=\"panel detail\" aria-label=\"Details\">\n <div class=\"detail-head\">\n <div class=\"detail-title\" id=\"detail-title\">Select an event</div>\n <div class=\"detail-actions\">\n <button type=\"button\" class=\"btn\" id=\"copy-json\" disabled>Copy JSON</button>\n <button type=\"button\" class=\"btn\" id=\"copy-raw\" disabled>Copy raw line</button>\n <label class=\"check\" title=\"Truncate very long strings in the JSON preview\">\n <input type=\"checkbox\" id=\"truncate-strings\" checked />\n Truncate long strings\n </label>\n </div>\n </div>\n <div id=\"detail-images\" class=\"detail-images\" hidden aria-label=\"Event images\"></div>\n <div id=\"detail-pretty\" class=\"detail-pretty\" hidden aria-label=\"Formatted event view\"></div>\n <div class=\"json-toggle\">\n <span class=\"json-toggle-label\">Raw JSON</span>\n </div>\n <pre id=\"detail-json\" aria-label=\"Selected event JSON\" class=\"empty\">Load a file to get started.</pre>\n </aside>\n </main>\n </div>\n\n <div id=\"image-modal\" class=\"image-modal\" role=\"dialog\" aria-modal=\"true\" aria-hidden=\"true\">\n <div class=\"dialog\">\n <button type=\"button\" class=\"close\" id=\"image-modal-close\" aria-label=\"Close image\">Close</button>\n <img id=\"image-modal-img\" alt=\"\" />\n </div>\n </div>\n\n <div id=\"prompts-modal\" class=\"prompts-modal\" role=\"dialog\" aria-modal=\"true\" aria-hidden=\"true\" aria-label=\"User prompts\">\n <div class=\"dialog\">\n <div class=\"prompts-head\">\n <div class=\"prompts-title\">User prompts</div>\n <button type=\"button\" class=\"btn\" id=\"prompts-close\" aria-label=\"Close prompts\">Close</button>\n </div>\n <textarea id=\"prompts-text\" readonly spellcheck=\"false\"></textarea>\n <div class=\"prompts-actions\">\n <button type=\"button\" class=\"btn primary\" id=\"prompts-copy\">Copy</button>\n </div>\n </div>\n </div>\n\n <script>\n const els = {\n dropzone: document.getElementById('dropzone'),\n fileInput: document.getElementById('file-input'),\n pasteInput: document.getElementById('paste-input'),\n renderPaste: document.getElementById('render-paste'),\n urlInput: document.getElementById('url-input'),\n fetchUrl: document.getElementById('fetch-url'),\n status: document.getElementById('status'),\n timeline: document.getElementById('timeline'),\n detailPanel: document.querySelector('aside.detail'),\n detailTitle: document.getElementById('detail-title'),\n detailImages: document.getElementById('detail-images'),\n detailPretty: document.getElementById('detail-pretty'),\n detailJson: document.getElementById('detail-json'),\n copyJson: document.getElementById('copy-json'),\n copyRaw: document.getElementById('copy-raw'),\n truncateStrings: document.getElementById('truncate-strings'),\n tzLocal: document.getElementById('tz-local'),\n tzUtc: document.getElementById('tz-utc'),\n search: document.getElementById('search'),\n typeFilter: document.getElementById('type-filter'),\n contentTypeFilter: document.getElementById('content-type-filter'),\n roleFilter: document.getElementById('role-filter'),\n hideThinking: document.getElementById('hide-thinking'),\n statsShowing: document.getElementById('stats-showing'),\n statsTotal: document.getElementById('stats-total'),\n statsRange: document.getElementById('stats-range'),\n clearFilters: document.getElementById('clear-filters'),\n imageModal: document.getElementById('image-modal'),\n imageModalImg: document.getElementById('image-modal-img'),\n imageModalClose: document.getElementById('image-modal-close'),\n showPrompts: document.getElementById('show-prompts'),\n promptsModal: document.getElementById('prompts-modal'),\n promptsClose: document.getElementById('prompts-close'),\n promptsText: document.getElementById('prompts-text'),\n promptsCopy: document.getElementById('prompts-copy'),\n };\n\n const state = {\n sourceLabel: null,\n tzMode: 'local',\n events: [],\n filtered: [],\n selectedId: null,\n selected: null,\n filters: {\n query: '',\n type: 'all',\n contentType: 'all',\n role: 'all',\n hideThinking: false,\n },\n };\n\n let lastActiveElementBeforeModal = null;\n let lastBodyOverflow = null;\n let suppressUrlStateSync = false;\n const wideLayoutMq = window.matchMedia('(min-width: 1060px)');\n const detailDock = document.createElement('div');\n detailDock.id = 'detail-dock';\n detailDock.style.display = 'none';\n els.detailPanel.parentElement.insertBefore(detailDock, els.detailPanel);\n\n function updateDetailPlacement() {\n const inline = !wideLayoutMq.matches;\n\n if (!inline) {\n if (els.detailPanel.previousSibling !== detailDock) detailDock.after(els.detailPanel);\n els.detailPanel.classList.remove('inline');\n return;\n }\n\n const selectedNode = els.timeline.querySelector('.event.selected');\n if (!selectedNode) {\n if (els.detailPanel.previousSibling !== detailDock) detailDock.after(els.detailPanel);\n els.detailPanel.classList.remove('inline');\n return;\n }\n\n if (selectedNode.nextSibling !== els.detailPanel) selectedNode.after(els.detailPanel);\n els.detailPanel.classList.add('inline');\n }\n\n function setStatus(message, kind = 'good') {\n if (!message) {\n els.status.className = 'status';\n els.status.textContent = '';\n return;\n }\n els.status.className = `status visible ${kind}`;\n els.status.textContent = message;\n }\n\n function pad2(number) {\n return String(number).padStart(2, '0');\n }\n\n function pad3(number) {\n return String(number).padStart(3, '0');\n }\n\n function getOffsetString(date) {\n const offsetMinutes = -date.getTimezoneOffset();\n const sign = offsetMinutes >= 0 ? '+' : '-';\n const abs = Math.abs(offsetMinutes);\n const hours = Math.floor(abs / 60);\n const minutes = abs % 60;\n return `${sign}${pad2(hours)}:${pad2(minutes)}`;\n }\n\n function formatTimestamp(date, tzMode) {\n if (!(date instanceof Date) || Number.isNaN(date.getTime())) return '-';\n if (tzMode === 'utc') {\n return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ` +\n `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}:${pad2(date.getUTCSeconds())}.` +\n `${pad3(date.getUTCMilliseconds())}Z`;\n }\n return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ` +\n `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}.` +\n `${pad3(date.getMilliseconds())} ${getOffsetString(date)}`;\n }\n\n function dayKey(date, tzMode) {\n if (!(date instanceof Date) || Number.isNaN(date.getTime())) return '-';\n if (tzMode === 'utc') {\n return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())}`;\n }\n return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;\n }\n\n function formatDelta(ms) {\n if (typeof ms !== 'number' || !Number.isFinite(ms)) return '';\n const abs = Math.abs(ms);\n const sign = ms < 0 ? '-' : '+';\n if (abs < 1000) return `${sign}${Math.round(abs)}ms`;\n if (abs < 60_000) return `${sign}${(abs / 1000).toFixed(abs < 10_000 ? 2 : 1)}s`;\n if (abs < 3_600_000) {\n const m = Math.floor(abs / 60_000);\n const s = Math.floor((abs % 60_000) / 1000);\n return `${sign}${m}m ${pad2(s)}s`;\n }\n if (abs < 86_400_000) {\n const h = Math.floor(abs / 3_600_000);\n const m = Math.floor((abs % 3_600_000) / 60_000);\n return `${sign}${h}h ${m}m`;\n }\n const d = Math.floor(abs / 86_400_000);\n const h = Math.floor((abs % 86_400_000) / 3_600_000);\n return `${sign}${d}d ${h}h`;\n }\n\n function safeJsonParse(text) {\n try {\n return { ok: true, value: JSON.parse(text) };\n } catch (error) {\n return { ok: false, error };\n }\n }\n\n function extractMessageText(message) {\n if (!message || typeof message !== 'object') return null;\n const content = message.content;\n if (typeof content === 'string') return content;\n if (!Array.isArray(content)) return null;\n const parts = [];\n for (const item of content) {\n if (!item || typeof item !== 'object') continue;\n if (typeof item.text === 'string') parts.push(item.text);\n else if (typeof item.thinking === 'string') parts.push(item.thinking);\n }\n const joined = parts.join('');\n return joined || null;\n }\n\n function getContentTypes(message) {\n if (!message || typeof message !== 'object') return [];\n const content = message.content;\n if (typeof content === 'string') return ['text'];\n if (!Array.isArray(content)) return [];\n const types = new Set();\n for (const item of content) {\n if (!item || typeof item !== 'object') continue;\n if (item.type) types.add(item.type);\n }\n return Array.from(types);\n }\n\n function firstLine(text) {\n if (typeof text !== 'string') return null;\n const trimmed = text.trim();\n if (!trimmed) return null;\n const idx = trimmed.indexOf('\\n');\n return idx === -1 ? trimmed : trimmed.slice(0, idx).trim();\n }\n\n function normalizeSummaryText(text, maxLen = 260) {\n if (typeof text !== 'string') return '';\n const singleLine = text.replace(/\\s+/g, ' ').trim();\n if (singleLine.length <= maxLen) return singleLine;\n return singleLine.slice(0, maxLen - 1) + '...';\n }\n\n function summarizeEvent(obj) {\n const kind = obj?.type || '';\n const message = obj?.message;\n\n if (kind === 'file-history-snapshot') {\n const msgId = obj?.messageId ? `msg=${obj.messageId.slice(0, 8)}...` : null;\n return normalizeSummaryText(msgId || 'file-history-snapshot');\n }\n\n if (kind === 'user' || kind === 'assistant') {\n const role = message?.role || kind;\n const content = message?.content;\n\n if (typeof content === 'string') {\n return normalizeSummaryText(`${role}: ${content}`);\n }\n\n if (Array.isArray(content) && content.length > 0) {\n for (const item of content) {\n if (item?.type === 'text' && item.text) {\n return normalizeSummaryText(`${role}: ${item.text}`);\n }\n if (item?.type === 'thinking' && item.thinking) {\n return normalizeSummaryText(`${role} (thinking): ${item.thinking}`);\n }\n if (item?.type === 'tool_use') {\n const name = item.name || 'tool';\n const input = item.input;\n if (name === 'Bash' && input?.command) {\n return normalizeSummaryText(`${name}: ${input.command}`);\n }\n if (name === 'Read' && input?.file_path) {\n return normalizeSummaryText(`${name}: ${input.file_path}`);\n }\n if (name === 'Write' && input?.file_path) {\n return normalizeSummaryText(`${name}: ${input.file_path}`);\n }\n if (name === 'Edit' && input?.file_path) {\n return normalizeSummaryText(`${name}: ${input.file_path}`);\n }\n if (name === 'Glob' && input?.pattern) {\n return normalizeSummaryText(`${name}: ${input.pattern}`);\n }\n if (name === 'Grep' && input?.pattern) {\n return normalizeSummaryText(`${name}: ${input.pattern}`);\n }\n if (name === 'Task' && input?.description) {\n return normalizeSummaryText(`${name}: ${input.description}`);\n }\n if (input && typeof input === 'object') {\n const keys = Object.keys(input).slice(0, 3);\n return normalizeSummaryText(`${name}(${keys.join(', ')}${Object.keys(input).length > 3 ? ', ...' : ''})`);\n }\n return normalizeSummaryText(`${name}()`);\n }\n if (item?.type === 'tool_result') {\n const toolUseId = item.tool_use_id ? item.tool_use_id.slice(0, 12) : 'result';\n const resultContent = item.content;\n if (typeof resultContent === 'string') {\n return normalizeSummaryText(`tool_result: ${firstLine(resultContent) || toolUseId}`);\n }\n if (Array.isArray(resultContent)) {\n for (const rc of resultContent) {\n if (rc?.type === 'text' && rc.text) {\n return normalizeSummaryText(`tool_result: ${firstLine(rc.text) || toolUseId}`);\n }\n if (rc?.type === 'image') {\n return normalizeSummaryText(`tool_result: [image]`);\n }\n }\n }\n return normalizeSummaryText(`tool_result: ${toolUseId}`);\n }\n if (item?.type === 'image') {\n return normalizeSummaryText(`${role}: [image]`);\n }\n }\n return normalizeSummaryText(`${role}: [${content.length} content items]`);\n }\n\n return kind;\n }\n\n return kind || 'event';\n }\n\n function buildSearchText(obj, summary) {\n const pieces = [];\n pieces.push(obj?.type || '');\n pieces.push(obj?.uuid || '');\n pieces.push(obj?.sessionId || '');\n pieces.push(obj?.cwd || '');\n pieces.push(obj?.gitBranch || '');\n pieces.push(obj?.slug || '');\n const message = obj?.message;\n if (message && typeof message === 'object') {\n pieces.push(message.role || '');\n const msg = extractMessageText(message);\n if (msg) pieces.push(msg);\n const content = message.content;\n if (Array.isArray(content)) {\n for (const item of content) {\n if (item?.type === 'tool_use') {\n pieces.push(item.name || '');\n if (item.input && typeof item.input === 'object') {\n pieces.push(JSON.stringify(item.input).slice(0, 500));\n }\n }\n if (item?.type === 'tool_result' && typeof item.content === 'string') {\n pieces.push(item.content.slice(0, 500));\n }\n }\n }\n }\n if (typeof summary === 'string') pieces.push(summary);\n return pieces.join('\\n').toLowerCase();\n }\n\n function stringifyJson(value, truncateLongStrings) {\n const maxLen = 8000;\n return JSON.stringify(value, (key, v) => {\n if (!truncateLongStrings) return v;\n if (typeof v === 'string' && v.length > maxLen) {\n return v.slice(0, maxLen) + `... [truncated ${v.length - maxLen} chars]`;\n }\n return v;\n }, 2);\n }\n\n function updateFilterOptions(events) {\n const typeSet = new Set();\n const contentTypeSet = new Set();\n const roleSet = new Set();\n for (const e of events) {\n if (e.kind) typeSet.add(e.kind);\n for (const ct of e.contentTypes) {\n contentTypeSet.add(ct);\n }\n if (!e.contentTypes.length) contentTypeSet.add('(none)');\n if (e.role) roleSet.add(e.role);\n }\n\n function setOptions(select, items, current) {\n select.innerHTML = '';\n const optAll = document.createElement('option');\n optAll.value = 'all';\n optAll.textContent = 'All';\n select.appendChild(optAll);\n for (const item of items) {\n const opt = document.createElement('option');\n opt.value = item;\n opt.textContent = item;\n select.appendChild(opt);\n }\n select.value = current;\n }\n\n setOptions(els.typeFilter, Array.from(typeSet).sort(), state.filters.type);\n setOptions(els.contentTypeFilter, Array.from(contentTypeSet).sort(), state.filters.contentType);\n setOptions(els.roleFilter, Array.from(roleSet).sort(), state.filters.role);\n\n if (events.length) {\n state.filters.type = els.typeFilter.value || 'all';\n state.filters.contentType = els.contentTypeFilter.value || 'all';\n state.filters.role = els.roleFilter.value || 'all';\n }\n }\n\n function readHashParams() {\n const raw = window.location.hash && window.location.hash.startsWith('#')\n ? window.location.hash.slice(1)\n : '';\n return new URLSearchParams(raw);\n }\n\n function writeHashParams(params) {\n const base = window.location.href.split('#')[0];\n const hash = params.toString();\n const next = base + (hash ? `#${hash}` : '');\n window.history.replaceState(null, '', next);\n }\n\n function syncUrlHashFromControls({ clearSel = false } = {}) {\n if (suppressUrlStateSync) return;\n const params = readHashParams();\n params.set('tz', state.tzMode);\n params.set('q', (els.search.value || '').trim());\n params.set('type', state.filters.type || 'all');\n params.set('ct', state.filters.contentType || 'all');\n params.set('role', state.filters.role || 'all');\n params.set('hide', state.filters.hideThinking ? '1' : '0');\n params.set('truncate', els.truncateStrings.checked ? '1' : '0');\n const selectedLine = state.selected?.line;\n if (typeof selectedLine === 'number' && Number.isFinite(selectedLine)) {\n params.set('sel', String(selectedLine));\n } else if (clearSel) {\n params.delete('sel');\n }\n writeHashParams(params);\n }\n\n function applySelectionFromHash() {\n if (!state.events.length) return;\n const params = readHashParams();\n const raw = params.get('sel');\n if (!raw) return;\n const line = Number(raw);\n if (!Number.isFinite(line)) return;\n const found = state.events.find(e => e.line === line);\n if (!found) return;\n if (state.selectedId === found.id) return;\n suppressUrlStateSync = true;\n try {\n selectEvent(found.id, { scroll: true });\n } finally {\n suppressUrlStateSync = false;\n }\n }\n\n function applyControlsFromHash() {\n const params = readHashParams();\n suppressUrlStateSync = true;\n try {\n const q = params.get('q');\n if (q !== null) {\n els.search.value = q;\n state.filters.query = q.trim().toLowerCase();\n }\n\n const type = params.get('type');\n if (type !== null) state.filters.type = type;\n const ct = params.get('ct');\n if (ct !== null) state.filters.contentType = ct;\n const role = params.get('role');\n if (role !== null) state.filters.role = role;\n\n const hide = params.get('hide');\n if (hide !== null) {\n els.hideThinking.checked = hide === '1' || hide === 'true';\n state.filters.hideThinking = els.hideThinking.checked;\n }\n\n const truncate = params.get('truncate');\n if (truncate !== null) {\n els.truncateStrings.checked = truncate === '1' || truncate === 'true';\n }\n\n const tz = params.get('tz');\n if (tz === 'utc' || tz === 'local') {\n state.tzMode = tz;\n els.tzLocal.classList.toggle('active', tz === 'local');\n els.tzUtc.classList.toggle('active', tz === 'utc');\n }\n } finally {\n suppressUrlStateSync = false;\n }\n }\n\n function applyFilters() {\n const query = state.filters.query;\n const type = state.filters.type;\n const contentType = state.filters.contentType;\n const role = state.filters.role;\n const hideThinking = state.filters.hideThinking;\n\n const out = [];\n for (const e of state.events) {\n if (hideThinking && e.contentTypes.includes('thinking') && e.contentTypes.length === 1) continue;\n if (type !== 'all' && e.kind !== type) continue;\n if (contentType !== 'all') {\n const ct = e.contentTypes.length ? e.contentTypes : ['(none)'];\n if (!ct.includes(contentType)) continue;\n }\n if (role !== 'all' && e.role !== role) continue;\n if (query && !e.search.includes(query)) continue;\n out.push(e);\n }\n\n state.filtered = out;\n els.statsShowing.textContent = String(out.length);\n els.statsTotal.textContent = String(state.events.length);\n\n const start = out.find(e => e.date instanceof Date && !Number.isNaN(e.date.getTime()))?.date;\n const end = [...out].reverse().find(e => e.date instanceof Date && !Number.isNaN(e.date.getTime()))?.date;\n if (start && end) {\n els.statsRange.textContent = `${formatTimestamp(start, state.tzMode)} -> ${formatTimestamp(end, state.tzMode)}`;\n } else {\n els.statsRange.textContent = '-';\n }\n\n renderTimeline();\n }\n\n function clearSelection({ syncHash = true } = {}) {\n state.selectedId = null;\n state.selected = null;\n els.detailTitle.textContent = 'Select an event';\n renderDetailImages(null);\n renderPrettyView(null);\n els.detailJson.textContent = state.events.length ? 'Select an event from the timeline.' : 'Load a file to get started.';\n els.detailJson.classList.add('empty');\n els.copyJson.disabled = true;\n els.copyRaw.disabled = true;\n for (const node of els.timeline.querySelectorAll('.event.selected')) {\n node.classList.remove('selected');\n }\n updateDetailPlacement();\n if (syncHash) syncUrlHashFromControls({ clearSel: true });\n }\n\n function renderTimeline() {\n els.timeline.innerHTML = '';\n\n if (!state.filtered.length) {\n const empty = document.createElement('div');\n empty.className = 'empty';\n empty.textContent = state.events.length ? 'No events match your filters.' : 'Load a file to see a timeline.';\n els.timeline.appendChild(empty);\n clearSelection();\n return;\n }\n\n const fragment = document.createDocumentFragment();\n let lastDay = null;\n let prevDate = null;\n\n for (const e of state.filtered) {\n const day = dayKey(e.date, state.tzMode);\n if (day !== lastDay) {\n lastDay = day;\n const sep = document.createElement('div');\n sep.className = 'day-sep';\n sep.textContent = day;\n fragment.appendChild(sep);\n }\n\n const row = document.createElement('div');\n row.className = 'event';\n row.setAttribute('role', 'listitem');\n row.dataset.eventId = String(e.id);\n\n const time = document.createElement('div');\n time.className = 'time';\n\n const ts = document.createElement('div');\n ts.className = 'ts';\n ts.textContent = formatTimestamp(e.date, state.tzMode);\n ts.dataset.kind = 'timestamp';\n\n const meta = document.createElement('div');\n meta.textContent = [\n `#${e.line}`,\n (prevDate && e.date) ? formatDelta(e.date.getTime() - prevDate.getTime()) : null,\n ].filter(Boolean).join(' . ');\n\n time.appendChild(ts);\n time.appendChild(meta);\n\n const body = document.createElement('div');\n\n const badges = document.createElement('div');\n badges.className = 'badges';\n const badgeKind = document.createElement('span');\n badgeKind.className = `badge k-${e.kind}`;\n badgeKind.textContent = e.kind;\n badges.appendChild(badgeKind);\n\n for (const ct of e.contentTypes.slice(0, 2)) {\n const badgeCt = document.createElement('span');\n badgeCt.className = `badge ct-${ct}`;\n badgeCt.textContent = ct;\n badges.appendChild(badgeCt);\n }\n if (e.contentTypes.length > 2) {\n const badgeMore = document.createElement('span');\n badgeMore.className = 'badge';\n badgeMore.textContent = `+${e.contentTypes.length - 2}`;\n badges.appendChild(badgeMore);\n }\n\n if (e.role) {\n const badgeRole = document.createElement('span');\n badgeRole.className = `badge role-${e.role}`;\n badgeRole.textContent = e.role;\n badges.appendChild(badgeRole);\n }\n\n const summary = document.createElement('div');\n summary.className = 'summary';\n summary.textContent = e.summary;\n\n body.appendChild(badges);\n body.appendChild(summary);\n\n row.appendChild(time);\n row.appendChild(body);\n\n row.addEventListener('click', () => selectEvent(e.id, { focusTimeline: true }));\n fragment.appendChild(row);\n\n if (e.date instanceof Date && !Number.isNaN(e.date.getTime())) prevDate = e.date;\n }\n\n els.timeline.appendChild(fragment);\n\n if (state.selectedId !== null) {\n const stillThere = state.filtered.some(e => e.id === state.selectedId);\n if (!stillThere) clearSelection();\n else selectEvent(state.selectedId, { scroll: false });\n } else {\n clearSelection({ syncHash: false });\n }\n }\n\n function selectEvent(id, { scroll = false, focusTimeline = false } = {}) {\n const e = state.filtered.find(item => item.id === id) || state.events.find(item => item.id === id);\n if (!e) return;\n state.selectedId = id;\n state.selected = e;\n\n if (focusTimeline) {\n els.timeline.focus({ preventScroll: true });\n }\n\n for (const node of els.timeline.querySelectorAll('.event.selected')) {\n node.classList.remove('selected');\n }\n const node = els.timeline.querySelector(`.event[data-event-id=\"${CSS.escape(String(id))}\"]`);\n if (node) {\n node.classList.add('selected');\n updateDetailPlacement();\n if (scroll) node.scrollIntoView({ block: 'center' });\n }\n\n els.detailJson.classList.remove('empty');\n const titleParts = [\n formatTimestamp(e.date, state.tzMode),\n `#${e.line}`,\n e.kind,\n e.role ? `(${e.role})` : null,\n ].filter(Boolean);\n els.detailTitle.textContent = titleParts.join(' ');\n\n const shouldTruncate = els.truncateStrings.checked;\n renderDetailImages(e.obj);\n renderPrettyView(e.obj);\n els.detailJson.textContent = stringifyJson(e.obj, shouldTruncate) || '';\n els.copyJson.disabled = false;\n els.copyRaw.disabled = false;\n updateDetailPlacement();\n syncUrlHashFromControls();\n }\n\n function extractImageUrls(obj) {\n const urls = [];\n const message = obj?.message;\n if (!message || typeof message !== 'object') return urls;\n const content = message.content;\n if (!Array.isArray(content)) return urls;\n\n for (const item of content) {\n if (!item || typeof item !== 'object') continue;\n\n // Direct image in content\n if (item.type === 'image' && item.source?.type === 'base64' && item.source?.data) {\n const mediaType = item.source.media_type || 'image/png';\n urls.push(`data:${mediaType};base64,${item.source.data}`);\n }\n\n // Image in tool_result content\n if (item.type === 'tool_result' && Array.isArray(item.content)) {\n for (const rc of item.content) {\n if (rc?.type === 'image' && rc.source?.type === 'base64' && rc.source?.data) {\n const mediaType = rc.source.media_type || 'image/png';\n urls.push(`data:${mediaType};base64,${rc.source.data}`);\n }\n }\n }\n }\n\n return urls;\n }\n\n function escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n\n // Initialize markdown-it with GFM-like settings\n const md = window.markdownit({\n html: false, // Disable raw HTML input\n linkify: true, // Auto-convert URLs to links\n typographer: true, // Smart quotes, dashes\n breaks: true // Convert \\n to <br>\n });\n\n function renderMarkdown(text) {\n if (typeof text !== 'string') return '';\n\n // Render markdown to HTML\n const html = md.render(text);\n\n // Sanitize with DOMPurify to prevent XSS\n return DOMPurify.sanitize(html);\n }\n\n function renderPrettyView(obj) {\n els.detailPretty.innerHTML = '';\n\n if (!obj || typeof obj !== 'object') {\n els.detailPretty.hidden = true;\n return;\n }\n\n const message = obj.message;\n if (!message || typeof message !== 'object') {\n els.detailPretty.hidden = true;\n return;\n }\n\n const content = message.content;\n const fragment = document.createDocumentFragment();\n let hasContent = false;\n\n // Handle string content (simple text message)\n if (typeof content === 'string') {\n const section = document.createElement('div');\n section.className = 'pretty-section';\n const label = document.createElement('div');\n label.className = 'pretty-label';\n label.textContent = message.role || 'Message';\n const body = document.createElement('div');\n body.className = 'pretty-content';\n body.innerHTML = renderMarkdown(content);\n section.appendChild(label);\n section.appendChild(body);\n fragment.appendChild(section);\n hasContent = true;\n }\n\n // Handle array content\n if (Array.isArray(content)) {\n for (const item of content) {\n if (!item || typeof item !== 'object') continue;\n\n // Text content\n if (item.type === 'text' && item.text) {\n const section = document.createElement('div');\n section.className = 'pretty-section';\n const label = document.createElement('div');\n label.className = 'pretty-label';\n label.textContent = message.role === 'assistant' ? 'Response' : 'Message';\n const body = document.createElement('div');\n body.className = 'pretty-content';\n body.innerHTML = renderMarkdown(item.text);\n section.appendChild(label);\n section.appendChild(body);\n fragment.appendChild(section);\n hasContent = true;\n }\n\n // Thinking content\n if (item.type === 'thinking' && item.thinking) {\n const section = document.createElement('div');\n section.className = 'pretty-section';\n const label = document.createElement('div');\n label.className = 'pretty-label';\n label.textContent = 'Thinking';\n const body = document.createElement('div');\n body.className = 'thinking-block pretty-content';\n body.innerHTML = renderMarkdown(item.thinking);\n section.appendChild(label);\n section.appendChild(body);\n fragment.appendChild(section);\n hasContent = true;\n }\n\n // Tool use\n if (item.type === 'tool_use') {\n const section = document.createElement('div');\n section.className = 'pretty-section';\n const label = document.createElement('div');\n label.className = 'pretty-label';\n label.textContent = 'Tool Call';\n\n const toolName = item.name || 'Unknown Tool';\n const input = item.input || {};\n\n // Special handling for Bash command\n if (toolName === 'Bash' && input.command) {\n const block = document.createElement('div');\n block.className = 'command-block';\n\n const header = document.createElement('div');\n header.className = 'command-header';\n const nameSpan = document.createElement('span');\n nameSpan.className = 'tool-name';\n nameSpan.textContent = toolName;\n header.appendChild(nameSpan);\n if (input.description) {\n const descSpan = document.createElement('span');\n descSpan.className = 'tool-desc';\n descSpan.textContent = input.description;\n header.appendChild(descSpan);\n }\n\n const cmdInput = document.createElement('div');\n cmdInput.className = 'command-input';\n cmdInput.textContent = input.command;\n\n block.appendChild(header);\n block.appendChild(cmdInput);\n section.appendChild(label);\n section.appendChild(block);\n }\n // Special handling for file operations\n else if (['Read', 'Write', 'Edit', 'Glob', 'Grep'].includes(toolName)) {\n const block = document.createElement('div');\n block.className = 'command-block';\n\n const header = document.createElement('div');\n header.className = 'command-header';\n const nameSpan = document.createElement('span');\n nameSpan.className = 'tool-name';\n nameSpan.textContent = toolName;\n header.appendChild(nameSpan);\n\n block.appendChild(header);\n\n const params = document.createElement('div');\n params.className = 'tool-params';\n params.style.padding = '10px 12px';\n\n for (const [key, value] of Object.entries(input)) {\n const param = document.createElement('div');\n param.className = 'tool-param';\n const paramName = document.createElement('span');\n paramName.className = 'tool-param-name';\n paramName.textContent = key;\n const paramValue = document.createElement('span');\n paramValue.className = 'tool-param-value';\n if (typeof value === 'string') {\n paramValue.textContent = value.length > 500 ? value.slice(0, 500) + '...' : value;\n } else {\n paramValue.textContent = JSON.stringify(value, null, 2);\n }\n param.appendChild(paramName);\n param.appendChild(paramValue);\n params.appendChild(param);\n }\n\n block.appendChild(params);\n section.appendChild(label);\n section.appendChild(block);\n }\n // Generic tool display\n else {\n const block = document.createElement('div');\n block.className = 'command-block';\n\n const header = document.createElement('div');\n header.className = 'command-header';\n const nameSpan = document.createElement('span');\n nameSpan.className = 'tool-name';\n nameSpan.textContent = toolName;\n header.appendChild(nameSpan);\n\n block.appendChild(header);\n\n if (Object.keys(input).length > 0) {\n const params = document.createElement('div');\n params.className = 'tool-params';\n params.style.padding = '10px 12px';\n\n for (const [key, value] of Object.entries(input)) {\n const param = document.createElement('div');\n param.className = 'tool-param';\n const paramName = document.createElement('span');\n paramName.className = 'tool-param-name';\n paramName.textContent = key;\n const paramValue = document.createElement('span');\n paramValue.className = 'tool-param-value';\n if (typeof value === 'string') {\n paramValue.textContent = value.length > 1000 ? value.slice(0, 1000) + '...' : value;\n } else {\n paramValue.textContent = JSON.stringify(value, null, 2);\n }\n param.appendChild(paramName);\n param.appendChild(paramValue);\n params.appendChild(param);\n }\n\n block.appendChild(params);\n }\n\n section.appendChild(label);\n section.appendChild(block);\n }\n\n fragment.appendChild(section);\n hasContent = true;\n }\n\n // Tool result\n if (item.type === 'tool_result') {\n const section = document.createElement('div');\n section.className = 'pretty-section';\n const label = document.createElement('div');\n label.className = 'pretty-label';\n label.textContent = item.is_error ? 'Tool Error' : 'Tool Result';\n\n const resultContent = item.content;\n let resultText = '';\n\n if (typeof resultContent === 'string') {\n resultText = resultContent;\n } else if (Array.isArray(resultContent)) {\n for (const rc of resultContent) {\n if (rc?.type === 'text' && rc.text) {\n resultText += rc.text;\n }\n }\n }\n\n if (resultText) {\n const block = document.createElement('div');\n block.className = 'pretty-content' + (item.is_error ? ' result-error' : '');\n const displayText = resultText.length > 5000 ? resultText.slice(0, 5000) + '\\n... [truncated]' : resultText;\n block.innerHTML = renderMarkdown(displayText);\n section.appendChild(label);\n section.appendChild(block);\n fragment.appendChild(section);\n hasContent = true;\n }\n }\n }\n }\n\n if (hasContent) {\n els.detailPretty.appendChild(fragment);\n els.detailPretty.hidden = false;\n } else {\n els.detailPretty.hidden = true;\n }\n }\n\n function openImageModal(src, altText = '') {\n if (!src) return;\n lastActiveElementBeforeModal = document.activeElement instanceof HTMLElement ? document.activeElement : null;\n lastBodyOverflow = document.body.style.overflow;\n document.body.style.overflow = 'hidden';\n\n els.imageModalImg.src = src;\n els.imageModalImg.alt = altText;\n els.imageModal.classList.add('open');\n els.imageModal.setAttribute('aria-hidden', 'false');\n els.imageModalClose.focus();\n }\n\n function closeImageModal() {\n if (!els.imageModal.classList.contains('open')) return;\n els.imageModal.classList.remove('open');\n els.imageModal.setAttribute('aria-hidden', 'true');\n els.imageModalImg.removeAttribute('src');\n els.imageModalImg.alt = '';\n document.body.style.overflow = lastBodyOverflow ?? '';\n\n if (lastActiveElementBeforeModal) {\n lastActiveElementBeforeModal.focus();\n }\n lastActiveElementBeforeModal = null;\n lastBodyOverflow = null;\n }\n\n function renderDetailImages(obj) {\n const urls = extractImageUrls(obj);\n els.detailImages.innerHTML = '';\n if (!urls.length) {\n els.detailImages.hidden = true;\n return;\n }\n const fragment = document.createDocumentFragment();\n urls.forEach((src, idx) => {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'thumb';\n btn.title = `Open image ${idx + 1}`;\n const img = document.createElement('img');\n img.loading = 'lazy';\n img.src = src;\n img.alt = `Image ${idx + 1}`;\n btn.appendChild(img);\n btn.addEventListener('click', () => openImageModal(src, `Image ${idx + 1}`));\n fragment.appendChild(btn);\n });\n els.detailImages.appendChild(fragment);\n els.detailImages.hidden = false;\n }\n\n function isEditableElement(el) {\n if (!(el instanceof HTMLElement)) return false;\n if (el.isContentEditable) return true;\n const tag = el.tagName;\n return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';\n }\n\n function moveSelection(delta) {\n if (!state.filtered.length) return;\n const idx = state.filtered.findIndex(e => e.id === state.selectedId);\n if (idx === -1) {\n selectEvent(state.filtered[0].id, { scroll: true });\n return;\n }\n const nextIdx = Math.max(0, Math.min(state.filtered.length - 1, idx + delta));\n if (nextIdx === idx) return;\n selectEvent(state.filtered[nextIdx].id, { scroll: true });\n }\n\n async function copyText(text) {\n if (typeof text !== 'string') return;\n try {\n await navigator.clipboard.writeText(text);\n setStatus('Copied to clipboard.', 'good');\n } catch (error) {\n const ta = document.createElement('textarea');\n ta.value = text;\n ta.style.position = 'fixed';\n ta.style.left = '-9999px';\n document.body.appendChild(ta);\n ta.select();\n document.execCommand('copy');\n document.body.removeChild(ta);\n setStatus('Copied to clipboard.', 'good');\n }\n }\n\n function extractUserPrompts() {\n const prompts = [];\n for (const e of state.events) {\n // Filter: type=user, content-type includes text, role=user\n if (e.kind !== 'user') continue;\n if (e.role !== 'user') continue;\n if (!e.contentTypes.includes('text')) continue;\n\n const message = e.obj?.message;\n if (!message) continue;\n\n const content = message.content;\n if (typeof content === 'string') {\n prompts.push(content);\n } else if (Array.isArray(content)) {\n for (const item of content) {\n if (item?.type === 'text' && item.text) {\n prompts.push(item.text);\n }\n }\n }\n }\n return prompts.join('\\n\\n');\n }\n\n async function copyUserPrompts() {\n const text = extractUserPrompts();\n if (!text) {\n setStatus('No user prompts found.', 'warn');\n return;\n }\n await copyText(text);\n const originalText = els.copyPrompts.textContent;\n els.copyPrompts.textContent = 'Copied!';\n setTimeout(() => {\n els.copyPrompts.textContent = originalText;\n }, 2000);\n }\n\n function setTimezoneMode(mode) {\n state.tzMode = mode;\n els.tzLocal.classList.toggle('active', mode === 'local');\n els.tzUtc.classList.toggle('active', mode === 'utc');\n applyFilters();\n if (state.selectedId !== null) selectEvent(state.selectedId);\n syncUrlHashFromControls();\n }\n\n els.imageModalClose.addEventListener('click', closeImageModal);\n els.imageModal.addEventListener('click', (event) => {\n if (event.target === els.imageModal) closeImageModal();\n });\n document.addEventListener('keydown', (event) => {\n if (event.key === 'Escape') closeImageModal();\n });\n\n document.addEventListener('keydown', (event) => {\n if (els.imageModal.classList.contains('open')) return;\n if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;\n if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;\n if (isEditableElement(document.activeElement)) return;\n if (document.activeElement !== els.timeline && !els.timeline.contains(document.activeElement)) return;\n if (state.selectedId === null) return;\n event.preventDefault();\n moveSelection(event.key === 'ArrowUp' ? -1 : 1);\n });\n\n if (typeof wideLayoutMq.addEventListener === 'function') {\n wideLayoutMq.addEventListener('change', updateDetailPlacement);\n } else if (typeof wideLayoutMq.addListener === 'function') {\n wideLayoutMq.addListener(updateDetailPlacement);\n }\n\n function setTab(tabName) {\n for (const tab of document.querySelectorAll('.tab')) {\n const active = tab.dataset.tab === tabName;\n tab.classList.toggle('active', active);\n tab.setAttribute('aria-selected', active ? 'true' : 'false');\n }\n for (const panel of document.querySelectorAll('[data-panel]')) {\n panel.hidden = panel.dataset.panel !== tabName;\n }\n }\n\n function setShareUrlParam(url) {\n const current = new URL(window.location.href);\n if (url) current.searchParams.set('url', url);\n else current.searchParams.delete('url');\n window.history.replaceState(null, '', current);\n }\n\n async function fetchAndRenderUrl(url, { syncUrl = true } = {}) {\n const trimmed = (url || '').trim();\n if (!trimmed) {\n setStatus('Enter a URL first.', 'error');\n return;\n }\n if (syncUrl) setShareUrlParam(trimmed);\n setStatus(`Fetching ${trimmed}...`, 'warn');\n try {\n const resp = await fetch(trimmed, { mode: 'cors' });\n if (!resp.ok) throw new Error(`HTTP ${resp.status}`);\n const text = await resp.text();\n await loadText(text, trimmed);\n } catch (error) {\n setStatus(`Failed to fetch URL: ${String(error)}`, 'error');\n }\n }\n\n async function loadText(text, sourceLabel) {\n const started = performance.now();\n state.sourceLabel = sourceLabel || null;\n setStatus(`Parsing ${sourceLabel || 'content'}...`, 'warn');\n clearSelection({ syncHash: false });\n\n const lines = text.split(/\\r?\\n/);\n const events = [];\n const errors = [];\n let idCounter = 0;\n\n for (let i = 0; i < lines.length; i++) {\n const line = lines[i];\n if (!line.trim()) continue;\n const parsed = safeJsonParse(line);\n if (!parsed.ok) {\n errors.push({ line: i + 1, error: parsed.error });\n continue;\n }\n const obj = parsed.value;\n const ts = typeof obj.timestamp === 'string' ? new Date(obj.timestamp) : null;\n const kind = obj.type || '(unknown)';\n const message = obj.message && typeof obj.message === 'object' ? obj.message : null;\n const contentTypes = message ? getContentTypes(message) : [];\n const role = message?.role ?? null;\n\n const summary = summarizeEvent(obj);\n events.push({\n id: ++idCounter,\n line: i + 1,\n raw: line,\n obj,\n date: ts,\n kind,\n contentTypes,\n role,\n summary,\n search: buildSearchText(obj, summary),\n });\n }\n\n state.events = events;\n updateFilterOptions(events);\n applyFilters();\n applySelectionFromHash();\n syncUrlHashFromControls();\n\n const durationMs = Math.round(performance.now() - started);\n if (errors.length) {\n const first = errors[0];\n setStatus(\n `Loaded ${events.length} events from ${sourceLabel || 'content'} in ${durationMs}ms.\\n` +\n `Could not parse ${errors.length} line(s). First error at line ${first.line}: ${String(first.error)}`,\n 'warn'\n );\n } else {\n setStatus(`Loaded ${events.length} events from ${sourceLabel || 'content'} in ${durationMs}ms.`, 'good');\n }\n }\n\n async function handleFile(file) {\n if (!file) return;\n setShareUrlParam(null);\n try {\n const text = await file.text();\n await loadText(text, file.name);\n } catch (error) {\n setStatus(`Failed to read file: ${String(error)}`, 'error');\n }\n }\n\n els.dropzone.addEventListener('click', () => els.fileInput.click());\n els.dropzone.addEventListener('keydown', (e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n els.fileInput.click();\n }\n });\n els.fileInput.addEventListener('change', () => handleFile(els.fileInput.files?.[0]));\n\n els.dropzone.addEventListener('dragover', (e) => {\n e.preventDefault();\n els.dropzone.classList.add('dragover');\n });\n els.dropzone.addEventListener('dragleave', () => els.dropzone.classList.remove('dragover'));\n els.dropzone.addEventListener('drop', (e) => {\n e.preventDefault();\n els.dropzone.classList.remove('dragover');\n const file = e.dataTransfer?.files?.[0];\n if (file) handleFile(file);\n });\n\n els.renderPaste.addEventListener('click', async () => {\n const text = els.pasteInput.value || '';\n if (!text.trim()) {\n setStatus('Paste some JSONL content first.', 'error');\n return;\n }\n setShareUrlParam(null);\n await loadText(text, 'pasted content');\n });\n\n els.fetchUrl.addEventListener('click', async () => {\n await fetchAndRenderUrl(els.urlInput.value, { syncUrl: true });\n });\n\n for (const tab of document.querySelectorAll('.tab')) {\n tab.addEventListener('click', () => setTab(tab.dataset.tab));\n }\n\n els.tzLocal.addEventListener('click', () => setTimezoneMode('local'));\n els.tzUtc.addEventListener('click', () => setTimezoneMode('utc'));\n\n els.search.addEventListener('input', () => {\n state.filters.query = els.search.value.trim().toLowerCase();\n applyFilters();\n syncUrlHashFromControls();\n });\n els.typeFilter.addEventListener('change', () => {\n state.filters.type = els.typeFilter.value;\n applyFilters();\n syncUrlHashFromControls();\n });\n els.contentTypeFilter.addEventListener('change', () => {\n state.filters.contentType = els.contentTypeFilter.value;\n applyFilters();\n syncUrlHashFromControls();\n });\n els.roleFilter.addEventListener('change', () => {\n state.filters.role = els.roleFilter.value;\n applyFilters();\n syncUrlHashFromControls();\n });\n els.hideThinking.addEventListener('change', () => {\n state.filters.hideThinking = els.hideThinking.checked;\n applyFilters();\n syncUrlHashFromControls();\n });\n els.truncateStrings.addEventListener('change', () => {\n if (state.selectedId !== null) selectEvent(state.selectedId);\n syncUrlHashFromControls();\n });\n\n els.clearFilters.addEventListener('click', () => {\n els.search.value = '';\n state.filters.query = '';\n state.filters.type = 'all';\n state.filters.contentType = 'all';\n state.filters.role = 'all';\n state.filters.hideThinking = false;\n els.hideThinking.checked = false;\n updateFilterOptions(state.events);\n applyFilters();\n syncUrlHashFromControls();\n });\n\n els.copyJson.addEventListener('click', () => {\n if (!state.selected) return;\n copyText(stringifyJson(state.selected.obj, false));\n });\n els.copyRaw.addEventListener('click', () => {\n if (!state.selected) return;\n copyText(state.selected.raw);\n });\n els.copyPrompts.addEventListener('click', copyUserPrompts);\n\n async function initFromQuery() {\n const url = new URLSearchParams(window.location.search).get('url');\n if (!url) return;\n setTab('url');\n els.urlInput.value = url;\n await fetchAndRenderUrl(url, { syncUrl: false });\n }\n\n // Initialize select options for the empty state.\n applyControlsFromHash();\n updateFilterOptions([]);\n syncUrlHashFromControls();\n window.addEventListener('hashchange', () => {\n applyControlsFromHash();\n updateFilterOptions(state.events);\n applyFilters();\n applySelectionFromHash();\n });\n void initFromQuery();\n </script>\n</body>\n</html>\n","structuredPatch":[{"oldStart":1110,"oldLines":6,"newStart":1110,"newLines":9,"lines":[" "," let lastActiveElementBeforeModal = null;"," let lastBodyOverflow = null;","+ let lastActiveElementBeforePrompts = null;","+ let lastBodyOverflowBeforePrompts = null;","+ let promptsCopiedTimer = null;"," let suppressUrlStateSync = false;"," const wideLayoutMq = window.matchMedia('(min-width: 1060px)');"," const detailDock = document.createElement('div');"]}],"userModified":false,"replaceAll":false}}
{"parentUuid":"6e64025b-f071-4610-ae89-2d2b73f7a379","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","slug":"glittery-skipping-rivest","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01WJ5SH33YY5bfRnUSLypVFJ","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Good. Now I need to add the openPromptsModal and closePromptsModal functions. Let me find a good place to add them - likely near the image modal functions.","signature":"EsQCCkYIChgCKkCCb3larItME8SCrSWt13XyryUntWmro2XuncpofhuwVTXBXf92/8VvWCJoY78g6oR4yCaaswrJUlEZnQRA53MSEgyppq1eqUwPKE19drUaDKxLfwpIcIV0gX1fgiIwfLu8FUMRb4hsuul0pV0Wb0KwLcbheiC7fukToFYZzYX7nNBU90rFfi3s2JR5FzPzKqsBztZ5LLCg/o3LRVTGFmvUUXPDCyaByh38aohSKY9hTkFdNdRNISoNoLu59e1oHCDCKm7OI5Qlwb07McOi7VdV8kxxwh6v0tHm214PX0BACmz0hjERderw3x0fEkpkWJWDyyA8l+eWj6w4uR2KgUQqnuX/df7ArzRC+jognI+XuZCamVswyUQ3thpr+7Q6Sbijw4UxdMASSTJtBIneVvlG+SXW4jLIz7qJqUp0GAE="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":9,"cache_creation_input_tokens":651,"cache_read_input_tokens":89069,"cache_creation":{"ephemeral_5m_input_tokens":651,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWDdbyRMQNSbNi7rEUF1z","type":"assistant","uuid":"58f1d786-c1ee-4f64-a6a4-5c25682e0acd","timestamp":"2025-12-18T05:52:26.588Z"}
{"parentUuid":"58f1d786-c1ee-4f64-a6a4-5c25682e0acd","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","slug":"glittery-skipping-rivest","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01WJ5SH33YY5bfRnUSLypVFJ","type":"message","role":"assistant","content":[{"type":"text","text":"Now I'll add the openPromptsModal and closePromptsModal functions after the copyUserPrompts function. Let me find that function first:"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":9,"cache_creation_input_tokens":651,"cache_read_input_tokens":89069,"cache_creation":{"ephemeral_5m_input_tokens":651,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWDdbyRMQNSbNi7rEUF1z","type":"assistant","uuid":"f48a895d-a6df-46f9-b3dc-5e108a26cad2","timestamp":"2025-12-18T05:52:26.774Z"}
{"parentUuid":"f48a895d-a6df-46f9-b3dc-5e108a26cad2","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","slug":"glittery-skipping-rivest","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01WJ5SH33YY5bfRnUSLypVFJ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Wk2HyjJC2aotZ9KGqpXFK3","name":"Edit","input":{"file_path":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","old_string":" async function copyUserPrompts() {\n const text = extractUserPrompts();\n if (!text) {\n setStatus('No user prompts found.', 'warn');\n return;\n }\n await copyText(text);\n const originalText = els.copyPrompts.textContent;\n els.copyPrompts.textContent = 'Copied!';\n setTimeout(() => {\n els.copyPrompts.textContent = originalText;\n }, 2000);\n }","new_string":" function openPromptsModal(text) {\n lastActiveElementBeforePrompts = document.activeElement instanceof HTMLElement ? document.activeElement : null;\n lastBodyOverflowBeforePrompts = document.body.style.overflow;\n document.body.style.overflow = 'hidden';\n\n els.promptsText.value = text || '';\n els.promptsModal.classList.add('open');\n els.promptsModal.setAttribute('aria-hidden', 'false');\n els.promptsClose.focus();\n els.promptsText.scrollTop = 0;\n }\n\n function closePromptsModal() {\n if (!els.promptsModal.classList.contains('open')) return;\n els.promptsModal.classList.remove('open');\n els.promptsModal.setAttribute('aria-hidden', 'true');\n els.promptsText.value = '';\n document.body.style.overflow = lastBodyOverflowBeforePrompts ?? '';\n\n if (lastActiveElementBeforePrompts) lastActiveElementBeforePrompts.focus();\n lastActiveElementBeforePrompts = null;\n lastBodyOverflowBeforePrompts = null;\n }"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":9,"cache_creation_input_tokens":651,"cache_read_input_tokens":89069,"cache_creation":{"ephemeral_5m_input_tokens":651,"ephemeral_1h_input_tokens":0},"output_tokens":591,"service_tier":"standard"}},"requestId":"req_011CWDdbyRMQNSbNi7rEUF1z","type":"assistant","uuid":"940cefaf-e6b4-4985-8482-3f81d12127a6","timestamp":"2025-12-18T05:52:29.443Z"}
{"parentUuid":"940cefaf-e6b4-4985-8482-3f81d12127a6","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","slug":"glittery-skipping-rivest","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Wk2HyjJC2aotZ9KGqpXFK3","type":"tool_result","content":"The file /Users/simon/Dropbox/dev/tools/claude-code-timeline.html has been updated. Here's the result of running `cat -n` on a snippet of the edited file:\n 2120→ }\n 2121→ return prompts.join('\\n\\n');\n 2122→ }\n 2123→\n 2124→ function openPromptsModal(text) {\n 2125→ lastActiveElementBeforePrompts = document.activeElement instanceof HTMLElement ? document.activeElement : null;\n 2126→ lastBodyOverflowBeforePrompts = document.body.style.overflow;\n 2127→ document.body.style.overflow = 'hidden';\n 2128→\n 2129→ els.promptsText.value = text || '';\n 2130→ els.promptsModal.classList.add('open');\n 2131→ els.promptsModal.setAttribute('aria-hidden', 'false');\n 2132→ els.promptsClose.focus();\n 2133→ els.promptsText.scrollTop = 0;\n 2134→ }\n 2135→\n 2136→ function closePromptsModal() {\n 2137→ if (!els.promptsModal.classList.contains('open')) return;\n 2138→ els.promptsModal.classList.remove('open');\n 2139→ els.promptsModal.setAttribute('aria-hidden', 'true');\n 2140→ els.promptsText.value = '';\n 2141→ document.body.style.overflow = lastBodyOverflowBeforePrompts ?? '';\n 2142→\n 2143→ if (lastActiveElementBeforePrompts) lastActiveElementBeforePrompts.focus();\n 2144→ lastActiveElementBeforePrompts = null;\n 2145→ lastBodyOverflowBeforePrompts = null;\n 2146→ }\n 2147→\n 2148→ function setTimezoneMode(mode) {\n 2149→ state.tzMode = mode;\n 2150→ els.tzLocal.classList.toggle('active', mode === 'local');"}]},"uuid":"eaf8f88c-b81d-41be-be9f-8824c8d03086","timestamp":"2025-12-18T05:52:29.489Z","toolUseResult":{"filePath":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","oldString":" async function copyUserPrompts() {\n const text = extractUserPrompts();\n if (!text) {\n setStatus('No user prompts found.', 'warn');\n return;\n }\n await copyText(text);\n const originalText = els.copyPrompts.textContent;\n els.copyPrompts.textContent = 'Copied!';\n setTimeout(() => {\n els.copyPrompts.textContent = originalText;\n }, 2000);\n }","newString":" function openPromptsModal(text) {\n lastActiveElementBeforePrompts = document.activeElement instanceof HTMLElement ? document.activeElement : null;\n lastBodyOverflowBeforePrompts = document.body.style.overflow;\n document.body.style.overflow = 'hidden';\n\n els.promptsText.value = text || '';\n els.promptsModal.classList.add('open');\n els.promptsModal.setAttribute('aria-hidden', 'false');\n els.promptsClose.focus();\n els.promptsText.scrollTop = 0;\n }\n\n function closePromptsModal() {\n if (!els.promptsModal.classList.contains('open')) return;\n els.promptsModal.classList.remove('open');\n els.promptsModal.setAttribute('aria-hidden', 'true');\n els.promptsText.value = '';\n document.body.style.overflow = lastBodyOverflowBeforePrompts ?? '';\n\n if (lastActiveElementBeforePrompts) lastActiveElementBeforePrompts.focus();\n lastActiveElementBeforePrompts = null;\n lastBodyOverflowBeforePrompts = null;\n }","originalFile":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>Claude Code Timeline Viewer</title>\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js\" integrity=\"sha384-osZDKVu4ipZP703HmPOhWdyBajcFyjX2Psjk//TG1Rc0AdwEtuToaylrmcK3LdAl\" crossorigin=\"anonymous\"></script>\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/markdown-it/4.4.0/markdown-it.min.js\" integrity=\"sha384-cxGp+QDeMxACP73aA0Lj0Sc/h4z1X73+umcvCyI0Vbyh3OYxgrPI+0mxG9slsMTN\" crossorigin=\"anonymous\"></script>\n <style>\n :root {\n color-scheme: light dark;\n --bg: #0b0c0f;\n --panel: #12141a;\n --panel-2: #0f1116;\n --text: #e8eaf0;\n --muted: #a6adbf;\n --line: rgba(255, 255, 255, 0.10);\n --line-2: rgba(255, 255, 255, 0.16);\n --accent: #6aa9ff;\n --accent-2: #8a5cff;\n --good: #46d39a;\n --warn: #ffcc66;\n --bad: #ff6b6b;\n --shadow: 0 18px 50px rgba(0, 0, 0, 0.35);\n --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\",\n \"Courier New\", monospace;\n --sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial,\n \"Apple Color Emoji\", \"Segoe UI Emoji\";\n }\n\n @media (prefers-color-scheme: light) {\n :root {\n --bg: #f7f8fc;\n --panel: #ffffff;\n --panel-2: #ffffff;\n --text: #10121a;\n --muted: #5b647a;\n --line: rgba(16, 18, 26, 0.12);\n --line-2: rgba(16, 18, 26, 0.18);\n --shadow: 0 18px 50px rgba(16, 18, 26, 0.10);\n }\n }\n\n * { box-sizing: border-box; }\n html, body { height: 100%; }\n body {\n margin: 0;\n font-family: var(--sans);\n background: radial-gradient(1200px 600px at 10% -10%, rgba(106, 169, 255, 0.18), transparent 45%),\n radial-gradient(1200px 600px at 90% -20%, rgba(138, 92, 255, 0.16), transparent 50%),\n var(--bg);\n color: var(--text);\n }\n\n .wrap {\n max-width: none;\n margin: 0;\n padding: 22px 16px 42px;\n }\n\n header {\n display: grid;\n gap: 10px;\n margin-bottom: 14px;\n }\n\n h1 {\n margin: 0;\n font-size: clamp(22px, 3vw, 32px);\n letter-spacing: 0.2px;\n }\n\n .sub {\n margin: 6px 0 0;\n color: var(--muted);\n line-height: 1.35;\n max-width: 70ch;\n }\n\n .controls {\n display: flex;\n flex-wrap: wrap;\n gap: 10px;\n align-items: center;\n justify-content: flex-start;\n }\n\n .controls .field {\n display: grid;\n gap: 4px;\n min-width: 180px;\n }\n\n .label {\n font-size: 12px;\n color: var(--muted);\n }\n\n input[type=\"search\"], input[type=\"url\"], select, textarea {\n font: inherit;\n color: inherit;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n border-radius: 10px;\n padding: 10px 11px;\n outline: none;\n }\n\n input[type=\"search\"]:focus, input[type=\"url\"]:focus, select:focus, textarea:focus {\n border-color: color-mix(in srgb, var(--accent) 60%, var(--line));\n box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);\n }\n\n .segmented {\n display: inline-flex;\n border: 1px solid var(--line);\n border-radius: 999px;\n overflow: hidden;\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n }\n\n .segmented button {\n border: none;\n padding: 9px 12px;\n font: inherit;\n background: transparent;\n color: var(--muted);\n cursor: pointer;\n min-width: 74px;\n }\n\n .segmented button.active {\n background: color-mix(in srgb, var(--accent) 16%, transparent);\n color: var(--text);\n }\n\n .segmented button:focus-visible {\n outline: 2px solid color-mix(in srgb, var(--accent) 70%, transparent);\n outline-offset: -2px;\n }\n\n .btn {\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n color: var(--text);\n border-radius: 10px;\n padding: 10px 12px;\n font: inherit;\n cursor: pointer;\n transition: transform 80ms ease, border-color 120ms ease;\n }\n\n .btn:hover { border-color: var(--line-2); }\n .btn:active { transform: translateY(1px); }\n .btn.primary {\n border-color: color-mix(in srgb, var(--accent) 55%, var(--line));\n background: color-mix(in srgb, var(--accent) 14%, transparent);\n }\n\n .btn:disabled {\n opacity: 0.55;\n cursor: not-allowed;\n }\n\n .panel {\n background: var(--panel);\n border: 1px solid var(--line);\n border-radius: 16px;\n box-shadow: var(--shadow);\n }\n\n .input-panel {\n padding: 14px;\n margin-bottom: 14px;\n }\n\n .filters-panel {\n padding: 14px;\n margin-bottom: 14px;\n }\n\n .input-grid {\n display: grid;\n grid-template-columns: 1fr;\n gap: 14px;\n align-items: stretch;\n }\n\n @media (min-width: 920px) {\n .input-grid {\n grid-template-columns: 380px 1fr;\n }\n }\n\n .dropzone {\n border: 1px dashed color-mix(in srgb, var(--accent) 40%, var(--line));\n border-radius: 14px;\n background: linear-gradient(\n 135deg,\n color-mix(in srgb, var(--accent) 12%, transparent),\n transparent 45%,\n color-mix(in srgb, var(--accent-2) 10%, transparent)\n );\n padding: 16px;\n display: grid;\n gap: 6px;\n cursor: pointer;\n min-height: 124px;\n align-content: center;\n }\n\n .dropzone.dragover {\n border-style: solid;\n border-color: color-mix(in srgb, var(--accent) 70%, var(--line));\n box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);\n }\n\n .dropzone strong { font-size: 15px; }\n .muted { color: var(--muted); }\n .tiny { font-size: 12px; color: var(--muted); }\n\n .tabs {\n display: flex;\n gap: 8px;\n margin-bottom: 10px;\n flex-wrap: wrap;\n align-items: center;\n }\n\n .tab {\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel-2) 94%, transparent);\n color: var(--muted);\n border-radius: 999px;\n padding: 7px 10px;\n cursor: pointer;\n font: inherit;\n }\n\n .tab.active {\n color: var(--text);\n border-color: color-mix(in srgb, var(--accent) 55%, var(--line));\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n }\n\n textarea {\n width: 100%;\n min-height: 190px;\n resize: vertical;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.4;\n }\n\n .row {\n display: flex;\n gap: 10px;\n flex-wrap: wrap;\n align-items: center;\n margin-top: 10px;\n }\n\n .status {\n margin-top: 12px;\n padding: 10px 12px;\n border-radius: 12px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel-2) 94%, transparent);\n display: none;\n white-space: pre-wrap;\n word-break: break-word;\n }\n .status.visible { display: block; }\n .status.error { border-color: color-mix(in srgb, var(--bad) 60%, var(--line)); }\n .status.good { border-color: color-mix(in srgb, var(--good) 55%, var(--line)); }\n .status.warn { border-color: color-mix(in srgb, var(--warn) 55%, var(--line)); }\n\n main {\n display: grid;\n gap: 14px;\n grid-template-columns: 1fr;\n min-height: 560px;\n }\n\n @media (min-width: 1060px) {\n main {\n grid-template-columns: 1fr 520px;\n align-items: stretch;\n }\n }\n\n .timeline-panel {\n display: grid;\n grid-template-rows: auto 1fr;\n min-height: 560px;\n }\n\n .stats {\n display: flex;\n gap: 12px;\n flex-wrap: wrap;\n align-items: baseline;\n justify-content: space-between;\n padding: 12px 14px;\n border-bottom: 1px solid var(--line);\n }\n\n .stats .big {\n font-size: 14px;\n font-weight: 600;\n }\n\n .timeline {\n overflow: auto;\n padding: 8px 0;\n }\n\n .timeline:focus-visible {\n outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent);\n outline-offset: -2px;\n border-radius: 14px;\n }\n\n .day-sep {\n position: sticky;\n top: 0;\n z-index: 2;\n padding: 8px 14px;\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n border-top: 1px solid var(--line);\n border-bottom: 1px solid var(--line);\n font-size: 12px;\n color: var(--muted);\n backdrop-filter: blur(6px);\n }\n\n .event {\n display: grid;\n grid-template-columns: 176px 1fr;\n gap: 12px;\n padding: 10px 14px;\n border-bottom: 1px solid color-mix(in srgb, var(--line) 85%, transparent);\n cursor: pointer;\n }\n\n .event:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); }\n .event.selected {\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n outline: 2px solid color-mix(in srgb, var(--accent) 35%, transparent);\n outline-offset: -2px;\n }\n\n .time {\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.35;\n color: var(--muted);\n display: grid;\n gap: 3px;\n }\n\n .time .ts {\n color: var(--text);\n font-weight: 600;\n }\n\n .badges {\n display: flex;\n gap: 6px;\n flex-wrap: wrap;\n align-items: center;\n margin-bottom: 6px;\n }\n\n .badge {\n font-size: 11px;\n font-family: var(--mono);\n padding: 2px 7px;\n border-radius: 999px;\n border: 1px solid var(--line);\n color: var(--muted);\n background: color-mix(in srgb, var(--panel-2) 90%, transparent);\n }\n\n .badge.k-user { border-color: color-mix(in srgb, #00c8ff 55%, var(--line)); color: color-mix(in srgb, #00c8ff 78%, var(--text)); }\n .badge.k-assistant { border-color: color-mix(in srgb, #ff73c4 55%, var(--line)); color: color-mix(in srgb, #ff73c4 78%, var(--text)); }\n .badge.k-file-history-snapshot { border-color: color-mix(in srgb, var(--warn) 55%, var(--line)); color: color-mix(in srgb, var(--warn) 75%, var(--text)); }\n\n .badge.ct-text { border-color: color-mix(in srgb, var(--good) 55%, var(--line)); color: color-mix(in srgb, var(--good) 75%, var(--text)); }\n .badge.ct-thinking { border-color: color-mix(in srgb, var(--accent-2) 55%, var(--line)); color: color-mix(in srgb, var(--accent-2) 75%, var(--text)); }\n .badge.ct-tool_use { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); color: color-mix(in srgb, var(--accent) 75%, var(--text)); }\n .badge.ct-tool_result { border-color: color-mix(in srgb, #8690a8 55%, var(--line)); color: color-mix(in srgb, #8690a8 70%, var(--text)); }\n .badge.ct-image { border-color: color-mix(in srgb, #ff9f43 55%, var(--line)); color: color-mix(in srgb, #ff9f43 75%, var(--text)); }\n\n .badge.role-user { border-color: color-mix(in srgb, #00c8ff 55%, var(--line)); color: color-mix(in srgb, #00c8ff 78%, var(--text)); }\n .badge.role-assistant { border-color: color-mix(in srgb, #ff73c4 55%, var(--line)); color: color-mix(in srgb, #ff73c4 78%, var(--text)); }\n\n .summary {\n font-size: 13px;\n line-height: 1.35;\n color: var(--text);\n word-break: break-word;\n }\n\n .summary code {\n font-family: var(--mono);\n font-size: 12px;\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n padding: 2px 5px;\n border-radius: 7px;\n }\n\n .detail {\n display: grid;\n grid-template-rows: auto auto auto auto 1fr;\n min-height: 560px;\n }\n\n @media (min-width: 1060px) {\n .detail {\n position: sticky;\n top: 16px;\n align-self: start;\n max-height: calc(100vh - 32px);\n }\n }\n\n .detail.inline {\n position: static;\n top: auto;\n align-self: auto;\n max-height: none;\n min-height: 0;\n margin: 8px 14px 12px;\n box-shadow: none;\n }\n\n .detail.inline pre {\n max-height: 52vh;\n }\n\n .detail-head {\n padding: 12px 14px;\n border-bottom: 1px solid var(--line);\n display: grid;\n gap: 8px;\n }\n\n .detail-title {\n font-size: 14px;\n font-weight: 700;\n line-height: 1.25;\n margin: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .detail-actions {\n display: flex;\n gap: 8px;\n flex-wrap: wrap;\n align-items: center;\n justify-content: flex-start;\n }\n\n .check {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n color: var(--muted);\n font-size: 12px;\n user-select: none;\n }\n\n pre {\n margin: 0;\n padding: 12px 14px 16px;\n overflow: auto;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.45;\n color: color-mix(in srgb, var(--text) 92%, transparent);\n background: transparent;\n white-space: pre-wrap;\n word-break: break-word;\n }\n\n .detail-images {\n display: flex;\n gap: 10px;\n padding: 12px 14px;\n border-bottom: 1px solid var(--line);\n overflow-x: auto;\n overscroll-behavior-x: contain;\n }\n\n .thumb {\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel-2) 92%, transparent);\n border-radius: 12px;\n padding: 0;\n cursor: pointer;\n width: 78px;\n height: 58px;\n overflow: hidden;\n flex: 0 0 auto;\n display: grid;\n place-items: stretch;\n }\n\n .thumb:hover { border-color: var(--line-2); }\n\n .thumb img {\n width: 100%;\n height: 100%;\n display: block;\n object-fit: cover;\n }\n\n .image-modal {\n position: fixed;\n inset: 0;\n z-index: 9999;\n background: rgba(0, 0, 0, 0.68);\n display: none;\n padding: 18px;\n place-items: center;\n }\n\n .image-modal.open { display: grid; }\n\n .image-modal .dialog {\n position: relative;\n border-radius: 16px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 98%, transparent);\n box-shadow: var(--shadow);\n padding: 14px;\n max-width: min(1200px, 96vw);\n max-height: 92vh;\n display: grid;\n place-items: center;\n }\n\n .image-modal img {\n display: block;\n max-width: min(1140px, 92vw);\n max-height: 84vh;\n width: auto;\n height: auto;\n object-fit: contain;\n border-radius: 10px;\n background: color-mix(in srgb, var(--panel-2) 92%, transparent);\n }\n\n .image-modal .close {\n position: absolute;\n top: 10px;\n right: 10px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 92%, transparent);\n color: var(--text);\n border-radius: 999px;\n padding: 8px 10px;\n font: inherit;\n cursor: pointer;\n }\n\n .image-modal .close:hover { border-color: var(--line-2); }\n\n .empty {\n padding: 14px;\n color: var(--muted);\n font-size: 13px;\n line-height: 1.35;\n }\n\n /* Pretty view styles */\n .detail-pretty {\n padding: 14px;\n border-bottom: 1px solid var(--line);\n overflow: auto;\n max-height: 50vh;\n }\n\n .detail.inline .detail-pretty {\n max-height: 40vh;\n }\n\n .pretty-section {\n margin-bottom: 14px;\n }\n\n .pretty-section:last-child {\n margin-bottom: 0;\n }\n\n .pretty-label {\n font-size: 11px;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n color: var(--muted);\n margin-bottom: 6px;\n }\n\n .pretty-content {\n font-size: 13px;\n line-height: 1.5;\n }\n\n .pretty-content p {\n margin: 0 0 10px;\n }\n\n .pretty-content p:last-child {\n margin-bottom: 0;\n }\n\n .pretty-content h1, .pretty-content h2, .pretty-content h3,\n .pretty-content h4, .pretty-content h5, .pretty-content h6 {\n margin: 14px 0 8px;\n font-weight: 600;\n line-height: 1.3;\n }\n\n .pretty-content h1:first-child, .pretty-content h2:first-child,\n .pretty-content h3:first-child {\n margin-top: 0;\n }\n\n .pretty-content h1 { font-size: 1.4em; }\n .pretty-content h2 { font-size: 1.25em; }\n .pretty-content h3 { font-size: 1.1em; }\n\n .pretty-content ul, .pretty-content ol {\n margin: 8px 0;\n padding-left: 1.5em;\n list-style-position: outside;\n }\n\n .pretty-content li {\n margin: 4px 0;\n }\n\n .thinking-block ul, .thinking-block ol {\n padding-left: 2em;\n }\n\n .pretty-content code {\n font-family: var(--mono);\n font-size: 0.9em;\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n padding: 2px 5px;\n border-radius: 5px;\n }\n\n .pretty-content pre {\n margin: 10px 0;\n padding: 12px;\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n border-radius: 10px;\n overflow-x: auto;\n }\n\n .pretty-content pre code {\n background: none;\n border: none;\n padding: 0;\n font-size: 12px;\n }\n\n .pretty-content blockquote {\n margin: 10px 0;\n padding: 8px 12px;\n border-left: 3px solid var(--accent);\n background: color-mix(in srgb, var(--accent) 8%, transparent);\n border-radius: 0 8px 8px 0;\n }\n\n .pretty-content a {\n color: var(--accent);\n text-decoration: none;\n }\n\n .pretty-content a:hover {\n text-decoration: underline;\n }\n\n .pretty-content strong {\n font-weight: 600;\n }\n\n .pretty-content em {\n font-style: italic;\n }\n\n .pretty-content table {\n border-collapse: collapse;\n margin: 10px 0;\n width: 100%;\n font-size: 13px;\n }\n\n .pretty-content th,\n .pretty-content td {\n border: 1px solid var(--line);\n padding: 8px 10px;\n text-align: left;\n }\n\n .pretty-content th {\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n font-weight: 600;\n }\n\n .pretty-content tr:hover {\n background: color-mix(in srgb, var(--accent) 6%, transparent);\n }\n\n .command-block {\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n border-radius: 10px;\n overflow: hidden;\n margin: 8px 0;\n }\n\n .command-header {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 8px 12px;\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n border-bottom: 1px solid var(--line);\n font-size: 12px;\n }\n\n .command-header .tool-name {\n font-weight: 600;\n color: var(--accent);\n }\n\n .command-header .tool-desc {\n color: var(--muted);\n }\n\n .command-input {\n padding: 10px 12px;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.4;\n white-space: pre-wrap;\n word-break: break-word;\n border-bottom: 1px solid var(--line);\n }\n\n .command-input::before {\n content: '$ ';\n color: var(--good);\n font-weight: 600;\n }\n\n .command-output {\n padding: 10px 12px;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.4;\n white-space: pre-wrap;\n word-break: break-word;\n max-height: 300px;\n overflow: auto;\n color: var(--muted);\n }\n\n .file-path {\n font-family: var(--mono);\n font-size: 12px;\n padding: 6px 10px;\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n border-radius: 8px;\n display: inline-block;\n margin: 4px 0;\n }\n\n .tool-params {\n display: grid;\n gap: 8px;\n margin-top: 8px;\n }\n\n .tool-param {\n display: grid;\n grid-template-columns: auto 1fr;\n gap: 8px;\n align-items: start;\n font-size: 12px;\n }\n\n .tool-param-name {\n font-family: var(--mono);\n font-weight: 600;\n color: var(--accent);\n padding: 2px 6px;\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n border-radius: 4px;\n }\n\n .tool-param-value {\n font-family: var(--mono);\n white-space: pre-wrap;\n word-break: break-word;\n padding: 2px 0;\n }\n\n .thinking-block {\n background: color-mix(in srgb, var(--accent-2) 10%, transparent);\n border: 1px solid color-mix(in srgb, var(--accent-2) 30%, var(--line));\n border-radius: 10px;\n padding: 12px 16px;\n font-size: 13px;\n line-height: 1.5;\n }\n\n .result-block {\n background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n border: 1px solid var(--line);\n border-radius: 10px;\n padding: 12px;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.4;\n white-space: pre-wrap;\n word-break: break-word;\n max-height: 400px;\n overflow: auto;\n }\n\n .result-error {\n border-color: color-mix(in srgb, var(--bad) 50%, var(--line));\n background: color-mix(in srgb, var(--bad) 8%, transparent);\n }\n\n .json-toggle {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 8px 14px;\n border-bottom: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel-2) 50%, transparent);\n }\n\n .json-toggle-label {\n font-size: 12px;\n color: var(--muted);\n font-weight: 500;\n }\n\n .prompts-modal {\n position: fixed;\n inset: 0;\n z-index: 80;\n background: rgba(0, 0, 0, 0.68);\n display: none;\n padding: 18px;\n place-items: center;\n }\n\n .prompts-modal.open { display: grid; }\n\n .prompts-modal .dialog {\n position: relative;\n border-radius: 16px;\n border: 1px solid var(--line);\n background: color-mix(in srgb, var(--panel) 98%, transparent);\n box-shadow: var(--shadow);\n padding: 14px;\n width: min(1100px, 96vw);\n height: min(86vh, 780px);\n display: grid;\n grid-template-rows: auto 1fr auto;\n gap: 10px;\n }\n\n .prompts-head {\n display: flex;\n gap: 10px;\n align-items: center;\n justify-content: space-between;\n }\n\n .prompts-title {\n font-weight: 700;\n letter-spacing: 0.2px;\n }\n\n .prompts-modal textarea {\n width: 100%;\n height: 100%;\n resize: none;\n border-radius: 12px;\n font-family: var(--mono);\n font-size: 12px;\n line-height: 1.45;\n padding: 12px;\n }\n\n .prompts-actions {\n display: flex;\n gap: 10px;\n align-items: center;\n justify-content: flex-end;\n flex-wrap: wrap;\n }\n </style>\n</head>\n <body>\n <div class=\"wrap\">\n <header>\n <div>\n <h1>Claude Code Timeline Viewer</h1>\n <p class=\"sub\">Open a Claude Code session <code>.jsonl</code> file and explore it as an interactive timeline. Times are shown in your local timezone by default. Find sessions in <code>~/.claude/projects/</code>.</p>\n </div>\n </header>\n\n <section class=\"panel input-panel\" aria-label=\"Input\">\n <div class=\"input-grid\">\n <div>\n <div id=\"dropzone\" class=\"dropzone\" tabindex=\"0\">\n <strong>Drop a <code>.jsonl</code> file here</strong>\n <div class=\"muted\">or click to choose a file</div>\n <div class=\"tiny\">Supported: file picker, drag-and-drop, paste, or URL fetch (CORS-enabled).</div>\n <input id=\"file-input\" type=\"file\" accept=\".jsonl,application/json,text/plain\" hidden />\n </div>\n </div>\n <div>\n <div class=\"tabs\" role=\"tablist\" aria-label=\"Input tabs\">\n <button type=\"button\" class=\"tab active\" data-tab=\"paste\" aria-selected=\"true\">Paste</button>\n <button type=\"button\" class=\"tab\" data-tab=\"url\" aria-selected=\"false\">URL</button>\n </div>\n\n <div id=\"panel-paste\" data-panel=\"paste\">\n <textarea id=\"paste-input\" placeholder=\"Paste JSONL content here...\"></textarea>\n <div class=\"row\">\n <button type=\"button\" class=\"btn primary\" id=\"render-paste\">Render pasted content</button>\n <div class=\"tiny\">Tip: the timeline uses one JSON object per line.</div>\n </div>\n </div>\n\n <div id=\"panel-url\" data-panel=\"url\" hidden>\n <input id=\"url-input\" type=\"url\" placeholder=\"https://example.com/session.jsonl\" />\n <div class=\"row\">\n <button type=\"button\" class=\"btn primary\" id=\"fetch-url\">Fetch and render</button>\n <div class=\"tiny\">The URL must allow cross-origin requests (CORS).</div>\n </div>\n </div>\n </div>\n </div>\n\n <div id=\"status\" class=\"status\" role=\"status\" aria-live=\"polite\"></div>\n </section>\n\n <section class=\"panel filters-panel\" aria-label=\"Filters\">\n <div class=\"controls\" aria-label=\"Timeline controls\">\n <div class=\"field\" style=\"min-width: 170px\">\n <div class=\"label\">Timezone</div>\n <div class=\"segmented\" role=\"group\" aria-label=\"Timezone toggle\">\n <button type=\"button\" id=\"tz-local\" class=\"active\">Local</button>\n <button type=\"button\" id=\"tz-utc\">UTC</button>\n </div>\n </div>\n <div class=\"field\" style=\"min-width: 220px\">\n <div class=\"label\">Search</div>\n <input id=\"search\" type=\"search\" placeholder=\"message, tool name, cwd...\" autocomplete=\"off\" />\n </div>\n <div class=\"field\" style=\"min-width: 190px\">\n <div class=\"label\">Type</div>\n <select id=\"type-filter\"></select>\n </div>\n <div class=\"field\" style=\"min-width: 210px\">\n <div class=\"label\">Content Type</div>\n <select id=\"content-type-filter\"></select>\n </div>\n <div class=\"field\" style=\"min-width: 160px\">\n <div class=\"label\">Role</div>\n <select id=\"role-filter\"></select>\n </div>\n <label class=\"check\" title=\"Hide thinking blocks (can be verbose)\">\n <input type=\"checkbox\" id=\"hide-thinking\" />\n Hide thinking\n </label>\n <button type=\"button\" class=\"btn\" id=\"clear-filters\">Clear</button>\n </div>\n </section>\n\n <div class=\"controls\" style=\"margin-bottom: 14px;\">\n <button type=\"button\" class=\"btn\" id=\"show-prompts\" disabled>Show prompts</button>\n </div>\n\n <main>\n <section class=\"panel timeline-panel\" aria-label=\"Timeline\">\n <div class=\"stats\">\n <div class=\"big\"><span id=\"stats-showing\">0</span> <span class=\"muted\">showing</span> <span class=\"muted\">/</span> <span id=\"stats-total\">0</span> <span class=\"muted\">events</span></div>\n <div class=\"muted\" id=\"stats-range\">-</div>\n </div>\n <div id=\"timeline\" class=\"timeline\" role=\"list\" tabindex=\"0\" aria-label=\"Events list\"></div>\n </section>\n\n <aside class=\"panel detail\" aria-label=\"Details\">\n <div class=\"detail-head\">\n <div class=\"detail-title\" id=\"detail-title\">Select an event</div>\n <div class=\"detail-actions\">\n <button type=\"button\" class=\"btn\" id=\"copy-json\" disabled>Copy JSON</button>\n <button type=\"button\" class=\"btn\" id=\"copy-raw\" disabled>Copy raw line</button>\n <label class=\"check\" title=\"Truncate very long strings in the JSON preview\">\n <input type=\"checkbox\" id=\"truncate-strings\" checked />\n Truncate long strings\n </label>\n </div>\n </div>\n <div id=\"detail-images\" class=\"detail-images\" hidden aria-label=\"Event images\"></div>\n <div id=\"detail-pretty\" class=\"detail-pretty\" hidden aria-label=\"Formatted event view\"></div>\n <div class=\"json-toggle\">\n <span class=\"json-toggle-label\">Raw JSON</span>\n </div>\n <pre id=\"detail-json\" aria-label=\"Selected event JSON\" class=\"empty\">Load a file to get started.</pre>\n </aside>\n </main>\n </div>\n\n <div id=\"image-modal\" class=\"image-modal\" role=\"dialog\" aria-modal=\"true\" aria-hidden=\"true\">\n <div class=\"dialog\">\n <button type=\"button\" class=\"close\" id=\"image-modal-close\" aria-label=\"Close image\">Close</button>\n <img id=\"image-modal-img\" alt=\"\" />\n </div>\n </div>\n\n <div id=\"prompts-modal\" class=\"prompts-modal\" role=\"dialog\" aria-modal=\"true\" aria-hidden=\"true\" aria-label=\"User prompts\">\n <div class=\"dialog\">\n <div class=\"prompts-head\">\n <div class=\"prompts-title\">User prompts</div>\n <button type=\"button\" class=\"btn\" id=\"prompts-close\" aria-label=\"Close prompts\">Close</button>\n </div>\n <textarea id=\"prompts-text\" readonly spellcheck=\"false\"></textarea>\n <div class=\"prompts-actions\">\n <button type=\"button\" class=\"btn primary\" id=\"prompts-copy\">Copy</button>\n </div>\n </div>\n </div>\n\n <script>\n const els = {\n dropzone: document.getElementById('dropzone'),\n fileInput: document.getElementById('file-input'),\n pasteInput: document.getElementById('paste-input'),\n renderPaste: document.getElementById('render-paste'),\n urlInput: document.getElementById('url-input'),\n fetchUrl: document.getElementById('fetch-url'),\n status: document.getElementById('status'),\n timeline: document.getElementById('timeline'),\n detailPanel: document.querySelector('aside.detail'),\n detailTitle: document.getElementById('detail-title'),\n detailImages: document.getElementById('detail-images'),\n detailPretty: document.getElementById('detail-pretty'),\n detailJson: document.getElementById('detail-json'),\n copyJson: document.getElementById('copy-json'),\n copyRaw: document.getElementById('copy-raw'),\n truncateStrings: document.getElementById('truncate-strings'),\n tzLocal: document.getElementById('tz-local'),\n tzUtc: document.getElementById('tz-utc'),\n search: document.getElementById('search'),\n typeFilter: document.getElementById('type-filter'),\n contentTypeFilter: document.getElementById('content-type-filter'),\n roleFilter: document.getElementById('role-filter'),\n hideThinking: document.getElementById('hide-thinking'),\n statsShowing: document.getElementById('stats-showing'),\n statsTotal: document.getElementById('stats-total'),\n statsRange: document.getElementById('stats-range'),\n clearFilters: document.getElementById('clear-filters'),\n imageModal: document.getElementById('image-modal'),\n imageModalImg: document.getElementById('image-modal-img'),\n imageModalClose: document.getElementById('image-modal-close'),\n showPrompts: document.getElementById('show-prompts'),\n promptsModal: document.getElementById('prompts-modal'),\n promptsClose: document.getElementById('prompts-close'),\n promptsText: document.getElementById('prompts-text'),\n promptsCopy: document.getElementById('prompts-copy'),\n };\n\n const state = {\n sourceLabel: null,\n tzMode: 'local',\n events: [],\n filtered: [],\n selectedId: null,\n selected: null,\n filters: {\n query: '',\n type: 'all',\n contentType: 'all',\n role: 'all',\n hideThinking: false,\n },\n };\n\n let lastActiveElementBeforeModal = null;\n let lastBodyOverflow = null;\n let lastActiveElementBeforePrompts = null;\n let lastBodyOverflowBeforePrompts = null;\n let promptsCopiedTimer = null;\n let suppressUrlStateSync = false;\n const wideLayoutMq = window.matchMedia('(min-width: 1060px)');\n const detailDock = document.createElement('div');\n detailDock.id = 'detail-dock';\n detailDock.style.display = 'none';\n els.detailPanel.parentElement.insertBefore(detailDock, els.detailPanel);\n\n function updateDetailPlacement() {\n const inline = !wideLayoutMq.matches;\n\n if (!inline) {\n if (els.detailPanel.previousSibling !== detailDock) detailDock.after(els.detailPanel);\n els.detailPanel.classList.remove('inline');\n return;\n }\n\n const selectedNode = els.timeline.querySelector('.event.selected');\n if (!selectedNode) {\n if (els.detailPanel.previousSibling !== detailDock) detailDock.after(els.detailPanel);\n els.detailPanel.classList.remove('inline');\n return;\n }\n\n if (selectedNode.nextSibling !== els.detailPanel) selectedNode.after(els.detailPanel);\n els.detailPanel.classList.add('inline');\n }\n\n function setStatus(message, kind = 'good') {\n if (!message) {\n els.status.className = 'status';\n els.status.textContent = '';\n return;\n }\n els.status.className = `status visible ${kind}`;\n els.status.textContent = message;\n }\n\n function pad2(number) {\n return String(number).padStart(2, '0');\n }\n\n function pad3(number) {\n return String(number).padStart(3, '0');\n }\n\n function getOffsetString(date) {\n const offsetMinutes = -date.getTimezoneOffset();\n const sign = offsetMinutes >= 0 ? '+' : '-';\n const abs = Math.abs(offsetMinutes);\n const hours = Math.floor(abs / 60);\n const minutes = abs % 60;\n return `${sign}${pad2(hours)}:${pad2(minutes)}`;\n }\n\n function formatTimestamp(date, tzMode) {\n if (!(date instanceof Date) || Number.isNaN(date.getTime())) return '-';\n if (tzMode === 'utc') {\n return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ` +\n `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}:${pad2(date.getUTCSeconds())}.` +\n `${pad3(date.getUTCMilliseconds())}Z`;\n }\n return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ` +\n `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}.` +\n `${pad3(date.getMilliseconds())} ${getOffsetString(date)}`;\n }\n\n function dayKey(date, tzMode) {\n if (!(date instanceof Date) || Number.isNaN(date.getTime())) return '-';\n if (tzMode === 'utc') {\n return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())}`;\n }\n return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;\n }\n\n function formatDelta(ms) {\n if (typeof ms !== 'number' || !Number.isFinite(ms)) return '';\n const abs = Math.abs(ms);\n const sign = ms < 0 ? '-' : '+';\n if (abs < 1000) return `${sign}${Math.round(abs)}ms`;\n if (abs < 60_000) return `${sign}${(abs / 1000).toFixed(abs < 10_000 ? 2 : 1)}s`;\n if (abs < 3_600_000) {\n const m = Math.floor(abs / 60_000);\n const s = Math.floor((abs % 60_000) / 1000);\n return `${sign}${m}m ${pad2(s)}s`;\n }\n if (abs < 86_400_000) {\n const h = Math.floor(abs / 3_600_000);\n const m = Math.floor((abs % 3_600_000) / 60_000);\n return `${sign}${h}h ${m}m`;\n }\n const d = Math.floor(abs / 86_400_000);\n const h = Math.floor((abs % 86_400_000) / 3_600_000);\n return `${sign}${d}d ${h}h`;\n }\n\n function safeJsonParse(text) {\n try {\n return { ok: true, value: JSON.parse(text) };\n } catch (error) {\n return { ok: false, error };\n }\n }\n\n function extractMessageText(message) {\n if (!message || typeof message !== 'object') return null;\n const content = message.content;\n if (typeof content === 'string') return content;\n if (!Array.isArray(content)) return null;\n const parts = [];\n for (const item of content) {\n if (!item || typeof item !== 'object') continue;\n if (typeof item.text === 'string') parts.push(item.text);\n else if (typeof item.thinking === 'string') parts.push(item.thinking);\n }\n const joined = parts.join('');\n return joined || null;\n }\n\n function getContentTypes(message) {\n if (!message || typeof message !== 'object') return [];\n const content = message.content;\n if (typeof content === 'string') return ['text'];\n if (!Array.isArray(content)) return [];\n const types = new Set();\n for (const item of content) {\n if (!item || typeof item !== 'object') continue;\n if (item.type) types.add(item.type);\n }\n return Array.from(types);\n }\n\n function firstLine(text) {\n if (typeof text !== 'string') return null;\n const trimmed = text.trim();\n if (!trimmed) return null;\n const idx = trimmed.indexOf('\\n');\n return idx === -1 ? trimmed : trimmed.slice(0, idx).trim();\n }\n\n function normalizeSummaryText(text, maxLen = 260) {\n if (typeof text !== 'string') return '';\n const singleLine = text.replace(/\\s+/g, ' ').trim();\n if (singleLine.length <= maxLen) return singleLine;\n return singleLine.slice(0, maxLen - 1) + '...';\n }\n\n function summarizeEvent(obj) {\n const kind = obj?.type || '';\n const message = obj?.message;\n\n if (kind === 'file-history-snapshot') {\n const msgId = obj?.messageId ? `msg=${obj.messageId.slice(0, 8)}...` : null;\n return normalizeSummaryText(msgId || 'file-history-snapshot');\n }\n\n if (kind === 'user' || kind === 'assistant') {\n const role = message?.role || kind;\n const content = message?.content;\n\n if (typeof content === 'string') {\n return normalizeSummaryText(`${role}: ${content}`);\n }\n\n if (Array.isArray(content) && content.length > 0) {\n for (const item of content) {\n if (item?.type === 'text' && item.text) {\n return normalizeSummaryText(`${role}: ${item.text}`);\n }\n if (item?.type === 'thinking' && item.thinking) {\n return normalizeSummaryText(`${role} (thinking): ${item.thinking}`);\n }\n if (item?.type === 'tool_use') {\n const name = item.name || 'tool';\n const input = item.input;\n if (name === 'Bash' && input?.command) {\n return normalizeSummaryText(`${name}: ${input.command}`);\n }\n if (name === 'Read' && input?.file_path) {\n return normalizeSummaryText(`${name}: ${input.file_path}`);\n }\n if (name === 'Write' && input?.file_path) {\n return normalizeSummaryText(`${name}: ${input.file_path}`);\n }\n if (name === 'Edit' && input?.file_path) {\n return normalizeSummaryText(`${name}: ${input.file_path}`);\n }\n if (name === 'Glob' && input?.pattern) {\n return normalizeSummaryText(`${name}: ${input.pattern}`);\n }\n if (name === 'Grep' && input?.pattern) {\n return normalizeSummaryText(`${name}: ${input.pattern}`);\n }\n if (name === 'Task' && input?.description) {\n return normalizeSummaryText(`${name}: ${input.description}`);\n }\n if (input && typeof input === 'object') {\n const keys = Object.keys(input).slice(0, 3);\n return normalizeSummaryText(`${name}(${keys.join(', ')}${Object.keys(input).length > 3 ? ', ...' : ''})`);\n }\n return normalizeSummaryText(`${name}()`);\n }\n if (item?.type === 'tool_result') {\n const toolUseId = item.tool_use_id ? item.tool_use_id.slice(0, 12) : 'result';\n const resultContent = item.content;\n if (typeof resultContent === 'string') {\n return normalizeSummaryText(`tool_result: ${firstLine(resultContent) || toolUseId}`);\n }\n if (Array.isArray(resultContent)) {\n for (const rc of resultContent) {\n if (rc?.type === 'text' && rc.text) {\n return normalizeSummaryText(`tool_result: ${firstLine(rc.text) || toolUseId}`);\n }\n if (rc?.type === 'image') {\n return normalizeSummaryText(`tool_result: [image]`);\n }\n }\n }\n return normalizeSummaryText(`tool_result: ${toolUseId}`);\n }\n if (item?.type === 'image') {\n return normalizeSummaryText(`${role}: [image]`);\n }\n }\n return normalizeSummaryText(`${role}: [${content.length} content items]`);\n }\n\n return kind;\n }\n\n return kind || 'event';\n }\n\n function buildSearchText(obj, summary) {\n const pieces = [];\n pieces.push(obj?.type || '');\n pieces.push(obj?.uuid || '');\n pieces.push(obj?.sessionId || '');\n pieces.push(obj?.cwd || '');\n pieces.push(obj?.gitBranch || '');\n pieces.push(obj?.slug || '');\n const message = obj?.message;\n if (message && typeof message === 'object') {\n pieces.push(message.role || '');\n const msg = extractMessageText(message);\n if (msg) pieces.push(msg);\n const content = message.content;\n if (Array.isArray(content)) {\n for (const item of content) {\n if (item?.type === 'tool_use') {\n pieces.push(item.name || '');\n if (item.input && typeof item.input === 'object') {\n pieces.push(JSON.stringify(item.input).slice(0, 500));\n }\n }\n if (item?.type === 'tool_result' && typeof item.content === 'string') {\n pieces.push(item.content.slice(0, 500));\n }\n }\n }\n }\n if (typeof summary === 'string') pieces.push(summary);\n return pieces.join('\\n').toLowerCase();\n }\n\n function stringifyJson(value, truncateLongStrings) {\n const maxLen = 8000;\n return JSON.stringify(value, (key, v) => {\n if (!truncateLongStrings) return v;\n if (typeof v === 'string' && v.length > maxLen) {\n return v.slice(0, maxLen) + `... [truncated ${v.length - maxLen} chars]`;\n }\n return v;\n }, 2);\n }\n\n function updateFilterOptions(events) {\n const typeSet = new Set();\n const contentTypeSet = new Set();\n const roleSet = new Set();\n for (const e of events) {\n if (e.kind) typeSet.add(e.kind);\n for (const ct of e.contentTypes) {\n contentTypeSet.add(ct);\n }\n if (!e.contentTypes.length) contentTypeSet.add('(none)');\n if (e.role) roleSet.add(e.role);\n }\n\n function setOptions(select, items, current) {\n select.innerHTML = '';\n const optAll = document.createElement('option');\n optAll.value = 'all';\n optAll.textContent = 'All';\n select.appendChild(optAll);\n for (const item of items) {\n const opt = document.createElement('option');\n opt.value = item;\n opt.textContent = item;\n select.appendChild(opt);\n }\n select.value = current;\n }\n\n setOptions(els.typeFilter, Array.from(typeSet).sort(), state.filters.type);\n setOptions(els.contentTypeFilter, Array.from(contentTypeSet).sort(), state.filters.contentType);\n setOptions(els.roleFilter, Array.from(roleSet).sort(), state.filters.role);\n\n if (events.length) {\n state.filters.type = els.typeFilter.value || 'all';\n state.filters.contentType = els.contentTypeFilter.value || 'all';\n state.filters.role = els.roleFilter.value || 'all';\n }\n }\n\n function readHashParams() {\n const raw = window.location.hash && window.location.hash.startsWith('#')\n ? window.location.hash.slice(1)\n : '';\n return new URLSearchParams(raw);\n }\n\n function writeHashParams(params) {\n const base = window.location.href.split('#')[0];\n const hash = params.toString();\n const next = base + (hash ? `#${hash}` : '');\n window.history.replaceState(null, '', next);\n }\n\n function syncUrlHashFromControls({ clearSel = false } = {}) {\n if (suppressUrlStateSync) return;\n const params = readHashParams();\n params.set('tz', state.tzMode);\n params.set('q', (els.search.value || '').trim());\n params.set('type', state.filters.type || 'all');\n params.set('ct', state.filters.contentType || 'all');\n params.set('role', state.filters.role || 'all');\n params.set('hide', state.filters.hideThinking ? '1' : '0');\n params.set('truncate', els.truncateStrings.checked ? '1' : '0');\n const selectedLine = state.selected?.line;\n if (typeof selectedLine === 'number' && Number.isFinite(selectedLine)) {\n params.set('sel', String(selectedLine));\n } else if (clearSel) {\n params.delete('sel');\n }\n writeHashParams(params);\n }\n\n function applySelectionFromHash() {\n if (!state.events.length) return;\n const params = readHashParams();\n const raw = params.get('sel');\n if (!raw) return;\n const line = Number(raw);\n if (!Number.isFinite(line)) return;\n const found = state.events.find(e => e.line === line);\n if (!found) return;\n if (state.selectedId === found.id) return;\n suppressUrlStateSync = true;\n try {\n selectEvent(found.id, { scroll: true });\n } finally {\n suppressUrlStateSync = false;\n }\n }\n\n function applyControlsFromHash() {\n const params = readHashParams();\n suppressUrlStateSync = true;\n try {\n const q = params.get('q');\n if (q !== null) {\n els.search.value = q;\n state.filters.query = q.trim().toLowerCase();\n }\n\n const type = params.get('type');\n if (type !== null) state.filters.type = type;\n const ct = params.get('ct');\n if (ct !== null) state.filters.contentType = ct;\n const role = params.get('role');\n if (role !== null) state.filters.role = role;\n\n const hide = params.get('hide');\n if (hide !== null) {\n els.hideThinking.checked = hide === '1' || hide === 'true';\n state.filters.hideThinking = els.hideThinking.checked;\n }\n\n const truncate = params.get('truncate');\n if (truncate !== null) {\n els.truncateStrings.checked = truncate === '1' || truncate === 'true';\n }\n\n const tz = params.get('tz');\n if (tz === 'utc' || tz === 'local') {\n state.tzMode = tz;\n els.tzLocal.classList.toggle('active', tz === 'local');\n els.tzUtc.classList.toggle('active', tz === 'utc');\n }\n } finally {\n suppressUrlStateSync = false;\n }\n }\n\n function applyFilters() {\n const query = state.filters.query;\n const type = state.filters.type;\n const contentType = state.filters.contentType;\n const role = state.filters.role;\n const hideThinking = state.filters.hideThinking;\n\n const out = [];\n for (const e of state.events) {\n if (hideThinking && e.contentTypes.includes('thinking') && e.contentTypes.length === 1) continue;\n if (type !== 'all' && e.kind !== type) continue;\n if (contentType !== 'all') {\n const ct = e.contentTypes.length ? e.contentTypes : ['(none)'];\n if (!ct.includes(contentType)) continue;\n }\n if (role !== 'all' && e.role !== role) continue;\n if (query && !e.search.includes(query)) continue;\n out.push(e);\n }\n\n state.filtered = out;\n els.statsShowing.textContent = String(out.length);\n els.statsTotal.textContent = String(state.events.length);\n\n const start = out.find(e => e.date instanceof Date && !Number.isNaN(e.date.getTime()))?.date;\n const end = [...out].reverse().find(e => e.date instanceof Date && !Number.isNaN(e.date.getTime()))?.date;\n if (start && end) {\n els.statsRange.textContent = `${formatTimestamp(start, state.tzMode)} -> ${formatTimestamp(end, state.tzMode)}`;\n } else {\n els.statsRange.textContent = '-';\n }\n\n renderTimeline();\n }\n\n function clearSelection({ syncHash = true } = {}) {\n state.selectedId = null;\n state.selected = null;\n els.detailTitle.textContent = 'Select an event';\n renderDetailImages(null);\n renderPrettyView(null);\n els.detailJson.textContent = state.events.length ? 'Select an event from the timeline.' : 'Load a file to get started.';\n els.detailJson.classList.add('empty');\n els.copyJson.disabled = true;\n els.copyRaw.disabled = true;\n for (const node of els.timeline.querySelectorAll('.event.selected')) {\n node.classList.remove('selected');\n }\n updateDetailPlacement();\n if (syncHash) syncUrlHashFromControls({ clearSel: true });\n }\n\n function renderTimeline() {\n els.timeline.innerHTML = '';\n\n if (!state.filtered.length) {\n const empty = document.createElement('div');\n empty.className = 'empty';\n empty.textContent = state.events.length ? 'No events match your filters.' : 'Load a file to see a timeline.';\n els.timeline.appendChild(empty);\n clearSelection();\n return;\n }\n\n const fragment = document.createDocumentFragment();\n let lastDay = null;\n let prevDate = null;\n\n for (const e of state.filtered) {\n const day = dayKey(e.date, state.tzMode);\n if (day !== lastDay) {\n lastDay = day;\n const sep = document.createElement('div');\n sep.className = 'day-sep';\n sep.textContent = day;\n fragment.appendChild(sep);\n }\n\n const row = document.createElement('div');\n row.className = 'event';\n row.setAttribute('role', 'listitem');\n row.dataset.eventId = String(e.id);\n\n const time = document.createElement('div');\n time.className = 'time';\n\n const ts = document.createElement('div');\n ts.className = 'ts';\n ts.textContent = formatTimestamp(e.date, state.tzMode);\n ts.dataset.kind = 'timestamp';\n\n const meta = document.createElement('div');\n meta.textContent = [\n `#${e.line}`,\n (prevDate && e.date) ? formatDelta(e.date.getTime() - prevDate.getTime()) : null,\n ].filter(Boolean).join(' . ');\n\n time.appendChild(ts);\n time.appendChild(meta);\n\n const body = document.createElement('div');\n\n const badges = document.createElement('div');\n badges.className = 'badges';\n const badgeKind = document.createElement('span');\n badgeKind.className = `badge k-${e.kind}`;\n badgeKind.textContent = e.kind;\n badges.appendChild(badgeKind);\n\n for (const ct of e.contentTypes.slice(0, 2)) {\n const badgeCt = document.createElement('span');\n badgeCt.className = `badge ct-${ct}`;\n badgeCt.textContent = ct;\n badges.appendChild(badgeCt);\n }\n if (e.contentTypes.length > 2) {\n const badgeMore = document.createElement('span');\n badgeMore.className = 'badge';\n badgeMore.textContent = `+${e.contentTypes.length - 2}`;\n badges.appendChild(badgeMore);\n }\n\n if (e.role) {\n const badgeRole = document.createElement('span');\n badgeRole.className = `badge role-${e.role}`;\n badgeRole.textContent = e.role;\n badges.appendChild(badgeRole);\n }\n\n const summary = document.createElement('div');\n summary.className = 'summary';\n summary.textContent = e.summary;\n\n body.appendChild(badges);\n body.appendChild(summary);\n\n row.appendChild(time);\n row.appendChild(body);\n\n row.addEventListener('click', () => selectEvent(e.id, { focusTimeline: true }));\n fragment.appendChild(row);\n\n if (e.date instanceof Date && !Number.isNaN(e.date.getTime())) prevDate = e.date;\n }\n\n els.timeline.appendChild(fragment);\n\n if (state.selectedId !== null) {\n const stillThere = state.filtered.some(e => e.id === state.selectedId);\n if (!stillThere) clearSelection();\n else selectEvent(state.selectedId, { scroll: false });\n } else {\n clearSelection({ syncHash: false });\n }\n }\n\n function selectEvent(id, { scroll = false, focusTimeline = false } = {}) {\n const e = state.filtered.find(item => item.id === id) || state.events.find(item => item.id === id);\n if (!e) return;\n state.selectedId = id;\n state.selected = e;\n\n if (focusTimeline) {\n els.timeline.focus({ preventScroll: true });\n }\n\n for (const node of els.timeline.querySelectorAll('.event.selected')) {\n node.classList.remove('selected');\n }\n const node = els.timeline.querySelector(`.event[data-event-id=\"${CSS.escape(String(id))}\"]`);\n if (node) {\n node.classList.add('selected');\n updateDetailPlacement();\n if (scroll) node.scrollIntoView({ block: 'center' });\n }\n\n els.detailJson.classList.remove('empty');\n const titleParts = [\n formatTimestamp(e.date, state.tzMode),\n `#${e.line}`,\n e.kind,\n e.role ? `(${e.role})` : null,\n ].filter(Boolean);\n els.detailTitle.textContent = titleParts.join(' ');\n\n const shouldTruncate = els.truncateStrings.checked;\n renderDetailImages(e.obj);\n renderPrettyView(e.obj);\n els.detailJson.textContent = stringifyJson(e.obj, shouldTruncate) || '';\n els.copyJson.disabled = false;\n els.copyRaw.disabled = false;\n updateDetailPlacement();\n syncUrlHashFromControls();\n }\n\n function extractImageUrls(obj) {\n const urls = [];\n const message = obj?.message;\n if (!message || typeof message !== 'object') return urls;\n const content = message.content;\n if (!Array.isArray(content)) return urls;\n\n for (const item of content) {\n if (!item || typeof item !== 'object') continue;\n\n // Direct image in content\n if (item.type === 'image' && item.source?.type === 'base64' && item.source?.data) {\n const mediaType = item.source.media_type || 'image/png';\n urls.push(`data:${mediaType};base64,${item.source.data}`);\n }\n\n // Image in tool_result content\n if (item.type === 'tool_result' && Array.isArray(item.content)) {\n for (const rc of item.content) {\n if (rc?.type === 'image' && rc.source?.type === 'base64' && rc.source?.data) {\n const mediaType = rc.source.media_type || 'image/png';\n urls.push(`data:${mediaType};base64,${rc.source.data}`);\n }\n }\n }\n }\n\n return urls;\n }\n\n function escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n\n // Initialize markdown-it with GFM-like settings\n const md = window.markdownit({\n html: false, // Disable raw HTML input\n linkify: true, // Auto-convert URLs to links\n typographer: true, // Smart quotes, dashes\n breaks: true // Convert \\n to <br>\n });\n\n function renderMarkdown(text) {\n if (typeof text !== 'string') return '';\n\n // Render markdown to HTML\n const html = md.render(text);\n\n // Sanitize with DOMPurify to prevent XSS\n return DOMPurify.sanitize(html);\n }\n\n function renderPrettyView(obj) {\n els.detailPretty.innerHTML = '';\n\n if (!obj || typeof obj !== 'object') {\n els.detailPretty.hidden = true;\n return;\n }\n\n const message = obj.message;\n if (!message || typeof message !== 'object') {\n els.detailPretty.hidden = true;\n return;\n }\n\n const content = message.content;\n const fragment = document.createDocumentFragment();\n let hasContent = false;\n\n // Handle string content (simple text message)\n if (typeof content === 'string') {\n const section = document.createElement('div');\n section.className = 'pretty-section';\n const label = document.createElement('div');\n label.className = 'pretty-label';\n label.textContent = message.role || 'Message';\n const body = document.createElement('div');\n body.className = 'pretty-content';\n body.innerHTML = renderMarkdown(content);\n section.appendChild(label);\n section.appendChild(body);\n fragment.appendChild(section);\n hasContent = true;\n }\n\n // Handle array content\n if (Array.isArray(content)) {\n for (const item of content) {\n if (!item || typeof item !== 'object') continue;\n\n // Text content\n if (item.type === 'text' && item.text) {\n const section = document.createElement('div');\n section.className = 'pretty-section';\n const label = document.createElement('div');\n label.className = 'pretty-label';\n label.textContent = message.role === 'assistant' ? 'Response' : 'Message';\n const body = document.createElement('div');\n body.className = 'pretty-content';\n body.innerHTML = renderMarkdown(item.text);\n section.appendChild(label);\n section.appendChild(body);\n fragment.appendChild(section);\n hasContent = true;\n }\n\n // Thinking content\n if (item.type === 'thinking' && item.thinking) {\n const section = document.createElement('div');\n section.className = 'pretty-section';\n const label = document.createElement('div');\n label.className = 'pretty-label';\n label.textContent = 'Thinking';\n const body = document.createElement('div');\n body.className = 'thinking-block pretty-content';\n body.innerHTML = renderMarkdown(item.thinking);\n section.appendChild(label);\n section.appendChild(body);\n fragment.appendChild(section);\n hasContent = true;\n }\n\n // Tool use\n if (item.type === 'tool_use') {\n const section = document.createElement('div');\n section.className = 'pretty-section';\n const label = document.createElement('div');\n label.className = 'pretty-label';\n label.textContent = 'Tool Call';\n\n const toolName = item.name || 'Unknown Tool';\n const input = item.input || {};\n\n // Special handling for Bash command\n if (toolName === 'Bash' && input.command) {\n const block = document.createElement('div');\n block.className = 'command-block';\n\n const header = document.createElement('div');\n header.className = 'command-header';\n const nameSpan = document.createElement('span');\n nameSpan.className = 'tool-name';\n nameSpan.textContent = toolName;\n header.appendChild(nameSpan);\n if (input.description) {\n const descSpan = document.createElement('span');\n descSpan.className = 'tool-desc';\n descSpan.textContent = input.description;\n header.appendChild(descSpan);\n }\n\n const cmdInput = document.createElement('div');\n cmdInput.className = 'command-input';\n cmdInput.textContent = input.command;\n\n block.appendChild(header);\n block.appendChild(cmdInput);\n section.appendChild(label);\n section.appendChild(block);\n }\n // Special handling for file operations\n else if (['Read', 'Write', 'Edit', 'Glob', 'Grep'].includes(toolName)) {\n const block = document.createElement('div');\n block.className = 'command-block';\n\n const header = document.createElement('div');\n header.className = 'command-header';\n const nameSpan = document.createElement('span');\n nameSpan.className = 'tool-name';\n nameSpan.textContent = toolName;\n header.appendChild(nameSpan);\n\n block.appendChild(header);\n\n const params = document.createElement('div');\n params.className = 'tool-params';\n params.style.padding = '10px 12px';\n\n for (const [key, value] of Object.entries(input)) {\n const param = document.createElement('div');\n param.className = 'tool-param';\n const paramName = document.createElement('span');\n paramName.className = 'tool-param-name';\n paramName.textContent = key;\n const paramValue = document.createElement('span');\n paramValue.className = 'tool-param-value';\n if (typeof value === 'string') {\n paramValue.textContent = value.length > 500 ? value.slice(0, 500) + '...' : value;\n } else {\n paramValue.textContent = JSON.stringify(value, null, 2);\n }\n param.appendChild(paramName);\n param.appendChild(paramValue);\n params.appendChild(param);\n }\n\n block.appendChild(params);\n section.appendChild(label);\n section.appendChild(block);\n }\n // Generic tool display\n else {\n const block = document.createElement('div');\n block.className = 'command-block';\n\n const header = document.createElement('div');\n header.className = 'command-header';\n const nameSpan = document.createElement('span');\n nameSpan.className = 'tool-name';\n nameSpan.textContent = toolName;\n header.appendChild(nameSpan);\n\n block.appendChild(header);\n\n if (Object.keys(input).length > 0) {\n const params = document.createElement('div');\n params.className = 'tool-params';\n params.style.padding = '10px 12px';\n\n for (const [key, value] of Object.entries(input)) {\n const param = document.createElement('div');\n param.className = 'tool-param';\n const paramName = document.createElement('span');\n paramName.className = 'tool-param-name';\n paramName.textContent = key;\n const paramValue = document.createElement('span');\n paramValue.className = 'tool-param-value';\n if (typeof value === 'string') {\n paramValue.textContent = value.length > 1000 ? value.slice(0, 1000) + '...' : value;\n } else {\n paramValue.textContent = JSON.stringify(value, null, 2);\n }\n param.appendChild(paramName);\n param.appendChild(paramValue);\n params.appendChild(param);\n }\n\n block.appendChild(params);\n }\n\n section.appendChild(label);\n section.appendChild(block);\n }\n\n fragment.appendChild(section);\n hasContent = true;\n }\n\n // Tool result\n if (item.type === 'tool_result') {\n const section = document.createElement('div');\n section.className = 'pretty-section';\n const label = document.createElement('div');\n label.className = 'pretty-label';\n label.textContent = item.is_error ? 'Tool Error' : 'Tool Result';\n\n const resultContent = item.content;\n let resultText = '';\n\n if (typeof resultContent === 'string') {\n resultText = resultContent;\n } else if (Array.isArray(resultContent)) {\n for (const rc of resultContent) {\n if (rc?.type === 'text' && rc.text) {\n resultText += rc.text;\n }\n }\n }\n\n if (resultText) {\n const block = document.createElement('div');\n block.className = 'pretty-content' + (item.is_error ? ' result-error' : '');\n const displayText = resultText.length > 5000 ? resultText.slice(0, 5000) + '\\n... [truncated]' : resultText;\n block.innerHTML = renderMarkdown(displayText);\n section.appendChild(label);\n section.appendChild(block);\n fragment.appendChild(section);\n hasContent = true;\n }\n }\n }\n }\n\n if (hasContent) {\n els.detailPretty.appendChild(fragment);\n els.detailPretty.hidden = false;\n } else {\n els.detailPretty.hidden = true;\n }\n }\n\n function openImageModal(src, altText = '') {\n if (!src) return;\n lastActiveElementBeforeModal = document.activeElement instanceof HTMLElement ? document.activeElement : null;\n lastBodyOverflow = document.body.style.overflow;\n document.body.style.overflow = 'hidden';\n\n els.imageModalImg.src = src;\n els.imageModalImg.alt = altText;\n els.imageModal.classList.add('open');\n els.imageModal.setAttribute('aria-hidden', 'false');\n els.imageModalClose.focus();\n }\n\n function closeImageModal() {\n if (!els.imageModal.classList.contains('open')) return;\n els.imageModal.classList.remove('open');\n els.imageModal.setAttribute('aria-hidden', 'true');\n els.imageModalImg.removeAttribute('src');\n els.imageModalImg.alt = '';\n document.body.style.overflow = lastBodyOverflow ?? '';\n\n if (lastActiveElementBeforeModal) {\n lastActiveElementBeforeModal.focus();\n }\n lastActiveElementBeforeModal = null;\n lastBodyOverflow = null;\n }\n\n function renderDetailImages(obj) {\n const urls = extractImageUrls(obj);\n els.detailImages.innerHTML = '';\n if (!urls.length) {\n els.detailImages.hidden = true;\n return;\n }\n const fragment = document.createDocumentFragment();\n urls.forEach((src, idx) => {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'thumb';\n btn.title = `Open image ${idx + 1}`;\n const img = document.createElement('img');\n img.loading = 'lazy';\n img.src = src;\n img.alt = `Image ${idx + 1}`;\n btn.appendChild(img);\n btn.addEventListener('click', () => openImageModal(src, `Image ${idx + 1}`));\n fragment.appendChild(btn);\n });\n els.detailImages.appendChild(fragment);\n els.detailImages.hidden = false;\n }\n\n function isEditableElement(el) {\n if (!(el instanceof HTMLElement)) return false;\n if (el.isContentEditable) return true;\n const tag = el.tagName;\n return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';\n }\n\n function moveSelection(delta) {\n if (!state.filtered.length) return;\n const idx = state.filtered.findIndex(e => e.id === state.selectedId);\n if (idx === -1) {\n selectEvent(state.filtered[0].id, { scroll: true });\n return;\n }\n const nextIdx = Math.max(0, Math.min(state.filtered.length - 1, idx + delta));\n if (nextIdx === idx) return;\n selectEvent(state.filtered[nextIdx].id, { scroll: true });\n }\n\n async function copyText(text) {\n if (typeof text !== 'string') return;\n try {\n await navigator.clipboard.writeText(text);\n setStatus('Copied to clipboard.', 'good');\n } catch (error) {\n const ta = document.createElement('textarea');\n ta.value = text;\n ta.style.position = 'fixed';\n ta.style.left = '-9999px';\n document.body.appendChild(ta);\n ta.select();\n document.execCommand('copy');\n document.body.removeChild(ta);\n setStatus('Copied to clipboard.', 'good');\n }\n }\n\n function extractUserPrompts() {\n const prompts = [];\n for (const e of state.events) {\n // Filter: type=user, content-type includes text, role=user\n if (e.kind !== 'user') continue;\n if (e.role !== 'user') continue;\n if (!e.contentTypes.includes('text')) continue;\n\n const message = e.obj?.message;\n if (!message) continue;\n\n const content = message.content;\n if (typeof content === 'string') {\n prompts.push(content);\n } else if (Array.isArray(content)) {\n for (const item of content) {\n if (item?.type === 'text' && item.text) {\n prompts.push(item.text);\n }\n }\n }\n }\n return prompts.join('\\n\\n');\n }\n\n async function copyUserPrompts() {\n const text = extractUserPrompts();\n if (!text) {\n setStatus('No user prompts found.', 'warn');\n return;\n }\n await copyText(text);\n const originalText = els.copyPrompts.textContent;\n els.copyPrompts.textContent = 'Copied!';\n setTimeout(() => {\n els.copyPrompts.textContent = originalText;\n }, 2000);\n }\n\n function setTimezoneMode(mode) {\n state.tzMode = mode;\n els.tzLocal.classList.toggle('active', mode === 'local');\n els.tzUtc.classList.toggle('active', mode === 'utc');\n applyFilters();\n if (state.selectedId !== null) selectEvent(state.selectedId);\n syncUrlHashFromControls();\n }\n\n els.imageModalClose.addEventListener('click', closeImageModal);\n els.imageModal.addEventListener('click', (event) => {\n if (event.target === els.imageModal) closeImageModal();\n });\n document.addEventListener('keydown', (event) => {\n if (event.key === 'Escape') closeImageModal();\n });\n\n document.addEventListener('keydown', (event) => {\n if (els.imageModal.classList.contains('open')) return;\n if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;\n if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;\n if (isEditableElement(document.activeElement)) return;\n if (document.activeElement !== els.timeline && !els.timeline.contains(document.activeElement)) return;\n if (state.selectedId === null) return;\n event.preventDefault();\n moveSelection(event.key === 'ArrowUp' ? -1 : 1);\n });\n\n if (typeof wideLayoutMq.addEventListener === 'function') {\n wideLayoutMq.addEventListener('change', updateDetailPlacement);\n } else if (typeof wideLayoutMq.addListener === 'function') {\n wideLayoutMq.addListener(updateDetailPlacement);\n }\n\n function setTab(tabName) {\n for (const tab of document.querySelectorAll('.tab')) {\n const active = tab.dataset.tab === tabName;\n tab.classList.toggle('active', active);\n tab.setAttribute('aria-selected', active ? 'true' : 'false');\n }\n for (const panel of document.querySelectorAll('[data-panel]')) {\n panel.hidden = panel.dataset.panel !== tabName;\n }\n }\n\n function setShareUrlParam(url) {\n const current = new URL(window.location.href);\n if (url) current.searchParams.set('url', url);\n else current.searchParams.delete('url');\n window.history.replaceState(null, '', current);\n }\n\n async function fetchAndRenderUrl(url, { syncUrl = true } = {}) {\n const trimmed = (url || '').trim();\n if (!trimmed) {\n setStatus('Enter a URL first.', 'error');\n return;\n }\n if (syncUrl) setShareUrlParam(trimmed);\n setStatus(`Fetching ${trimmed}...`, 'warn');\n try {\n const resp = await fetch(trimmed, { mode: 'cors' });\n if (!resp.ok) throw new Error(`HTTP ${resp.status}`);\n const text = await resp.text();\n await loadText(text, trimmed);\n } catch (error) {\n setStatus(`Failed to fetch URL: ${String(error)}`, 'error');\n }\n }\n\n async function loadText(text, sourceLabel) {\n const started = performance.now();\n state.sourceLabel = sourceLabel || null;\n setStatus(`Parsing ${sourceLabel || 'content'}...`, 'warn');\n clearSelection({ syncHash: false });\n\n const lines = text.split(/\\r?\\n/);\n const events = [];\n const errors = [];\n let idCounter = 0;\n\n for (let i = 0; i < lines.length; i++) {\n const line = lines[i];\n if (!line.trim()) continue;\n const parsed = safeJsonParse(line);\n if (!parsed.ok) {\n errors.push({ line: i + 1, error: parsed.error });\n continue;\n }\n const obj = parsed.value;\n const ts = typeof obj.timestamp === 'string' ? new Date(obj.timestamp) : null;\n const kind = obj.type || '(unknown)';\n const message = obj.message && typeof obj.message === 'object' ? obj.message : null;\n const contentTypes = message ? getContentTypes(message) : [];\n const role = message?.role ?? null;\n\n const summary = summarizeEvent(obj);\n events.push({\n id: ++idCounter,\n line: i + 1,\n raw: line,\n obj,\n date: ts,\n kind,\n contentTypes,\n role,\n summary,\n search: buildSearchText(obj, summary),\n });\n }\n\n state.events = events;\n updateFilterOptions(events);\n applyFilters();\n applySelectionFromHash();\n syncUrlHashFromControls();\n\n const durationMs = Math.round(performance.now() - started);\n if (errors.length) {\n const first = errors[0];\n setStatus(\n `Loaded ${events.length} events from ${sourceLabel || 'content'} in ${durationMs}ms.\\n` +\n `Could not parse ${errors.length} line(s). First error at line ${first.line}: ${String(first.error)}`,\n 'warn'\n );\n } else {\n setStatus(`Loaded ${events.length} events from ${sourceLabel || 'content'} in ${durationMs}ms.`, 'good');\n }\n }\n\n async function handleFile(file) {\n if (!file) return;\n setShareUrlParam(null);\n try {\n const text = await file.text();\n await loadText(text, file.name);\n } catch (error) {\n setStatus(`Failed to read file: ${String(error)}`, 'error');\n }\n }\n\n els.dropzone.addEventListener('click', () => els.fileInput.click());\n els.dropzone.addEventListener('keydown', (e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n els.fileInput.click();\n }\n });\n els.fileInput.addEventListener('change', () => handleFile(els.fileInput.files?.[0]));\n\n els.dropzone.addEventListener('dragover', (e) => {\n e.preventDefault();\n els.dropzone.classList.add('dragover');\n });\n els.dropzone.addEventListener('dragleave', () => els.dropzone.classList.remove('dragover'));\n els.dropzone.addEventListener('drop', (e) => {\n e.preventDefault();\n els.dropzone.classList.remove('dragover');\n const file = e.dataTransfer?.files?.[0];\n if (file) handleFile(file);\n });\n\n els.renderPaste.addEventListener('click', async () => {\n const text = els.pasteInput.value || '';\n if (!text.trim()) {\n setStatus('Paste some JSONL content first.', 'error');\n return;\n }\n setShareUrlParam(null);\n await loadText(text, 'pasted content');\n });\n\n els.fetchUrl.addEventListener('click', async () => {\n await fetchAndRenderUrl(els.urlInput.value, { syncUrl: true });\n });\n\n for (const tab of document.querySelectorAll('.tab')) {\n tab.addEventListener('click', () => setTab(tab.dataset.tab));\n }\n\n els.tzLocal.addEventListener('click', () => setTimezoneMode('local'));\n els.tzUtc.addEventListener('click', () => setTimezoneMode('utc'));\n\n els.search.addEventListener('input', () => {\n state.filters.query = els.search.value.trim().toLowerCase();\n applyFilters();\n syncUrlHashFromControls();\n });\n els.typeFilter.addEventListener('change', () => {\n state.filters.type = els.typeFilter.value;\n applyFilters();\n syncUrlHashFromControls();\n });\n els.contentTypeFilter.addEventListener('change', () => {\n state.filters.contentType = els.contentTypeFilter.value;\n applyFilters();\n syncUrlHashFromControls();\n });\n els.roleFilter.addEventListener('change', () => {\n state.filters.role = els.roleFilter.value;\n applyFilters();\n syncUrlHashFromControls();\n });\n els.hideThinking.addEventListener('change', () => {\n state.filters.hideThinking = els.hideThinking.checked;\n applyFilters();\n syncUrlHashFromControls();\n });\n els.truncateStrings.addEventListener('change', () => {\n if (state.selectedId !== null) selectEvent(state.selectedId);\n syncUrlHashFromControls();\n });\n\n els.clearFilters.addEventListener('click', () => {\n els.search.value = '';\n state.filters.query = '';\n state.filters.type = 'all';\n state.filters.contentType = 'all';\n state.filters.role = 'all';\n state.filters.hideThinking = false;\n els.hideThinking.checked = false;\n updateFilterOptions(state.events);\n applyFilters();\n syncUrlHashFromControls();\n });\n\n els.copyJson.addEventListener('click', () => {\n if (!state.selected) return;\n copyText(stringifyJson(state.selected.obj, false));\n });\n els.copyRaw.addEventListener('click', () => {\n if (!state.selected) return;\n copyText(state.selected.raw);\n });\n els.copyPrompts.addEventListener('click', copyUserPrompts);\n\n async function initFromQuery() {\n const url = new URLSearchParams(window.location.search).get('url');\n if (!url) return;\n setTab('url');\n els.urlInput.value = url;\n await fetchAndRenderUrl(url, { syncUrl: false });\n }\n\n // Initialize select options for the empty state.\n applyControlsFromHash();\n updateFilterOptions([]);\n syncUrlHashFromControls();\n window.addEventListener('hashchange', () => {\n applyControlsFromHash();\n updateFilterOptions(state.events);\n applyFilters();\n applySelectionFromHash();\n });\n void initFromQuery();\n </script>\n</body>\n</html>\n","structuredPatch":[{"oldStart":2121,"oldLines":20,"newStart":2121,"newLines":30,"lines":[" return prompts.join('\\n\\n');"," }"," ","- async function copyUserPrompts() {","- const text = extractUserPrompts();","- if (!text) {","- setStatus('No user prompts found.', 'warn');","- return;","- }","- await copyText(text);","- const originalText = els.copyPrompts.textContent;","- els.copyPrompts.textContent = 'Copied!';","- setTimeout(() => {","- els.copyPrompts.textContent = originalText;","- }, 2000);","+ function openPromptsModal(text) {","+ lastActiveElementBeforePrompts = document.activeElement instanceof HTMLElement ? document.activeElement : null;","+ lastBodyOverflowBeforePrompts = document.body.style.overflow;","+ document.body.style.overflow = 'hidden';","+","+ els.promptsText.value = text || '';","+ els.promptsModal.classList.add('open');","+ els.promptsModal.setAttribute('aria-hidden', 'false');","+ els.promptsClose.focus();","+ els.promptsText.scrollTop = 0;"," }"," ","+ function closePromptsModal() {","+ if (!els.promptsModal.classList.contains('open')) return;","+ els.promptsModal.classList.remove('open');","+ els.promptsModal.setAttribute('aria-hidden', 'true');","+ els.promptsText.value = '';","+ document.body.style.overflow = lastBodyOverflowBeforePrompts ?? '';","+","+ if (lastActiveElementBeforePrompts) lastActiveElementBeforePrompts.focus();","+ lastActiveElementBeforePrompts = null;","+ lastBodyOverflowBeforePrompts = null;","+ }","+"," function setTimezoneMode(mode) {"," state.tzMode = mode;"," els.tzLocal.classList.toggle('active', mode === 'local');"]}],"userModified":false,"replaceAll":false}}
{"parentUuid":"eaf8f88c-b81d-41be-be9f-8824c8d03086","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","slug":"glittery-skipping-rivest","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01NdREoFV6vvhywnMnFquik8","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Good. Now I need to update the event listeners. Let me find where the image modal close listeners are and add the prompts modal listeners nearby. I also need to update the loadText function to enable/disable the button, and replace the copyUserPrompts listener with openPromptsModal logic.","signature":"EsoDCkYIChgCKkB9WtfjSS90xzjC+MQUD8gIz6EaS+lafn56nAAQrVrJVo0/wAVKkUm2J+rXq9grxigLfdQgs8A7OlKGN5+tK8x9EgzUAPnhhAkbLpZVY4EaDDabeWR/unWnzwL8ISIw2Jvtwapn3O23bz1e3I1UzRQ4ZPkDbzblcQYvvFB37Lp3kAPlDBm07SFFeyvH03LkKrECsaHv/5GmrL+CWSlJnSajtJvb3H4DmFrDw1mPY2Pw834i/pI01i4eAePTey7CEbWJPT7I7ebhQjLghtkdng1looi1j7fGOIDBiyXd+UNxWx6SBYhjxSr8vXDCdoOjw5Fc7aWn9lUOrnm0Pjd3OGc7a/3LjxMYFrVKtShz+Kx2aLowe2Wquy5whrYCBZgGrWJyBqwLhikuKplfOhI7UrBAFm+oA1222XfvFDb3PZIrKdZVavxkj25vE92eqHgyWZXgFqj+nHx/iooBhAS8O45aqyT/iLRxtVil0e2/NLppRdU+ha169gDjX6l/86NukcHKHO1kXjq9G+7JXZpSjTviXe+FmdmxzJPlovPNs9p8de9q0ZmYtK4pr449nb0jh8OPxmRAj5tm0DQkwDOr/6Hk1qgYAQ=="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":9,"cache_creation_input_tokens":1128,"cache_read_input_tokens":89720,"cache_creation":{"ephemeral_5m_input_tokens":1128,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWDdcMoVmrnucZDW3ngKk","type":"assistant","uuid":"2a9b252a-04ec-4702-aec3-351059995463","timestamp":"2025-12-18T05:52:33.892Z"}
{"parentUuid":"2a9b252a-04ec-4702-aec3-351059995463","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","slug":"glittery-skipping-rivest","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01NdREoFV6vvhywnMnFquik8","type":"message","role":"assistant","content":[{"type":"text","text":"Now I'll add the event listeners for the prompts modal. Let me find where the image modal listeners are:"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":9,"cache_creation_input_tokens":1128,"cache_read_input_tokens":89720,"cache_creation":{"ephemeral_5m_input_tokens":1128,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWDdcMoVmrnucZDW3ngKk","type":"assistant","uuid":"76e5f624-4c49-4d03-99aa-73a8cc3bd0e6","timestamp":"2025-12-18T05:52:34.139Z"}
{"parentUuid":"76e5f624-4c49-4d03-99aa-73a8cc3bd0e6","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","slug":"glittery-skipping-rivest","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01NdREoFV6vvhywnMnFquik8","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01AHeK7o3bw7hSVRR6AE7kDv","name":"Edit","input":{"file_path":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","old_string":" els.imageModalClose.addEventListener('click', closeImageModal);\n els.imageModal.addEventListener('click', (event) => {\n if (event.target === els.imageModal) closeImageModal();\n });\n document.addEventListener('keydown', (event) => {\n if (event.key === 'Escape') closeImageModal();\n });","new_string":" els.promptsClose.addEventListener('click', closePromptsModal);\n els.promptsModal.addEventListener('click', (event) => {\n if (event.target === els.promptsModal) closePromptsModal();\n });\n els.promptsCopy.addEventListener('click', async () => {\n const text = els.promptsText.value || '';\n if (!text.trim()) {\n setStatus('No user prompts found.', 'warn');\n return;\n }\n await copyText(text);\n if (promptsCopiedTimer) clearTimeout(promptsCopiedTimer);\n const originalText = els.promptsCopy.textContent;\n els.promptsCopy.textContent = 'Copied!';\n promptsCopiedTimer = setTimeout(() => {\n els.promptsCopy.textContent = originalText;\n promptsCopiedTimer = null;\n }, 1500);\n });\n els.showPrompts.addEventListener('click', () => {\n const text = extractUserPrompts();\n if (!text) {\n setStatus('No user prompts found.', 'warn');\n return;\n }\n openPromptsModal(text);\n });\n\n els.imageModalClose.addEventListener('click', closeImageModal);\n els.imageModal.addEventListener('click', (event) => {\n if (event.target === els.imageModal) closeImageModal();\n });\n document.addEventListener('keydown', (event) => {\n if (event.key !== 'Escape') return;\n if (els.promptsModal.classList.contains('open')) closePromptsModal();\n if (els.imageModal.classList.contains('open')) closeImageModal();\n });"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":9,"cache_creation_input_tokens":1128,"cache_read_input_tokens":89720,"cache_creation":{"ephemeral_5m_input_tokens":1128,"ephemeral_1h_input_tokens":0},"output_tokens":704,"service_tier":"standard"}},"requestId":"req_011CWDdcMoVmrnucZDW3ngKk","type":"assistant","uuid":"63d93d02-1433-4381-9e4b-b8390ac1d890","timestamp":"2025-12-18T05:52:36.680Z"}
{"parentUuid":"63d93d02-1433-4381-9e4b-b8390ac1d890","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"4e7c9d12-3299-44a8-84b5-11cb81f04bb4","version":"2.0.72","gitBranch":"main","slug":"glittery-skipping-rivest","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01AHeK7o3bw7hSVRR6AE7kDv","type":"tool_result","content":"The file /Users/simon/Dropbox/dev/tools/claude-code-timeline.html has been updated. Here's the result of running `cat -n` on a snippet of the edited file:\n 2153→ if (state.selectedId !== null) selectEvent(state.selectedId);\n 2154→ syncUrlHashFromControls();\n 2155→ }\n 2156→\n 2157→ els.promptsClose.addEventListener('click', closePromptsModal);\n 2158→ els.promptsModal.addEventListener('click', (event) => {\n 2159→ if (event.target === els.promptsModal) closePromptsModal();\n 2160→ });\n 2161→ els.promptsCopy.addEventListener('click', async () => {\n 2162→ const text = els.promptsText.value || '';\n 2163→ if (!text.trim()) {\n 2164→ setStatus('No user prompts found.', 'warn');\n 2165→ return;\n 2166→ }\n 2167→ await copyText(text);\n 2168→ if (promptsCopiedTimer) clearTimeout(promptsCopiedTimer);\n 2169→ const originalText = els.promptsCopy.textContent;\n 2170→ els.promptsCopy.textContent = 'Copied!';\n 2171→ promptsCopiedTimer = setTimeout(() => {\n 2172→ els.promptsCopy.textContent = originalText;\n 2173→ promptsCopiedTimer = null;\n 2174→ }, 1500);\n 2175→ });\n 2176→ els.showPrompts.addEventListener('click', () => {\n 2177→ const text = extractUserPrompts();\n 2178→ if (!text) {\n 2179→ setStatus('No user prompts found.', 'warn');\n 2180→ return;\n 2181→ }\n 2182→ openPromptsModal(text);\n 2183→ });\n 2184→\n 2185→ els.imageModalClose.addEventListener('click', closeImageModal);\n 2186→ els.imageModal.addEventListener('click', (event) => {\n 2187→ if (event.target === els.imageModal) closeImageModal();\n 2188→ });\n 2189→ document.addEventListener('keydown', (event) => {\n 2190→ if (event.key !== 'Escape') return;\n 2191→ if (els.promptsModal.classList.contains('open')) closePromptsModal();\n 2192→ if (els.imageModal.classList.contains('open')) closeImageModal();\n 2193→ });\n 2194→\n 2195→ document.addEventListener('keydown', (event) => {\n 2196→ if (els.imageModal.classList.contains('open')) return;\n 2197→ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;"}]},"uuid":"7dda0298-5c19-4670-99f3-e8c5f76dcc96","timestamp":"2025-12-18T05:52:36.724Z","toolUseResult":{"filePath":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","oldString":" els.imageModalClose.addEventListener('click', closeImageModal);\n els.imageModal.addEventListener('click', (event) => {\n if (event.target === els.imageModal) closeImageModal();\n });\n document.addEventListener('keydown', (event) => {\n if (event.key === 'Escape') closeImageModal();\n });","newString":" els.promptsClose.addEventListener('click', closePromptsModal);\n els.promptsModal.addEventListener('click', (event) => {\n if (event.target === els.promptsModal) closePromptsModal();\n });\n els.promptsCopy.addEventListener('click', async () => {\n const text = els.promptsText.value || '';\n if (!text.trim()) {\n setStatus('No user prompts found.', 'warn');\n return;\n }\n await copyText(text);\n if (promptsCopiedTimer) clearTimeout(promptsCopiedTimer);\n const originalText = els.promptsCopy.textContent;\n els.promptsCopy.textContent = 'Copied!';\n promptsCopiedTimer = setTimeout(() => {\n els.promptsCopy.textContent = originalText;\n promptsCopiedTimer = null;\n }, 1500);\n });\n els.showPrompts.addEventListener('click', () => {\n const text = extractUserPrompts();\n if (!text) {\n setStatus('No user prompts found.', 'warn');\n return;\n }\n openPromptsModal(text);\n });\n\n els.imageModalClose.addEventListener('click', closeImageModal);\n els.imageModal.addEventListener('click', (event) => {\n if (event.target === els.imageModal) closeImageModal();\n });\n document.addEventListener('keydown', (event) => {\n if (event.key !== 'Escape') return;\n if (els.promptsModal.classList.contains('open')) closePromptsModal();\n if (els.imageModal.classList.contains('open')) closeImageModal();\n });","originalFile":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>Claude Code Timeline Viewer</title>\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js\" integrity=\"sha384-osZDKVu4ipZP703HmPOhWdyBajcFyjX2Psjk//TG1Rc0AdwEtuToaylrmcK3LdAl\" crossorigin=\"anonymous\"></script>\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/markdown-it/4.4.0/markdown-it.min.js\" integrity=\"sha384-cxGp+QDeMxACP73aA0Lj0Sc/h4z1X73+umcvCyI0Vbyh3OYxgrPI+0mxG9slsMTN\" crossorigin=\"anonymous\"></script>\n <style>\n :root {\n color-scheme: light dark;\n --bg: #0b0c0f;\n --panel: #12141a;\n --panel-2: #0f1116;\n --text: #e8eaf0;\n --muted: #a6adbf;\n --line: rgba(255, 255, 255, 0.10);\n --line-2: rgba(255, 255, 255, 0.16);\n --accent: #6aa9ff;\n --accent-2: #8a5cff;\n --good: #46d39a;\n --warn: #ffcc66;\n --bad: #ff6b6b;\n --shadow: 0 18px 50px rgba(0, 0, 0, 0.35);\n --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\",\n \"Courier New\", monospace;\n --sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial,\n \"Apple Color Emoji\", \"Segoe UI Emoji\";\n }\n\n @media (prefers-color-scheme: light) {\n :root {\n --bg: #f7f8fc;\n --panel: #ffffff;\n --panel-2: #ffffff;\n --text: #10121a;\n --muted: #5b647a;\n --line: rgba(16, 18, 26, 0.12);\n --line-2: rgba(16, 18, 26, 0.18);\n --shadow: 0 18px 50px rgba(16, 18, 26, 0.10);\n }\n }\n\n * { box-sizing: border-box; }\n html, body { height: 100%; }\n body {\n margin: 0;\n font-family: var(--sans);\n background: radial-gradient(1200px 600px at 10% -10%, rgba(106, 169, 255, 0.18), transparent 45%),\n radial-gradient(1200px 600px at 90% -20%, rgba(138, 92, 255, 0.16), transparent 50%),\n var(--bg);\n color: var(--text);\n }\n\n .wrap {\n max-width: none;\n margin: 0;\n padding: 22px 16px 42px;\n }\n\n header {\n display: grid;\n gap: 10px;\n margin-bottom: 14px;\n }\n\n h1 {\n margin: 0;\n font-size: clamp(22px, 3vw, 32px);\n letter-spacing: 0.2px;\n }\n\n .sub {\n margin: 6px 0 0;\n color: var(--muted);\n line-height: 1.35;\n max-width: 70ch;\n }\n\n .controls {\n display: flex;\n flex-wrap: wrap;\n gap: 10px;\n align-items: center;\n justif
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment