Created
December 17, 2025 21:53
-
-
Save simonw/308bb1e376f4e67da34cb2d56f068ad5 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| {"type":"file-history-snapshot","messageId":"cc93ec3d-d1ea-4e9c-b82d-6f575befa5ef","snapshot":{"messageId":"cc93ec3d-d1ea-4e9c-b82d-6f575befa5ef","trackedFileBackups":{},"timestamp":"2025-12-17T21:50:59.635Z"},"isSnapshotUpdate":false} | |
| {"parentUuid":null,"isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"e2651abf-182b-466f-8fe6-4b422b5821f5","version":"2.0.71","gitBranch":"main","type":"user","message":{"role":"user","content":"Modify claude-code-timeline.html add a \"Copy prompts\" button directly above the two column display bit that concatenates together all of the user prompts - where type=user and content-type=text and role=user - into a single block and copies it to clipboard"},"uuid":"cc93ec3d-d1ea-4e9c-b82d-6f575befa5ef","timestamp":"2025-12-17T21:50:59.626Z","thinkingMetadata":{"level":"high","disabled":false,"triggers":[]},"todos":[]} | |
| {"parentUuid":"cc93ec3d-d1ea-4e9c-b82d-6f575befa5ef","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"e2651abf-182b-466f-8fe6-4b422b5821f5","version":"2.0.71","gitBranch":"main","message":{"model":"claude-opus-4-5-20251101","id":"msg_01YJyonXmJ2U63QgPJwq38qn","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to modify claude-code-timeline.html to add a \"Copy prompts\" button that:\n1. Is placed directly above the two column display\n2. Concatenates all user prompts where type=user, content-type=text, and role=user\n3. Copies the result to clipboard\n\nLet me first read the file to understand its structure.","signature":"EuEDCkYIChgCKkBMoctTp24Go4nDelgjdxhElSyR22FIED87C733Qu0SiKKHT2LZenoVEGAv8Y7qnZxEmD+39aP1SdYAk0f0lZgSEgxaa+g5Z+EurOUFbywaDIi1j2k3xE2K5uErKCIwRjbHb8vZ/IlIpz9ojXGuM2+UI2rHQwSrPehJQp6klsrlrn1S7CNFdNhAhjfbq0EhKsgCx4mvxLOOn3FCqnnIaMdb1l/2eGJ55/B9KMOqxkx74YWJJeLnw5wFsWHZLFFtgdjSA7gf/a/33JKWoFVGrZcwQEbMmyhQY4u71Kh7Xf3aFHG8ZuZJyMft8OMhmcxEGnpF/UO2ftA67TCAEUFBdBq508BvWMfSsDgTA+TRKAKGoh+KSXAzHpnP9Gn5UI460XgidhUFt+GidgHCa/zNwhMuCvb+Z2LCtayvC8JKF82P/cNKqYHzkRZ/afnA2N9ryeqlVshWE5HDkcucDbyHLxX3P4TQ4qKaGBL1xkaDVbtaLuK4/iUWw5aB2ncO5e07tIXbB5lnLclySNbxqlaDZqhnxX3acfTzf+ag15lEoT2aldQ+lCDMnoshAIdAW7rSCOxCEI+jjvGTdJ54Zz1bkOKKOJwVCZCwFqgHqrUZV7RlrFRueH14FtU8ixgB"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":5707,"cache_read_input_tokens":12973,"cache_creation":{"ephemeral_5m_input_tokens":5707,"ephemeral_1h_input_tokens":0},"output_tokens":7,"service_tier":"standard"}},"requestId":"req_011CWCztmWAAZvsHwZpH8CmJ","type":"assistant","uuid":"eff8e023-3a4d-4cfa-bde5-3b04e954e32e","timestamp":"2025-12-17T21:51:03.142Z"} | |
| {"parentUuid":"eff8e023-3a4d-4cfa-bde5-3b04e954e32e","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"e2651abf-182b-466f-8fe6-4b422b5821f5","version":"2.0.71","gitBranch":"main","slug":"woolly-percolating-wren","message":{"model":"claude-opus-4-5-20251101","id":"msg_01YJyonXmJ2U63QgPJwq38qn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01TWcwGxNhjTWEY3N3t8eMoo","name":"Read","input":{"file_path":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":5707,"cache_read_input_tokens":12973,"cache_creation":{"ephemeral_5m_input_tokens":5707,"ephemeral_1h_input_tokens":0},"output_tokens":161,"service_tier":"standard"}},"requestId":"req_011CWCztmWAAZvsHwZpH8CmJ","type":"assistant","uuid":"50ae0636-93c4-4a01-b9ce-26c4e8b9e7f5","timestamp":"2025-12-17T21:51:03.616Z"} | |
| {"parentUuid":"50ae0636-93c4-4a01-b9ce-26c4e8b9e7f5","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"e2651abf-182b-466f-8fe6-4b422b5821f5","version":"2.0.71","gitBranch":"main","slug":"woolly-percolating-wren","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01TWcwGxNhjTWEY3N3t8eMoo","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→ <main>\n 945→ <section class=\"panel timeline-panel\" aria-label=\"Timeline\">\n 946→ <div class=\"stats\">\n 947→ <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 948→ <div class=\"muted\" id=\"stats-range\">-</div>\n 949→ </div>\n 950→ <div id=\"timeline\" class=\"timeline\" role=\"list\" tabindex=\"0\" aria-label=\"Events list\"></div>\n 951→ </section>\n 952→\n 953→ <aside class=\"panel detail\" aria-label=\"Details\">\n 954→ <div class=\"detail-head\">\n 955→ <div class=\"detail-title\" id=\"detail-title\">Select an event</div>\n 956→ <div class=\"detail-actions\">\n 957→ <button type=\"button\" class=\"btn\" id=\"copy-json\" disabled>Copy JSON</button>\n 958→ <button type=\"button\" class=\"btn\" id=\"copy-raw\" disabled>Copy raw line</button>\n 959→ <label class=\"check\" title=\"Truncate very long strings in the JSON preview\">\n 960→ <input type=\"checkbox\" id=\"truncate-strings\" checked />\n 961→ Truncate long strings\n 962→ </label>\n 963→ </div>\n 964→ </div>\n 965→ <div id=\"detail-images\" class=\"detail-images\" hidden aria-label=\"Event images\"></div>\n 966→ <div id=\"detail-pretty\" class=\"detail-pretty\" hidden aria-label=\"Formatted event view\"></div>\n 967→ <div class=\"json-toggle\">\n 968→ <span class=\"json-toggle-label\">Raw JSON</span>\n 969→ </div>\n 970→ <pre id=\"detail-json\" aria-label=\"Selected event JSON\" class=\"empty\">Load a file to get started.</pre>\n 971→ </aside>\n 972→ </main>\n 973→ </div>\n 974→\n 975→ <div id=\"image-modal\" class=\"image-modal\" role=\"dialog\" aria-modal=\"true\" aria-hidden=\"true\">\n 976→ <div class=\"dialog\">\n 977→ <button type=\"button\" class=\"close\" id=\"image-modal-close\" aria-label=\"Close image\">Close</button>\n 978→ <img id=\"image-modal-img\" alt=\"\" />\n 979→ </div>\n 980→ </div>\n 981→\n 982→ <script>\n 983→ const els = {\n 984→ dropzone: document.getElementById('dropzone'),\n 985→ fileInput: document.getElementById('file-input'),\n 986→ pasteInput: document.getElementById('paste-input'),\n 987→ renderPaste: document.getElementById('render-paste'),\n 988→ urlInput: document.getElementById('url-input'),\n 989→ fetchUrl: document.getElementById('fetch-url'),\n 990→ status: document.getElementById('status'),\n 991→ timeline: document.getElementById('timeline'),\n 992→ detailPanel: document.querySelector('aside.detail'),\n 993→ detailTitle: document.getElementById('detail-title'),\n 994→ detailImages: document.getElementById('detail-images'),\n 995→ detailPretty: document.getElementById('detail-pretty'),\n 996→ detailJson: document.getElementById('detail-json'),\n 997→ copyJson: document.getElementById('copy-json'),\n 998→ copyRaw: document.getElementById('copy-raw'),\n 999→ truncateStrings: document.getElementById('truncate-strings'),\n 1000→ tzLocal: document.getElementById('tz-local'),\n 1001→ tzUtc: document.getElementById('tz-utc'),\n 1002→ search: document.getElementById('search'),\n 1003→ typeFilter: document.getElementById('type-filter'),\n 1004→ contentTypeFilter: document.getElementById('content-type-filter'),\n 1005→ roleFilter: document.getElementById('role-filter'),\n 1006→ hideThinking: document.getElementById('hide-thinking'),\n 1007→ statsShowing: document.getElementById('stats-showing'),\n 1008→ statsTotal: document.getElementById('stats-total'),\n 1009→ statsRange: document.getElementById('stats-range'),\n 1010→ clearFilters: document.getElementById('clear-filters'),\n 1011→ imageModal: document.getElementById('image-modal'),\n 1012→ imageModalImg: document.getElementById('image-modal-img'),\n 1013→ imageModalClose: document.getElementById('image-modal-close'),\n 1014→ };\n 1015→\n 1016→ const state = {\n 1017→ sourceLabel: null,\n 1018→ tzMode: 'local',\n 1019→ events: [],\n 1020→ filtered: [],\n 1021→ selectedId: null,\n 1022→ selected: null,\n 1023→ filters: {\n 1024→ query: '',\n 1025→ type: 'all',\n 1026→ contentType: 'all',\n 1027→ role: 'all',\n 1028→ hideThinking: false,\n 1029→ },\n 1030→ };\n 1031→\n 1032→ let lastActiveElementBeforeModal = null;\n 1033→ let lastBodyOverflow = null;\n 1034→ let suppressUrlStateSync = false;\n 1035→ const wideLayoutMq = window.matchMedia('(min-width: 1060px)');\n 1036→ const detailDock = document.createElement('div');\n 1037→ detailDock.id = 'detail-dock';\n 1038→ detailDock.style.display = 'none';\n 1039→ els.detailPanel.parentElement.insertBefore(detailDock, els.detailPanel);\n 1040→\n 1041→ function updateDetailPlacement() {\n 1042→ const inline = !wideLayoutMq.matches;\n 1043→\n 1044→ if (!inline) {\n 1045→ if (els.detailPanel.previousSibling !== detailDock) detailDock.after(els.detailPanel);\n 1046→ els.detailPanel.classList.remove('inline');\n 1047→ return;\n 1048→ }\n 1049→\n 1050→ const selectedNode = els.timeline.querySelector('.event.selected');\n 1051→ if (!selectedNode) {\n 1052→ if (els.detailPanel.previousSibling !== detailDock) detailDock.after(els.detailPanel);\n 1053→ els.detailPanel.classList.remove('inline');\n 1054→ return;\n 1055→ }\n 1056→\n 1057→ if (selectedNode.nextSibling !== els.detailPanel) selectedNode.after(els.detailPanel);\n 1058→ els.detailPanel.classList.add('inline');\n 1059→ }\n 1060→\n 1061→ function setStatus(message, kind = 'good') {\n 1062→ if (!message) {\n 1063→ els.status.className = 'status';\n 1064→ els.status.textContent = '';\n 1065→ return;\n 1066→ }\n 1067→ els.status.className = `status visible ${kind}`;\n 1068→ els.status.textContent = message;\n 1069→ }\n 1070→\n 1071→ function pad2(number) {\n 1072→ return String(number).padStart(2, '0');\n 1073→ }\n 1074→\n 1075→ function pad3(number) {\n 1076→ return String(number).padStart(3, '0');\n 1077→ }\n 1078→\n 1079→ function getOffsetString(date) {\n 1080→ const offsetMinutes = -date.getTimezoneOffset();\n 1081→ const sign = offsetMinutes >= 0 ? '+' : '-';\n 1082→ const abs = Math.abs(offsetMinutes);\n 1083→ const hours = Math.floor(abs / 60);\n 1084→ const minutes = abs % 60;\n 1085→ return `${sign}${pad2(hours)}:${pad2(minutes)}`;\n 1086→ }\n 1087→\n 1088→ function formatTimestamp(date, tzMode) {\n 1089→ if (!(date instanceof Date) || Number.isNaN(date.getTime())) return '-';\n 1090→ if (tzMode === 'utc') {\n 1091→ return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ` +\n 1092→ `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}:${pad2(date.getUTCSeconds())}.` +\n 1093→ `${pad3(date.getUTCMilliseconds())}Z`;\n 1094→ }\n 1095→ return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ` +\n 1096→ `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}.` +\n 1097→ `${pad3(date.getMilliseconds())} ${getOffsetString(date)}`;\n 1098→ }\n 1099→\n 1100→ function dayKey(date, tzMode) {\n 1101→ if (!(date instanceof Date) || Number.isNaN(date.getTime())) return '-';\n 1102→ if (tzMode === 'utc') {\n 1103→ return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())}`;\n 1104→ }\n 1105→ return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;\n 1106→ }\n 1107→\n 1108→ function formatDelta(ms) {\n 1109→ if (typeof ms !== 'number' || !Number.isFinite(ms)) return '';\n 1110→ const abs = Math.abs(ms);\n 1111→ const sign = ms < 0 ? '-' : '+';\n 1112→ if (abs < 1000) return `${sign}${Math.round(abs)}ms`;\n 1113→ if (abs < 60_000) return `${sign}${(abs / 1000).toFixed(abs < 10_000 ? 2 : 1)}s`;\n 1114→ if (abs < 3_600_000) {\n 1115→ const m = Math.floor(abs / 60_000);\n 1116→ const s = Math.floor((abs % 60_000) / 1000);\n 1117→ return `${sign}${m}m ${pad2(s)}s`;\n 1118→ }\n 1119→ if (abs < 86_400_000) {\n 1120→ const h = Math.floor(abs / 3_600_000);\n 1121→ const m = Math.floor((abs % 3_600_000) / 60_000);\n 1122→ return `${sign}${h}h ${m}m`;\n 1123→ }\n 1124→ const d = Math.floor(abs / 86_400_000);\n 1125→ const h = Math.floor((abs % 86_400_000) / 3_600_000);\n 1126→ return `${sign}${d}d ${h}h`;\n 1127→ }\n 1128→\n 1129→ function safeJsonParse(text) {\n 1130→ try {\n 1131→ return { ok: true, value: JSON.parse(text) };\n 1132→ } catch (error) {\n 1133→ return { ok: false, error };\n 1134→ }\n 1135→ }\n 1136→\n 1137→ function extractMessageText(message) {\n 1138→ if (!message || typeof message !== 'object') return null;\n 1139→ const content = message.content;\n 1140→ if (typeof content === 'string') return content;\n 1141→ if (!Array.isArray(content)) return null;\n 1142→ const parts = [];\n 1143→ for (const item of content) {\n 1144→ if (!item || typeof item !== 'object') continue;\n 1145→ if (typeof item.text === 'string') parts.push(item.text);\n 1146→ else if (typeof item.thinking === 'string') parts.push(item.thinking);\n 1147→ }\n 1148→ const joined = parts.join('');\n 1149→ return joined || null;\n 1150→ }\n 1151→\n 1152→ function getContentTypes(message) {\n 1153→ if (!message || typeof message !== 'object') return [];\n 1154→ const content = message.content;\n 1155→ if (typeof content === 'string') return ['text'];\n 1156→ if (!Array.isArray(content)) return [];\n 1157→ const types = new Set();\n 1158→ for (const item of content) {\n 1159→ if (!item || typeof item !== 'object') continue;\n 1160→ if (item.type) types.add(item.type);\n 1161→ }\n 1162→ return Array.from(types);\n 1163→ }\n 1164→\n 1165→ function firstLine(text) {\n 1166→ if (typeof text !== 'string') return null;\n 1167→ const trimmed = text.trim();\n 1168→ if (!trimmed) return null;\n 1169→ const idx = trimmed.indexOf('\\n');\n 1170→ return idx === -1 ? trimmed : trimmed.slice(0, idx).trim();\n 1171→ }\n 1172→\n 1173→ function normalizeSummaryText(text, maxLen = 260) {\n 1174→ if (typeof text !== 'string') return '';\n 1175→ const singleLine = text.replace(/\\s+/g, ' ').trim();\n 1176→ if (singleLine.length <= maxLen) return singleLine;\n 1177→ return singleLine.slice(0, maxLen - 1) + '...';\n 1178→ }\n 1179→\n 1180→ function summarizeEvent(obj) {\n 1181→ const kind = obj?.type || '';\n 1182→ const message = obj?.message;\n 1183→\n 1184→ if (kind === 'file-history-snapshot') {\n 1185→ const msgId = obj?.messageId ? `msg=${obj.messageId.slice(0, 8)}...` : null;\n 1186→ return normalizeSummaryText(msgId || 'file-history-snapshot');\n 1187→ }\n 1188→\n 1189→ if (kind === 'user' || kind === 'assistant') {\n 1190→ const role = message?.role || kind;\n 1191→ const content = message?.content;\n 1192→\n 1193→ if (typeof content === 'string') {\n 1194→ return normalizeSummaryText(`${role}: ${content}`);\n 1195→ }\n 1196→\n 1197→ if (Array.isArray(content) && content.length > 0) {\n 1198→ for (const item of content) {\n 1199→ if (item?.type === 'text' && item.text) {\n 1200→ return normalizeSummaryText(`${role}: ${item.text}`);\n 1201→ }\n 1202→ if (item?.type === 'thinking' && item.thinking) {\n 1203→ return normalizeSummaryText(`${role} (thinking): ${item.thinking}`);\n 1204→ }\n 1205→ if (item?.type === 'tool_use') {\n 1206→ const name = item.name || 'tool';\n 1207→ const input = item.input;\n 1208→ if (name === 'Bash' && input?.command) {\n 1209→ return normalizeSummaryText(`${name}: ${input.command}`);\n 1210→ }\n 1211→ if (name === 'Read' && input?.file_path) {\n 1212→ return normalizeSummaryText(`${name}: ${input.file_path}`);\n 1213→ }\n 1214→ if (name === 'Write' && input?.file_path) {\n 1215→ return normalizeSummaryText(`${name}: ${input.file_path}`);\n 1216→ }\n 1217→ if (name === 'Edit' && input?.file_path) {\n 1218→ return normalizeSummaryText(`${name}: ${input.file_path}`);\n 1219→ }\n 1220→ if (name === 'Glob' && input?.pattern) {\n 1221→ return normalizeSummaryText(`${name}: ${input.pattern}`);\n 1222→ }\n 1223→ if (name === 'Grep' && input?.pattern) {\n 1224→ return normalizeSummaryText(`${name}: ${input.pattern}`);\n 1225→ }\n 1226→ if (name === 'Task' && input?.description) {\n 1227→ return normalizeSummaryText(`${name}: ${input.description}`);\n 1228→ }\n 1229→ if (input && typeof input === 'object') {\n 1230→ const keys = Object.keys(input).slice(0, 3);\n 1231→ return normalizeSummaryText(`${name}(${keys.join(', ')}${Object.keys(input).length > 3 ? ', ...' : ''})`);\n 1232→ }\n 1233→ return normalizeSummaryText(`${name}()`);\n 1234→ }\n 1235→ if (item?.type === 'tool_result') {\n 1236→ const toolUseId = item.tool_use_id ? item.tool_use_id.slice(0, 12) : 'result';\n 1237→ const resultContent = item.content;\n 1238→ if (typeof resultContent === 'string') {\n 1239→ return normalizeSummaryText(`tool_result: ${firstLine(resultContent) || toolUseId}`);\n 1240→ }\n 1241→ if (Array.isArray(resultContent)) {\n 1242→ for (const rc of resultContent) {\n 1243→ if (rc?.type === 'text' && rc.text) {\n 1244→ return normalizeSummaryText(`tool_result: ${firstLine(rc.text) || toolUseId}`);\n 1245→ }\n 1246→ if (rc?.type === 'image') {\n 1247→ return normalizeSummaryText(`tool_result: [image]`);\n 1248→ }\n 1249→ }\n 1250→ }\n 1251→ return normalizeSummaryText(`tool_result: ${toolUseId}`);\n 1252→ }\n 1253→ if (item?.type === 'image') {\n 1254→ return normalizeSummaryText(`${role}: [image]`);\n 1255→ }\n 1256→ }\n 1257→ return normalizeSummaryText(`${role}: [${content.length} content items]`);\n 1258→ }\n 1259→\n 1260→ return kind;\n 1261→ }\n 1262→\n 1263→ return kind || 'event';\n 1264→ }\n 1265→\n 1266→ function buildSearchText(obj, summary) {\n 1267→ const pieces = [];\n 1268→ pieces.push(obj?.type || '');\n 1269→ pieces.push(obj?.uuid || '');\n 1270→ pieces.push(obj?.sessionId || '');\n 1271→ pieces.push(obj?.cwd || '');\n 1272→ pieces.push(obj?.gitBranch || '');\n 1273→ pieces.push(obj?.slug || '');\n 1274→ const message = obj?.message;\n 1275→ if (message && typeof message === 'object') {\n 1276→ pieces.push(message.role || '');\n 1277→ const msg = extractMessageText(message);\n 1278→ if (msg) pieces.push(msg);\n 1279→ const content = message.content;\n 1280→ if (Array.isArray(content)) {\n 1281→ for (const item of content) {\n 1282→ if (item?.type === 'tool_use') {\n 1283→ pieces.push(item.name || '');\n 1284→ if (item.input && typeof item.input === 'object') {\n 1285→ pieces.push(JSON.stringify(item.input).slice(0, 500));\n 1286→ }\n 1287→ }\n 1288→ if (item?.type === 'tool_result' && typeof item.content === 'string') {\n 1289→ pieces.push(item.content.slice(0, 500));\n 1290→ }\n 1291→ }\n 1292→ }\n 1293→ }\n 1294→ if (typeof summary === 'string') pieces.push(summary);\n 1295→ return pieces.join('\\n').toLowerCase();\n 1296→ }\n 1297→\n 1298→ function stringifyJson(value, truncateLongStrings) {\n 1299→ const maxLen = 8000;\n 1300→ return JSON.stringify(value, (key, v) => {\n 1301→ if (!truncateLongStrings) return v;\n 1302→ if (typeof v === 'string' && v.length > maxLen) {\n 1303→ return v.slice(0, maxLen) + `... [truncated ${v.length - maxLen} chars]`;\n 1304→ }\n 1305→ return v;\n 1306→ }, 2);\n 1307→ }\n 1308→\n 1309→ function updateFilterOptions(events) {\n 1310→ const typeSet = new Set();\n 1311→ const contentTypeSet = new Set();\n 1312→ const roleSet = new Set();\n 1313→ for (const e of events) {\n 1314→ if (e.kind) typeSet.add(e.kind);\n 1315→ for (const ct of e.contentTypes) {\n 1316→ contentTypeSet.add(ct);\n 1317→ }\n 1318→ if (!e.contentTypes.length) contentTypeSet.add('(none)');\n 1319→ if (e.role) roleSet.add(e.role);\n 1320→ }\n 1321→\n 1322→ function setOptions(select, items, current) {\n 1323→ select.innerHTML = '';\n 1324→ const optAll = document.createElement('option');\n 1325→ optAll.value = 'all';\n 1326→ optAll.textContent = 'All';\n 1327→ select.appendChild(optAll);\n 1328→ for (const item of items) {\n 1329→ const opt = document.createElement('option');\n 1330→ opt.value = item;\n 1331→ opt.textContent = item;\n 1332→ select.appendChild(opt);\n 1333→ }\n 1334→ select.value = current;\n 1335→ }\n 1336→\n 1337→ setOptions(els.typeFilter, Array.from(typeSet).sort(), state.filters.type);\n 1338→ setOptions(els.contentTypeFilter, Array.from(contentTypeSet).sort(), state.filters.contentType);\n 1339→ setOptions(els.roleFilter, Array.from(roleSet).sort(), state.filters.role);\n 1340→\n 1341→ if (events.length) {\n 1342→ state.filters.type = els.typeFilter.value || 'all';\n 1343→ state.filters.contentType = els.contentTypeFilter.value || 'all';\n 1344→ state.filters.role = els.roleFilter.value || 'all';\n 1345→ }\n 1346→ }\n 1347→\n 1348→ function readHashParams() {\n 1349→ const raw = window.location.hash && window.location.hash.startsWith('#')\n 1350→ ? window.location.hash.slice(1)\n 1351→ : '';\n 1352→ return new URLSearchParams(raw);\n 1353→ }\n 1354→\n 1355→ function writeHashParams(params) {\n 1356→ const base = window.location.href.split('#')[0];\n 1357→ const hash = params.toString();\n 1358→ const next = base + (hash ? `#${hash}` : '');\n 1359→ window.history.replaceState(null, '', next);\n 1360→ }\n 1361→\n 1362→ function syncUrlHashFromControls({ clearSel = false } = {}) {\n 1363→ if (suppressUrlStateSync) return;\n 1364→ const params = readHashParams();\n 1365→ params.set('tz', state.tzMode);\n 1366→ params.set('q', (els.search.value || '').trim());\n 1367→ params.set('type', state.filters.type || 'all');\n 1368→ params.set('ct', state.filters.contentType || 'all');\n 1369→ params.set('role', state.filters.role || 'all');\n 1370→ params.set('hide', state.filters.hideThinking ? '1' : '0');\n 1371→ params.set('truncate', els.truncateStrings.checked ? '1' : '0');\n 1372→ const selectedLine = state.selected?.line;\n 1373→ if (typeof selectedLine === 'number' && Number.isFinite(selectedLine)) {\n 1374→ params.set('sel', String(selectedLine));\n 1375→ } else if (clearSel) {\n 1376→ params.delete('sel');\n 1377→ }\n 1378→ writeHashParams(params);\n 1379→ }\n 1380→\n 1381→ function applySelectionFromHash() {\n 1382→ if (!state.events.length) return;\n 1383→ const params = readHashParams();\n 1384→ const raw = params.get('sel');\n 1385→ if (!raw) return;\n 1386→ const line = Number(raw);\n 1387→ if (!Number.isFinite(line)) return;\n 1388→ const found = state.events.find(e => e.line === line);\n 1389→ if (!found) return;\n 1390→ if (state.selectedId === found.id) return;\n 1391→ suppressUrlStateSync = true;\n 1392→ try {\n 1393→ selectEvent(found.id, { scroll: true });\n 1394→ } finally {\n 1395→ suppressUrlStateSync = false;\n 1396→ }\n 1397→ }\n 1398→\n 1399→ function applyControlsFromHash() {\n 1400→ const params = readHashParams();\n 1401→ suppressUrlStateSync = true;\n 1402→ try {\n 1403→ const q = params.get('q');\n 1404→ if (q !== null) {\n 1405→ els.search.value = q;\n 1406→ state.filters.query = q.trim().toLowerCase();\n 1407→ }\n 1408→\n 1409→ const type = params.get('type');\n 1410→ if (type !== null) state.filters.type = type;\n 1411→ const ct = params.get('ct');\n 1412→ if (ct !== null) state.filters.contentType = ct;\n 1413→ const role = params.get('role');\n 1414→ if (role !== null) state.filters.role = role;\n 1415→\n 1416→ const hide = params.get('hide');\n 1417→ if (hide !== null) {\n 1418→ els.hideThinking.checked = hide === '1' || hide === 'true';\n 1419→ state.filters.hideThinking = els.hideThinking.checked;\n 1420→ }\n 1421→\n 1422→ const truncate = params.get('truncate');\n 1423→ if (truncate !== null) {\n 1424→ els.truncateStrings.checked = truncate === '1' || truncate === 'true';\n 1425→ }\n 1426→\n 1427→ const tz = params.get('tz');\n 1428→ if (tz === 'utc' || tz === 'local') {\n 1429→ state.tzMode = tz;\n 1430→ els.tzLocal.classList.toggle('active', tz === 'local');\n 1431→ els.tzUtc.classList.toggle('active', tz === 'utc');\n 1432→ }\n 1433→ } finally {\n 1434→ suppressUrlStateSync = false;\n 1435→ }\n 1436→ }\n 1437→\n 1438→ function applyFilters() {\n 1439→ const query = state.filters.query;\n 1440→ const type = state.filters.type;\n 1441→ const contentType = state.filters.contentType;\n 1442→ const role = state.filters.role;\n 1443→ const hideThinking = state.filters.hideThinking;\n 1444→\n 1445→ const out = [];\n 1446→ for (const e of state.events) {\n 1447→ if (hideThinking && e.contentTypes.includes('thinking') && e.contentTypes.length === 1) continue;\n 1448→ if (type !== 'all' && e.kind !== type) continue;\n 1449→ if (contentType !== 'all') {\n 1450→ const ct = e.contentTypes.length ? e.contentTypes : ['(none)'];\n 1451→ if (!ct.includes(contentType)) continue;\n 1452→ }\n 1453→ if (role !== 'all' && e.role !== role) continue;\n 1454→ if (query && !e.search.includes(query)) continue;\n 1455→ out.push(e);\n 1456→ }\n 1457→\n 1458→ state.filtered = out;\n 1459→ els.statsShowing.textContent = String(out.length);\n 1460→ els.statsTotal.textContent = String(state.events.length);\n 1461→\n 1462→ const start = out.find(e => e.date instanceof Date && !Number.isNaN(e.date.getTime()))?.date;\n 1463→ const end = [...out].reverse().find(e => e.date instanceof Date && !Number.isNaN(e.date.getTime()))?.date;\n 1464→ if (start && end) {\n 1465→ els.statsRange.textContent = `${formatTimestamp(start, state.tzMode)} -> ${formatTimestamp(end, state.tzMode)}`;\n 1466→ } else {\n 1467→ els.statsRange.textContent = '-';\n 1468→ }\n 1469→\n 1470→ renderTimeline();\n 1471→ }\n 1472→\n 1473→ function clearSelection({ syncHash = true } = {}) {\n 1474→ state.selectedId = null;\n 1475→ state.selected = null;\n 1476→ els.detailTitle.textContent = 'Select an event';\n 1477→ renderDetailImages(null);\n 1478→ renderPrettyView(null);\n 1479→ els.detailJson.textContent = state.events.length ? 'Select an event from the timeline.' : 'Load a file to get started.';\n 1480→ els.detailJson.classList.add('empty');\n 1481→ els.copyJson.disabled = true;\n 1482→ els.copyRaw.disabled = true;\n 1483→ for (const node of els.timeline.querySelectorAll('.event.selected')) {\n 1484→ node.classList.remove('selected');\n 1485→ }\n 1486→ updateDetailPlacement();\n 1487→ if (syncHash) syncUrlHashFromControls({ clearSel: true });\n 1488→ }\n 1489→\n 1490→ function renderTimeline() {\n 1491→ els.timeline.innerHTML = '';\n 1492→\n 1493→ if (!state.filtered.length) {\n 1494→ const empty = document.createElement('div');\n 1495→ empty.className = 'empty';\n 1496→ empty.textContent = state.events.length ? 'No events match your filters.' : 'Load a file to see a timeline.';\n 1497→ els.timeline.appendChild(empty);\n 1498→ clearSelection();\n 1499→ return;\n 1500→ }\n 1501→\n 1502→ const fragment = document.createDocumentFragment();\n 1503→ let lastDay = null;\n 1504→ let prevDate = null;\n 1505→\n 1506→ for (const e of state.filtered) {\n 1507→ const day = dayKey(e.date, state.tzMode);\n 1508→ if (day !== lastDay) {\n 1509→ lastDay = day;\n 1510→ const sep = document.createElement('div');\n 1511→ sep.className = 'day-sep';\n 1512→ sep.textContent = day;\n 1513→ fragment.appendChild(sep);\n 1514→ }\n 1515→\n 1516→ const row = document.createElement('div');\n 1517→ row.className = 'event';\n 1518→ row.setAttribute('role', 'listitem');\n 1519→ row.dataset.eventId = String(e.id);\n 1520→\n 1521→ const time = document.createElement('div');\n 1522→ time.className = 'time';\n 1523→\n 1524→ const ts = document.createElement('div');\n 1525→ ts.className = 'ts';\n 1526→ ts.textContent = formatTimestamp(e.date, state.tzMode);\n 1527→ ts.dataset.kind = 'timestamp';\n 1528→\n 1529→ const meta = document.createElement('div');\n 1530→ meta.textContent = [\n 1531→ `#${e.line}`,\n 1532→ (prevDate && e.date) ? formatDelta(e.date.getTime() - prevDate.getTime()) : null,\n 1533→ ].filter(Boolean).join(' . ');\n 1534→\n 1535→ time.appendChild(ts);\n 1536→ time.appendChild(meta);\n 1537→\n 1538→ const body = document.createElement('div');\n 1539→\n 1540→ const badges = document.createElement('div');\n 1541→ badges.className = 'badges';\n 1542→ const badgeKind = document.createElement('span');\n 1543→ badgeKind.className = `badge k-${e.kind}`;\n 1544→ badgeKind.textContent = e.kind;\n 1545→ badges.appendChild(badgeKind);\n 1546→\n 1547→ for (const ct of e.contentTypes.slice(0, 2)) {\n 1548→ const badgeCt = document.createElement('span');\n 1549→ badgeCt.className = `badge ct-${ct}`;\n 1550→ badgeCt.textContent = ct;\n 1551→ badges.appendChild(badgeCt);\n 1552→ }\n 1553→ if (e.contentTypes.length > 2) {\n 1554→ const badgeMore = document.createElement('span');\n 1555→ badgeMore.className = 'badge';\n 1556→ badgeMore.textContent = `+${e.contentTypes.length - 2}`;\n 1557→ badges.appendChild(badgeMore);\n 1558→ }\n 1559→\n 1560→ if (e.role) {\n 1561→ const badgeRole = document.createElement('span');\n 1562→ badgeRole.className = `badge role-${e.role}`;\n 1563→ badgeRole.textContent = e.role;\n 1564→ badges.appendChild(badgeRole);\n 1565→ }\n 1566→\n 1567→ const summary = document.createElement('div');\n 1568→ summary.className = 'summary';\n 1569→ summary.textContent = e.summary;\n 1570→\n 1571→ body.appendChild(badges);\n 1572→ body.appendChild(summary);\n 1573→\n 1574→ row.appendChild(time);\n 1575→ row.appendChild(body);\n 1576→\n 1577→ row.addEventListener('click', () => selectEvent(e.id, { focusTimeline: true }));\n 1578→ fragment.appendChild(row);\n 1579→\n 1580→ if (e.date instanceof Date && !Number.isNaN(e.date.getTime())) prevDate = e.date;\n 1581→ }\n 1582→\n 1583→ els.timeline.appendChild(fragment);\n 1584→\n 1585→ if (state.selectedId !== null) {\n 1586→ const stillThere = state.filtered.some(e => e.id === state.selectedId);\n 1587→ if (!stillThere) clearSelection();\n 1588→ else selectEvent(state.selectedId, { scroll: false });\n 1589→ } else {\n 1590→ clearSelection({ syncHash: false });\n 1591→ }\n 1592→ }\n 1593→\n 1594→ function selectEvent(id, { scroll = false, focusTimeline = false } = {}) {\n 1595→ const e = state.filtered.find(item => item.id === id) || state.events.find(item => item.id === id);\n 1596→ if (!e) return;\n 1597→ state.selectedId = id;\n 1598→ state.selected = e;\n 1599→\n 1600→ if (focusTimeline) {\n 1601→ els.timeline.focus({ preventScroll: true });\n 1602→ }\n 1603→\n 1604→ for (const node of els.timeline.querySelectorAll('.event.selected')) {\n 1605→ node.classList.remove('selected');\n 1606→ }\n 1607→ const node = els.timeline.querySelector(`.event[data-event-id=\"${CSS.escape(String(id))}\"]`);\n 1608→ if (node) {\n 1609→ node.classList.add('selected');\n 1610→ updateDetailPlacement();\n 1611→ if (scroll) node.scrollIntoView({ block: 'center' });\n 1612→ }\n 1613→\n 1614→ els.detailJson.classList.remove('empty');\n 1615→ const titleParts = [\n 1616→ formatTimestamp(e.date, state.tzMode),\n 1617→ `#${e.line}`,\n 1618→ e.kind,\n 1619→ e.role ? `(${e.role})` : null,\n 1620→ ].filter(Boolean);\n 1621→ els.detailTitle.textContent = titleParts.join(' ');\n 1622→\n 1623→ const shouldTruncate = els.truncateStrings.checked;\n 1624→ renderDetailImages(e.obj);\n 1625→ renderPrettyView(e.obj);\n 1626→ els.detailJson.textContent = stringifyJson(e.obj, shouldTruncate) || '';\n 1627→ els.copyJson.disabled = false;\n 1628→ els.copyRaw.disabled = false;\n 1629→ updateDetailPlacement();\n 1630→ syncUrlHashFromControls();\n 1631→ }\n 1632→\n 1633→ function extractImageUrls(obj) {\n 1634→ const urls = [];\n 1635→ const message = obj?.message;\n 1636→ if (!message || typeof message !== 'object') return urls;\n 1637→ const content = message.content;\n 1638→ if (!Array.isArray(content)) return urls;\n 1639→\n 1640→ for (const item of content) {\n 1641→ if (!item || typeof item !== 'object') continue;\n 1642→\n 1643→ // Direct image in content\n 1644→ if (item.type === 'image' && item.source?.type === 'base64' && item.source?.data) {\n 1645→ const mediaType = item.source.media_type || 'image/png';\n 1646→ urls.push(`data:${mediaType};base64,${item.source.data}`);\n 1647→ }\n 1648→\n 1649→ // Image in tool_result content\n 1650→ if (item.type === 'tool_result' && Array.isArray(item.content)) {\n 1651→ for (const rc of item.content) {\n 1652→ if (rc?.type === 'image' && rc.source?.type === 'base64' && rc.source?.data) {\n 1653→ const mediaType = rc.source.media_type || 'image/png';\n 1654→ urls.push(`data:${mediaType};base64,${rc.source.data}`);\n 1655→ }\n 1656→ }\n 1657→ }\n 1658→ }\n 1659→\n 1660→ return urls;\n 1661→ }\n 1662→\n 1663→ function escapeHtml(text) {\n 1664→ const div = document.createElement('div');\n 1665→ div.textContent = text;\n 1666→ return div.innerHTML;\n 1667→ }\n 1668→\n 1669→ // Initialize markdown-it with GFM-like settings\n 1670→ const md = window.markdownit({\n 1671→ html: false, // Disable raw HTML input\n 1672→ linkify: true, // Auto-convert URLs to links\n 1673→ typographer: true, // Smart quotes, dashes\n 1674→ breaks: true // Convert \\n to <br>\n 1675→ });\n 1676→\n 1677→ function renderMarkdown(text) {\n 1678→ if (typeof text !== 'string') return '';\n 1679→\n 1680→ // Render markdown to HTML\n 1681→ const html = md.render(text);\n 1682→\n 1683→ // Sanitize with DOMPurify to prevent XSS\n 1684→ return DOMPurify.sanitize(html);\n 1685→ }\n 1686→\n 1687→ function renderPrettyView(obj) {\n 1688→ els.detailPretty.innerHTML = '';\n 1689→\n 1690→ if (!obj || typeof obj !== 'object') {\n 1691→ els.detailPretty.hidden = true;\n 1692→ return;\n 1693→ }\n 1694→\n 1695→ const message = obj.message;\n 1696→ if (!message || typeof message !== 'object') {\n 1697→ els.detailPretty.hidden = true;\n 1698→ return;\n 1699→ }\n 1700→\n 1701→ const content = message.content;\n 1702→ const fragment = document.createDocumentFragment();\n 1703→ let hasContent = false;\n 1704→\n 1705→ // Handle string content (simple text message)\n 1706→ if (typeof content === 'string') {\n 1707→ const section = document.createElement('div');\n 1708→ section.className = 'pretty-section';\n 1709→ const label = document.createElement('div');\n 1710→ label.className = 'pretty-label';\n 1711→ label.textContent = message.role || 'Message';\n 1712→ const body = document.createElement('div');\n 1713→ body.className = 'pretty-content';\n 1714→ body.innerHTML = renderMarkdown(content);\n 1715→ section.appendChild(label);\n 1716→ section.appendChild(body);\n 1717→ fragment.appendChild(section);\n 1718→ hasContent = true;\n 1719→ }\n 1720→\n 1721→ // Handle array content\n 1722→ if (Array.isArray(content)) {\n 1723→ for (const item of content) {\n 1724→ if (!item || typeof item !== 'object') continue;\n 1725→\n 1726→ // Text content\n 1727→ if (item.type === 'text' && item.text) {\n 1728→ const section = document.createElement('div');\n 1729→ section.className = 'pretty-section';\n 1730→ const label = document.createElement('div');\n 1731→ label.className = 'pretty-label';\n 1732→ label.textContent = message.role === 'assistant' ? 'Response' : 'Message';\n 1733→ const body = document.createElement('div');\n 1734→ body.className = 'pretty-content';\n 1735→ body.innerHTML = renderMarkdown(item.text);\n 1736→ section.appendChild(label);\n 1737→ section.appendChild(body);\n 1738→ fragment.appendChild(section);\n 1739→ hasContent = true;\n 1740→ }\n 1741→\n 1742→ // Thinking content\n 1743→ if (item.type === 'thinking' && item.thinking) {\n 1744→ const section = document.createElement('div');\n 1745→ section.className = 'pretty-section';\n 1746→ const label = document.createElement('div');\n 1747→ label.className = 'pretty-label';\n 1748→ label.textContent = 'Thinking';\n 1749→ const body = document.createElement('div');\n 1750→ body.className = 'thinking-block pretty-content';\n 1751→ body.innerHTML = renderMarkdown(item.thinking);\n 1752→ section.appendChild(label);\n 1753→ section.appendChild(body);\n 1754→ fragment.appendChild(section);\n 1755→ hasContent = true;\n 1756→ }\n 1757→\n 1758→ // Tool use\n 1759→ if (item.type === 'tool_use') {\n 1760→ const section = document.createElement('div');\n 1761→ section.className = 'pretty-section';\n 1762→ const label = document.createElement('div');\n 1763→ label.className = 'pretty-label';\n 1764→ label.textContent = 'Tool Call';\n 1765→\n 1766→ const toolName = item.name || 'Unknown Tool';\n 1767→ const input = item.input || {};\n 1768→\n 1769→ // Special handling for Bash command\n 1770→ if (toolName === 'Bash' && input.command) {\n 1771→ const block = document.createElement('div');\n 1772→ block.className = 'command-block';\n 1773→\n 1774→ const header = document.createElement('div');\n 1775→ header.className = 'command-header';\n 1776→ const nameSpan = document.createElement('span');\n 1777→ nameSpan.className = 'tool-name';\n 1778→ nameSpan.textContent = toolName;\n 1779→ header.appendChild(nameSpan);\n 1780→ if (input.description) {\n 1781→ const descSpan = document.createElement('span');\n 1782→ descSpan.className = 'tool-desc';\n 1783→ descSpan.textContent = input.description;\n 1784→ header.appendChild(descSpan);\n 1785→ }\n 1786→\n 1787→ const cmdInput = document.createElement('div');\n 1788→ cmdInput.className = 'command-input';\n 1789→ cmdInput.textContent = input.command;\n 1790→\n 1791→ block.appendChild(header);\n 1792→ block.appendChild(cmdInput);\n 1793→ section.appendChild(label);\n 1794→ section.appendChild(block);\n 1795→ }\n 1796→ // Special handling for file operations\n 1797→ else if (['Read', 'Write', 'Edit', 'Glob', 'Grep'].includes(toolName)) {\n 1798→ const block = document.createElement('div');\n 1799→ block.className = 'command-block';\n 1800→\n 1801→ const header = document.createElement('div');\n 1802→ header.className = 'command-header';\n 1803→ const nameSpan = document.createElement('span');\n 1804→ nameSpan.className = 'tool-name';\n 1805→ nameSpan.textContent = toolName;\n 1806→ header.appendChild(nameSpan);\n 1807→\n 1808→ block.appendChild(header);\n 1809→\n 1810→ const params = document.createElement('div');\n 1811→ params.className = 'tool-params';\n 1812→ params.style.padding = '10px 12px';\n 1813→\n 1814→ for (const [key, value] of Object.entries(input)) {\n 1815→ const param = document.createElement('div');\n 1816→ param.className = 'tool-param';\n 1817→ const paramName = document.createElement('span');\n 1818→ paramName.className = 'tool-param-name';\n 1819→ paramName.textContent = key;\n 1820→ const paramValue = document.createElement('span');\n 1821→ paramValue.className = 'tool-param-value';\n 1822→ if (typeof value === 'string') {\n 1823→ paramValue.textContent = value.length > 500 ? value.slice(0, 500) + '...' : value;\n 1824→ } else {\n 1825→ paramValue.textContent = JSON.stringify(value, null, 2);\n 1826→ }\n 1827→ param.appendChild(paramName);\n 1828→ param.appendChild(paramValue);\n 1829→ params.appendChild(param);\n 1830→ }\n 1831→\n 1832→ block.appendChild(params);\n 1833→ section.appendChild(label);\n 1834→ section.appendChild(block);\n 1835→ }\n 1836→ // Generic tool display\n 1837→ else {\n 1838→ const block = document.createElement('div');\n 1839→ block.className = 'command-block';\n 1840→\n 1841→ const header = document.createElement('div');\n 1842→ header.className = 'command-header';\n 1843→ const nameSpan = document.createElement('span');\n 1844→ nameSpan.className = 'tool-name';\n 1845→ nameSpan.textContent = toolName;\n 1846→ header.appendChild(nameSpan);\n 1847→\n 1848→ block.appendChild(header);\n 1849→\n 1850→ if (Object.keys(input).length > 0) {\n 1851→ const params = document.createElement('div');\n 1852→ params.className = 'tool-params';\n 1853→ params.style.padding = '10px 12px';\n 1854→\n 1855→ for (const [key, value] of Object.entries(input)) {\n 1856→ const param = document.createElement('div');\n 1857→ param.className = 'tool-param';\n 1858→ const paramName = document.createElement('span');\n 1859→ paramName.className = 'tool-param-name';\n 1860→ paramName.textContent = key;\n 1861→ const paramValue = document.createElement('span');\n 1862→ paramValue.className = 'tool-param-value';\n 1863→ if (typeof value === 'string') {\n 1864→ paramValue.textContent = value.length > 1000 ? value.slice(0, 1000) + '...' : value;\n 1865→ } else {\n 1866→ paramValue.textContent = JSON.stringify(value, null, 2);\n 1867→ }\n 1868→ param.appendChild(paramName);\n 1869→ param.appendChild(paramValue);\n 1870→ params.appendChild(param);\n 1871→ }\n 1872→\n 1873→ block.appendChild(params);\n 1874→ }\n 1875→\n 1876→ section.appendChild(label);\n 1877→ section.appendChild(block);\n 1878→ }\n 1879→\n 1880→ fragment.appendChild(section);\n 1881→ hasContent = true;\n 1882→ }\n 1883→\n 1884→ // Tool result\n 1885→ if (item.type === 'tool_result') {\n 1886→ const section = document.createElement('div');\n 1887→ section.className = 'pretty-section';\n 1888→ const label = document.createElement('div');\n 1889→ label.className = 'pretty-label';\n 1890→ label.textContent = item.is_error ? 'Tool Error' : 'Tool Result';\n 1891→\n 1892→ const resultContent = item.content;\n 1893→ let resultText = '';\n 1894→\n 1895→ if (typeof resultContent === 'string') {\n 1896→ resultText = resultContent;\n 1897→ } else if (Array.isArray(resultContent)) {\n 1898→ for (const rc of resultContent) {\n 1899→ if (rc?.type === 'text' && rc.text) {\n 1900→ resultText += rc.text;\n 1901→ }\n 1902→ }\n 1903→ }\n 1904→\n 1905→ if (resultText) {\n 1906→ const block = document.createElement('div');\n 1907→ block.className = 'pretty-content' + (item.is_error ? ' result-error' : '');\n 1908→ const displayText = resultText.length > 5000 ? resultText.slice(0, 5000) + '\\n... [truncated]' : resultText;\n 1909→ block.innerHTML = renderMarkdown(displayText);\n 1910→ section.appendChild(label);\n 1911→ section.appendChild(block);\n 1912→ fragment.appendChild(section);\n 1913→ hasContent = true;\n 1914→ }\n 1915→ }\n 1916→ }\n 1917→ }\n 1918→\n 1919→ if (hasContent) {\n 1920→ els.detailPretty.appendChild(fragment);\n 1921→ els.detailPretty.hidden = false;\n 1922→ } else {\n 1923→ els.detailPretty.hidden = true;\n 1924→ }\n 1925→ }\n 1926→\n 1927→ function openImageModal(src, altText = '') {\n 1928→ if (!src) return;\n 1929→ lastActiveElementBeforeModal = document.activeElement instanceof HTMLElement ? document.activeElement : null;\n 1930→ lastBodyOverflow = document.body.style.overflow;\n 1931→ document.body.style.overflow = 'hidden';\n 1932→\n 1933→ els.imageModalImg.src = src;\n 1934→ els.imageModalImg.alt = altText;\n 1935→ els.imageModal.classList.add('open');\n 1936→ els.imageModal.setAttribute('aria-hidden', 'false');\n 1937→ els.imageModalClose.focus();\n 1938→ }\n 1939→\n 1940→ function closeImageModal() {\n 1941→ if (!els.imageModal.classList.contains('open')) return;\n 1942→ els.imageModal.classList.remove('open');\n 1943→ els.imageModal.setAttribute('aria-hidden', 'true');\n 1944→ els.imageModalImg.removeAttribute('src');\n 1945→ els.imageModalImg.alt = '';\n 1946→ document.body.style.overflow = lastBodyOverflow ?? '';\n 1947→\n 1948→ if (lastActiveElementBeforeModal) {\n 1949→ lastActiveElementBeforeModal.focus();\n 1950→ }\n 1951→ lastActiveElementBeforeModal = null;\n 1952→ lastBodyOverflow = null;\n 1953→ }\n 1954→\n 1955→ function renderDetailImages(obj) {\n 1956→ const urls = extractImageUrls(obj);\n 1957→ els.detailImages.innerHTML = '';\n 1958→ if (!urls.length) {\n 1959→ els.detailImages.hidden = true;\n 1960→ return;\n 1961→ }\n 1962→ const fragment = document.createDocumentFragment();\n 1963→ urls.forEach((src, idx) => {\n 1964→ const btn = document.createElement('button');\n 1965→ btn.type = 'button';\n 1966→ btn.className = 'thumb';\n 1967→ btn.title = `Open image ${idx + 1}`;\n 1968→ const img = document.createElement('img');\n 1969→ img.loading = 'lazy';\n 1970→ img.src = src;\n 1971→ img.alt = `Image ${idx + 1}`;\n 1972→ btn.appendChild(img);\n 1973→ btn.addEventListener('click', () => openImageModal(src, `Image ${idx + 1}`));\n 1974→ fragment.appendChild(btn);\n 1975→ });\n 1976→ els.detailImages.appendChild(fragment);\n 1977→ els.detailImages.hidden = false;\n 1978→ }\n 1979→\n 1980→ function isEditableElement(el) {\n 1981→ if (!(el instanceof HTMLElement)) return false;\n 1982→ if (el.isContentEditable) return true;\n 1983→ const tag = el.tagName;\n 1984→ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';\n 1985→ }\n 1986→\n 1987→ function moveSelection(delta) {\n 1988→ if (!state.filtered.length) return;\n 1989→ const idx = state.filtered.findIndex(e => e.id === state.selectedId);\n 1990→ if (idx === -1) {\n 1991→ selectEvent(state.filtered[0].id, { scroll: true });\n 1992→ return;\n 1993→ }\n 1994→ const nextIdx = Math.max(0, Math.min(state.filtered.length - 1, idx + delta));\n 1995→ if (nextIdx === idx) return;\n 1996→ selectEvent(state.filtered[nextIdx].id, { scroll: true });\n 1997→ }\n 1998→\n 1999→ async function copyText(text) {\n 2000→ if (typeof text !== 'string') return;\n 2001→ try {\n 2002→ await navigator.clipboard.writeText(text);\n 2003→ setStatus('Copied to clipboard.', 'good');\n 2004→ } catch (error) {\n 2005→ const ta = document.createElement('textarea');\n 2006→ ta.value = text;\n 2007→ ta.style.position = 'fixed';\n 2008→ ta.style.left = '-9999px';\n 2009→ document.body.appendChild(ta);\n 2010→ ta.select();\n 2011→ document.execCommand('copy');\n 2012→ document.body.removeChild(ta);\n 2013→ setStatus('Copied to clipboard.', 'good');\n 2014→ }\n 2015→ }\n 2016→\n 2017→ function setTimezoneMode(mode) {\n 2018→ state.tzMode = mode;\n 2019→ els.tzLocal.classList.toggle('active', mode === 'local');\n 2020→ els.tzUtc.classList.toggle('active', mode === 'utc');\n 2021→ applyFilters();\n 2022→ if (state.selectedId !== null) selectEvent(state.selectedId);\n 2023→ syncUrlHashFromControls();\n 2024→ }\n 2025→\n 2026→ els.imageModalClose.addEventListener('click', closeImageModal);\n 2027→ els.imageModal.addEventListener('click', (event) => {\n 2028→ if (event.target === els.imageModal) closeImageModal();\n 2029→ });\n 2030→ document.addEventListener('keydown', (event) => {\n 2031→ if (event.key === 'Escape') closeImageModal();\n 2032→ });\n 2033→\n 2034→ document.addEventListener('keydown', (event) => {\n 2035→ if (els.imageModal.classList.contains('open')) return;\n 2036→ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;\n 2037→ if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;\n 2038→ if (isEditableElement(document.activeElement)) return;\n 2039→ if (document.activeElement !== els.timeline && !els.timeline.contains(document.activeElement)) return;\n 2040→ if (state.selectedId === null) return;\n 2041→ event.preventDefault();\n 2042→ moveSelection(event.key === 'ArrowUp' ? -1 : 1);\n 2043→ });\n 2044→\n 2045→ if (typeof wideLayoutMq.addEventListener === 'function') {\n 2046→ wideLayoutMq.addEventListener('change', updateDetailPlacement);\n 2047→ } else if (typeof wideLayoutMq.addListener === 'function') {\n 2048→ wideLayoutMq.addListener(updateDetailPlacement);\n 2049→ }\n 2050→\n 2051→ function setTab(tabName) {\n 2052→ for (const tab of document.querySelectorAll('.tab')) {\n 2053→ const active = tab.dataset.tab === tabName;\n 2054→ tab.classList.toggle('active', active);\n 2055→ tab.setAttribute('aria-selected', active ? 'true' : 'false');\n 2056→ }\n 2057→ for (const panel of document.querySelectorAll('[data-panel]')) {\n 2058→ panel.hidden = panel.dataset.panel !== tabName;\n 2059→ }\n 2060→ }\n 2061→\n 2062→ function setShareUrlParam(url) {\n 2063→ const current = new URL(window.location.href);\n 2064→ if (url) current.searchParams.set('url', url);\n 2065→ else current.searchParams.delete('url');\n 2066→ window.history.replaceState(null, '', current);\n 2067→ }\n 2068→\n 2069→ async function fetchAndRenderUrl(url, { syncUrl = true } = {}) {\n 2070→ const trimmed = (url || '').trim();\n 2071→ if (!trimmed) {\n 2072→ setStatus('Enter a URL first.', 'error');\n 2073→ return;\n 2074→ }\n 2075→ if (syncUrl) setShareUrlParam(trimmed);\n 2076→ setStatus(`Fetching ${trimmed}...`, 'warn');\n 2077→ try {\n 2078→ const resp = await fetch(trimmed, { mode: 'cors' });\n 2079→ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);\n 2080→ const text = await resp.text();\n 2081→ await loadText(text, trimmed);\n 2082→ } catch (error) {\n 2083→ setStatus(`Failed to fetch URL: ${String(error)}`, 'error');\n 2084→ }\n 2085→ }\n 2086→\n 2087→ async function loadText(text, sourceLabel) {\n 2088→ const started = performance.now();\n 2089→ state.sourceLabel = sourceLabel || null;\n 2090→ setStatus(`Parsing ${sourceLabel || 'content'}...`, 'warn');\n 2091→ clearSelection({ syncHash: false });\n 2092→\n 2093→ const lines = text.split(/\\r?\\n/);\n 2094→ const events = [];\n 2095→ const errors = [];\n 2096→ let idCounter = 0;\n 2097→\n 2098→ for (let i = 0; i < lines.length; i++) {\n 2099→ const line = lines[i];\n 2100→ if (!line.trim()) continue;\n 2101→ const parsed = safeJsonParse(line);\n 2102→ if (!parsed.ok) {\n 2103→ errors.push({ line: i + 1, error: parsed.error });\n 2104→ continue;\n 2105→ }\n 2106→ const obj = parsed.value;\n 2107→ const ts = typeof obj.timestamp === 'string' ? new Date(obj.timestamp) : null;\n 2108→ const kind = obj.type || '(unknown)';\n 2109→ const message = obj.message && typeof obj.message === 'object' ? obj.message : null;\n 2110→ const contentTypes = message ? getContentTypes(message) : [];\n 2111→ const role = message?.role ?? null;\n 2112→\n 2113→ const summary = summarizeEvent(obj);\n 2114→ events.push({\n 2115→ id: ++idCounter,\n 2116→ line: i + 1,\n 2117→ raw: line,\n 2118→ obj,\n 2119→ date: ts,\n 2120→ kind,\n 2121→ contentTypes,\n 2122→ role,\n 2123→ summary,\n 2124→ search: buildSearchText(obj, summary),\n 2125→ });\n 2126→ }\n 2127→\n 2128→ state.events = events;\n 2129→ updateFilterOptions(events);\n 2130→ applyFilters();\n 2131→ applySelectionFromHash();\n 2132→ syncUrlHashFromControls();\n 2133→\n 2134→ const durationMs = Math.round(performance.now() - started);\n 2135→ if (errors.length) {\n 2136→ const first = errors[0];\n 2137→ setStatus(\n 2138→ `Loaded ${events.length} events from ${sourceLabel || 'content'} in ${durationMs}ms.\\n` +\n 2139→ `Could not parse ${errors.length} line(s). First error at line ${first.line}: ${String(first.error)}`,\n 2140→ 'warn'\n 2141→ );\n 2142→ } else {\n 2143→ setStatus(`Loaded ${events.length} events from ${sourceLabel || 'content'} in ${durationMs}ms.`, 'good');\n 2144→ }\n 2145→ }\n 2146→\n 2147→ async function handleFile(file) {\n 2148→ if (!file) return;\n 2149→ setShareUrlParam(null);\n 2150→ try {\n 2151→ const text = await file.text();\n 2152→ await loadText(text, file.name);\n 2153→ } catch (error) {\n 2154→ setStatus(`Failed to read file: ${String(error)}`, 'error');\n 2155→ }\n 2156→ }\n 2157→\n 2158→ els.dropzone.addEventListener('click', () => els.fileInput.click());\n 2159→ els.dropzone.addEventListener('keydown', (e) => {\n 2160→ if (e.key === 'Enter' || e.key === ' ') {\n 2161→ e.preventDefault();\n 2162→ els.fileInput.click();\n 2163→ }\n 2164→ });\n 2165→ els.fileInput.addEventListener('change', () => handleFile(els.fileInput.files?.[0]));\n 2166→\n 2167→ els.dropzone.addEventListener('dragover', (e) => {\n 2168→ e.preventDefault();\n 2169→ els.dropzone.classList.add('dragover');\n 2170→ });\n 2171→ els.dropzone.addEventListener('dragleave', () => els.dropzone.classList.remove('dragover'));\n 2172→ els.dropzone.addEventListener('drop', (e) => {\n 2173→ e.preventDefault();\n 2174→ els.dropzone.classList.remove('dragover');\n 2175→ const file = e.dataTransfer?.files?.[0];\n 2176→ if (file) handleFile(file);\n 2177→ });\n 2178→\n 2179→ els.renderPaste.addEventListener('click', async () => {\n 2180→ const text = els.pasteInput.value || '';\n 2181→ if (!text.trim()) {\n 2182→ setStatus('Paste some JSONL content first.', 'error');\n 2183→ return;\n 2184→ }\n 2185→ setShareUrlParam(null);\n 2186→ await loadText(text, 'pasted content');\n 2187→ });\n 2188→\n 2189→ els.fetchUrl.addEventListener('click', async () => {\n 2190→ await fetchAndRenderUrl(els.urlInput.value, { syncUrl: true });\n 2191→ });\n 2192→\n 2193→ for (const tab of document.querySelectorAll('.tab')) {\n 2194→ tab.addEventListener('click', () => setTab(tab.dataset.tab));\n 2195→ }\n 2196→\n 2197→ els.tzLocal.addEventListener('click', () => setTimezoneMode('local'));\n 2198→ els.tzUtc.addEventListener('click', () => setTimezoneMode('utc'));\n 2199→\n 2200→ els.search.addEventListener('input', () => {\n 2201→ state.filters.query = els.search.value.trim().toLowerCase();\n 2202→ applyFilters();\n 2203→ syncUrlHashFromControls();\n 2204→ });\n 2205→ els.typeFilter.addEventListener('change', () => {\n 2206→ state.filters.type = els.typeFilter.value;\n 2207→ applyFilters();\n 2208→ syncUrlHashFromControls();\n 2209→ });\n 2210→ els.contentTypeFilter.addEventListener('change', () => {\n 2211→ state.filters.contentType = els.contentTypeFilter.value;\n 2212→ applyFilters();\n 2213→ syncUrlHashFromControls();\n 2214→ });\n 2215→ els.roleFilter.addEventListener('change', () => {\n 2216→ state.filters.role = els.roleFilter.value;\n 2217→ applyFilters();\n 2218→ syncUrlHashFromControls();\n 2219→ });\n 2220→ els.hideThinking.addEventListener('change', () => {\n 2221→ state.filters.hideThinking = els.hideThinking.checked;\n 2222→ applyFilters();\n 2223→ syncUrlHashFromControls();\n 2224→ });\n 2225→ els.truncateStrings.addEventListener('change', () => {\n 2226→ if (state.selectedId !== null) selectEvent(state.selectedId);\n 2227→ syncUrlHashFromControls();\n 2228→ });\n 2229→\n 2230→ els.clearFilters.addEventListener('click', () => {\n 2231→ els.search.value = '';\n 2232→ state.filters.query = '';\n 2233→ state.filters.type = 'all';\n 2234→ state.filters.contentType = 'all';\n 2235→ state.filters.role = 'all';\n 2236→ state.filters.hideThinking = false;\n 2237→ els.hideThinking.checked = false;\n 2238→ updateFilterOptions(state.events);\n 2239→ applyFilters();\n 2240→ syncUrlHashFromControls();\n 2241→ });\n 2242→\n 2243→ els.copyJson.addEventListener('click', () => {\n 2244→ if (!state.selected) return;\n 2245→ copyText(stringifyJson(state.selected.obj, false));\n 2246→ });\n 2247→ els.copyRaw.addEventListener('click', () => {\n 2248→ if (!state.selected) return;\n 2249→ copyText(state.selected.raw);\n 2250→ });\n 2251→\n 2252→ async function initFromQuery() {\n 2253→ const url = new URLSearchParams(window.location.search).get('url');\n 2254→ if (!url) return;\n 2255→ setTab('url');\n 2256→ els.urlInput.value = url;\n 2257→ await fetchAndRenderUrl(url, { syncUrl: false });\n 2258→ }\n 2259→\n 2260→ // Initialize select options for the empty state.\n 2261→ applyControlsFromHash();\n 2262→ updateFilterOptions([]);\n 2263→ syncUrlHashFromControls();\n 2264→ window.addEventListener('hashchange', () => {\n 2265→ applyControlsFromHash();\n 2266→ updateFilterOptions(state.events);\n 2267→ applyFilters();\n 2268→ applySelectionFromHash();\n 2269→ });\n 2270→ void initFromQuery();\n 2271→ </script>\n 2272→</body>\n 2273→</html>\n 2274→\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":"ebd19f9e-2204-4f57-9616-ac83c3e6df60","timestamp":"2025-12-17T21:51:03.957Z","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 <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 };\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 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\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":2274,"startLine":1,"totalLines":2274}}} | |
| {"parentUuid":"ebd19f9e-2204-4f57-9616-ac83c3e6df60","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"e2651abf-182b-466f-8fe6-4b422b5821f5","version":"2.0.71","gitBranch":"main","slug":"woolly-percolating-wren","message":{"model":"claude-opus-4-5-20251101","id":"msg_01TRb5Geud4SsDGEo2BcjWVP","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to add a \"Copy prompts\" button that:\n1. Is placed directly above the two-column display (the `<main>` element which contains the timeline and detail panels)\n2. When clicked, concatenates all user prompts where:\n - type=user\n - content-type=text \n - role=user\n3. Copies the result to clipboard\n\nLooking at the code structure:\n- The two-column display is in `<main>` around line 944\n- Events have properties like `kind` (type), `contentTypes`, and `role`\n- The actual message content is in `e.obj.message.content`\n\nI need to:\n1. Add a button above the `<main>` element\n2. Add a function that extracts user prompts from the events\n3. Wire up the button click handler\n\nLet me add the button in an appropriate place - probably in a small container above `<main>`. Looking at the structure, I could add it between the filters panel and the main section.\n\nThe function needs to:\n1. Loop through `state.events`\n2. Filter where `e.kind === 'user'` and `e.role === 'user'` and `e.contentTypes.includes('text')`\n3. For each matching event, extract the text content from `e.obj.message.content`\n4. Concatenate them all together\n5. Copy to clipboard\n\nLet me implement this.","signature":"ErkKCkYIChgCKkCvV5VRAehcReIoq0c9qWIqUkLjjlgNnEj21I6IJNqG0ZGN3YpWkeSsnarfMyAxDAtC0uT1fNxqhUXoxxrrd4vLEgzz131uzuVaKX3UZTQaDLW6XgMGdJghM0NHdiIw/XoaS8VsHA+LE3YGbfPX/q54b0WmIXOzptdTzzncfhgYKtRqxbG48HEWs0toewz1KqAJzeLPXSYDwmRqK8HUPYIVNaSA+3K59ZxKp/KlWB1rCQIbtGQkOsHpD4YUsWSqbhhmXT98/HStMZNHIzKHOQKvrFScqpn+YA0qlu9y8Uy51R0NjnKNa6P/Lwu+Q/QXR3deunPEY9qD7GNOSUheGs5y2R5HKQpKOIpmG9CvvgdioMV+cGl+9K6CTZFpX/HNDVPznwFBX9adssjETPQm17YkuQXo6KZl6ckibHEahx6lUWeceaU30+15xZPnmr79OGCZSzH1ei87yg4bAVfyTfrKwg/GrVp9r2BGzUZuEWS2xxymR9fMuXT1v9LuTtmhuXM3V6950DPpKIe0OdGMqxtY2QxGPtWt/0/jFGIs1H/jfL8O8VpYinYGxOvl/Gqb9htqRBMRDx/xDtG/CTkiMvRhR1x++5SmYAIlwtXhvgbxosicFo91qgVRmqmmfc9ZxgMS3bHoGRf6rnqkTT/qizGzNNBPMtISEhsOT1kzzKgpKMfJ0T0F9iCv2CIT03xj3GuuzCJ87XtYgxEvscK/N8zzI59WrYrCmk32oT9ffBjD5EBKhCrwrj1QajA+S1VLKhC6pXln9uwfsiyRY6xNqKo3ZZOiDNDtXOEiQwpD07A4pK/Ab2SAcA/saStQCnUAWo07+eNMyC96MRvSKYp/pTbnqLhWTr35Y+WQnZZsl1RFhPyqXKJZiNjOCRB9BCap1hzVBMFIwcsDfp8BppHMFKvl0K6oycmhP9juuDQNO6LLB6VGqT+bszwH3uOIl3/Rokk4GMYNhPtNLNa/bG39nfx/VqElr2pmMtMUJXP0Q54zYvFRT8xgn//2b7vHiTl9FimLSnSlIDRe/xpvQQ6ekjKBv6IoZ5b6HwJiZowwzyQajnUGe7q7abRIFOF9/O3aQRsJeUIiR6DEMPGoEK/Bj3yZlVOoZM4So3voYhBYus52Qt3rn92mscE+p4qzS0yNMqC7r/MiQUut9ezrkmAcvmeiHP7SCiC5IDBKKYcSUgOKfndV29jkgi+jMjsCocU5WAKksfVAAeD2bhkbV7ht9qG3CS/oR/By19zElCwp0Q0TNM6dUxeT+62QlA49twB6L8bON85Ngj4X8G4u7GcTudppG+kkDMVWHRz+2Z3sZFohn1V2CqnQqiuUmjqW88ZPdUz5UG5lOBJu9zgd2xlmFhB2gEWO5vqB/BZaYAEYWqt3u8spc0uMP1aBBAMtL3MZ2V1rTlH/klSbT0oiSjh+NR9LqjsLUO8Wx2UF6YF94UU3+4NetCVo84g5RONAFsyWrOWpczTxNO9M9S5dPKR+vRGBbk4vxbOotMKUPjKBF0Z0qqP0PWqOEPAL1bx4k46Bcvzhkz9YjucDKiM2qfCQloflV1NX9/6jkY8Fj3cYRsqX2XYemGw1QvKD7OYlMTgaFkJ4lpS+WdM3M168NB6QouvUB7D3efn2vd1Y8Y1nLDI2uf0feXvPRZzKl7bJRcPEGbxzDMtIrAcWxNWf+Zrs++fmlHEd1oC2V2kXRnb5lkxAGaMIrn2kMXK2WqOMk2GQpOTde8iSZubgeiwWoJPGX343+CACx9n+ZJrUXjCY14A17AEYAQ=="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":31816,"cache_read_input_tokens":18680,"cache_creation":{"ephemeral_5m_input_tokens":31816,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWCzu5aMHdYDBKcXqvq6F","type":"assistant","uuid":"876a6f2f-332a-492f-a8da-6d4550170ec6","timestamp":"2025-12-17T21:51:13.274Z"} | |
| {"parentUuid":"876a6f2f-332a-492f-a8da-6d4550170ec6","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"e2651abf-182b-466f-8fe6-4b422b5821f5","version":"2.0.71","gitBranch":"main","slug":"woolly-percolating-wren","message":{"model":"claude-opus-4-5-20251101","id":"msg_01TRb5Geud4SsDGEo2BcjWVP","type":"message","role":"assistant","content":[{"type":"text","text":"I'll add a \"Copy prompts\" button above the two-column display. Let me make the edit."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":31816,"cache_read_input_tokens":18680,"cache_creation":{"ephemeral_5m_input_tokens":31816,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWCzu5aMHdYDBKcXqvq6F","type":"assistant","uuid":"e04137f1-4fb8-484f-90b6-9f9c2f63dfad","timestamp":"2025-12-17T21:51:13.782Z"} | |
| {"parentUuid":"e04137f1-4fb8-484f-90b6-9f9c2f63dfad","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"e2651abf-182b-466f-8fe6-4b422b5821f5","version":"2.0.71","gitBranch":"main","slug":"woolly-percolating-wren","message":{"model":"claude-opus-4-5-20251101","id":"msg_01TRb5Geud4SsDGEo2BcjWVP","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01XvfAgsRkL2cGrNg6SnTsNm","name":"Edit","input":{"file_path":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","old_string":" </section>\n\n <main>","new_string":" </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>"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":31816,"cache_read_input_tokens":18680,"cache_creation":{"ephemeral_5m_input_tokens":31816,"ephemeral_1h_input_tokens":0},"output_tokens":533,"service_tier":"standard"}},"requestId":"req_011CWCzu5aMHdYDBKcXqvq6F","type":"assistant","uuid":"82aa35aa-0864-4663-ab24-e317f71565b3","timestamp":"2025-12-17T21:51:15.985Z"} | |
| {"type":"file-history-snapshot","messageId":"82aa35aa-0864-4663-ab24-e317f71565b3","snapshot":{"messageId":"cc93ec3d-d1ea-4e9c-b82d-6f575befa5ef","trackedFileBackups":{"claude-code-timeline.html":{"backupFileName":"cc3a5887c9685f80@v1","version":1,"backupTime":"2025-12-17T21:51:16.033Z"}},"timestamp":"2025-12-17T21:50:59.635Z"},"isSnapshotUpdate":true} | |
| {"parentUuid":"82aa35aa-0864-4663-ab24-e317f71565b3","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"e2651abf-182b-466f-8fe6-4b422b5821f5","version":"2.0.71","gitBranch":"main","slug":"woolly-percolating-wren","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01XvfAgsRkL2cGrNg6SnTsNm","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 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>"}]},"uuid":"5e947d00-d264-4a9e-b72c-37d63eedf664","timestamp":"2025-12-17T21:51:16.026Z","toolUseResult":{"filePath":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","oldString":" </section>\n\n <main>","newString":" </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>","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 <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 };\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 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\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":941,"oldLines":6,"newStart":941,"newLines":10,"lines":[" </div>"," </section>"," ","+ <div class=\"controls\" style=\"margin-bottom: 14px;\">","+ <button type=\"button\" class=\"btn\" id=\"copy-prompts\">Copy prompts</button>","+ </div>","+"," <main>"," <section class=\"panel timeline-panel\" aria-label=\"Timeline\">"," <div class=\"stats\">"]}],"userModified":false,"replaceAll":false}} | |
| {"parentUuid":"5e947d00-d264-4a9e-b72c-37d63eedf664","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"e2651abf-182b-466f-8fe6-4b422b5821f5","version":"2.0.71","gitBranch":"main","slug":"woolly-percolating-wren","message":{"model":"claude-opus-4-5-20251101","id":"msg_015vZEiuK9cbYwZ6ogo1d6tR","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Now I need to add the element reference and the event listener for the button. Let me add it to the `els` object and then add the click handler.","signature":"ErkCCkYIChgCKkBDI7edE+1ebTwiv9LWlUx6Y4PfWZMkrpe4bq9j8T1b7a/7McD0LJU3TBQKH83IiASsirt4gQeDOLRZQsQC/Z/2Egy3Erl5P6z0psIUDU8aDO6ix5EfhIu1nAGSWyIwKTFrxWJlizaTCprsJYtMUzMC3Mhj5kW2NXvoTmB/juO5yr6nfm1P8WqzBkozS8Z/KqAB378hfGHBLdXI0OhZPYqi46JI9vGST9jzSQlovDZyURXagQKbwewt4PTU5fJ8EVLk6qpCBi8kNiXRHqDiBi9YqhJcCISYU0TnpyVefhM9e/sncjK0Uc+fN+j0iIYKbiP6gZ0xNgvnNDYx9fKNevRVrMvlWCw9wJqe0zee51zZMuzOu3s/APoRl2x7TOSqUjtJAqaMsU8B7Tzq0uP/HjMwvBgB"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":839,"cache_read_input_tokens":50496,"cache_creation":{"ephemeral_5m_input_tokens":839,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard"}},"requestId":"req_011CWCzuyHMdhqVmcrhTELFX","type":"assistant","uuid":"a97ebd0c-966f-4506-94b0-d19fb8cde753","timestamp":"2025-12-17T21:51:20.338Z"} | |
| {"parentUuid":"a97ebd0c-966f-4506-94b0-d19fb8cde753","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"e2651abf-182b-466f-8fe6-4b422b5821f5","version":"2.0.71","gitBranch":"main","slug":"woolly-percolating-wren","message":{"model":"claude-opus-4-5-20251101","id":"msg_015vZEiuK9cbYwZ6ogo1d6tR","type":"message","role":"assistant","content":[{"type":"text","text":"Now I'll add the element reference and the click handler. First, let me add it to the `els` object."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":839,"cache_read_input_tokens":50496,"cache_creation":{"ephemeral_5m_input_tokens":839,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard"}},"requestId":"req_011CWCzuyHMdhqVmcrhTELFX","type":"assistant","uuid":"c50370aa-a730-455d-9c8d-7dd64d3cc42d","timestamp":"2025-12-17T21:51:20.617Z"} | |
| {"parentUuid":"c50370aa-a730-455d-9c8d-7dd64d3cc42d","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"e2651abf-182b-466f-8fe6-4b422b5821f5","version":"2.0.71","gitBranch":"main","slug":"woolly-percolating-wren","message":{"model":"claude-opus-4-5-20251101","id":"msg_015vZEiuK9cbYwZ6ogo1d6tR","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_019FScA7SwPU5gTr8GHe4suh","name":"Edit","input":{"file_path":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","old_string":" imageModalImg: document.getElementById('image-modal-img'),\n imageModalClose: document.getElementById('image-modal-close'),\n };","new_string":" imageModalImg: document.getElementById('image-modal-img'),\n imageModalClose: document.getElementById('image-modal-close'),\n copyPrompts: document.getElementById('copy-prompts'),\n };"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":839,"cache_read_input_tokens":50496,"cache_creation":{"ephemeral_5m_input_tokens":839,"ephemeral_1h_input_tokens":0},"output_tokens":258,"service_tier":"standard"}},"requestId":"req_011CWCzuyHMdhqVmcrhTELFX","type":"assistant","uuid":"a3c978ad-2838-42a1-9e7b-01dbd083deca","timestamp":"2025-12-17T21:51:22.194Z"} | |
| {"parentUuid":"a3c978ad-2838-42a1-9e7b-01dbd083deca","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"e2651abf-182b-466f-8fe6-4b422b5821f5","version":"2.0.71","gitBranch":"main","slug":"woolly-percolating-wren","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_019FScA7SwPU5gTr8GHe4suh","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 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',"}]},"uuid":"5eb7194e-3a3a-403e-9d79-1af7189c3dc5","timestamp":"2025-12-17T21:51:22.251Z","toolUseResult":{"filePath":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","oldString":" imageModalImg: document.getElementById('image-modal-img'),\n imageModalClose: document.getElementById('image-modal-close'),\n };","newString":" imageModalImg: document.getElementById('image-modal-img'),\n imageModalClose: document.getElementById('image-modal-close'),\n copyPrompts: document.getElementById('copy-prompts'),\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 </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 };\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 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\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":1015,"oldLines":6,"newStart":1015,"newLines":7,"lines":[" imageModal: document.getElementById('image-modal'),"," imageModalImg: document.getElementById('image-modal-img'),"," imageModalClose: document.getElementById('image-modal-close'),","+ copyPrompts: document.getElementById('copy-prompts'),"," };"," "," const state = {"]}],"userModified":false,"replaceAll":false}} | |
| {"parentUuid":"5eb7194e-3a3a-403e-9d79-1af7189c3dc5","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"e2651abf-182b-466f-8fe6-4b422b5821f5","version":"2.0.71","gitBranch":"main","slug":"woolly-percolating-wren","message":{"model":"claude-opus-4-5-20251101","id":"msg_01KmBDKfXwmU5Eo59t3HaJDm","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Now I need to add a function that extracts user prompts and the event handler. I'll add it near the other copy functions and then wire up the event handler.","signature":"EsUCCkYIChgCKkAXm/n8u2WBViPKYmr4h3lemSW3cfSXeYE5lkXKyJvFaSFClI9JTysyx9uPd2g4aEOdQTMwFtuLnJRFE6e1ofUDEgzB1oYPhpuGc9Y7QFgaDOda0A0G8VFhFLSt2SIwX+4cv5+K77NIS4I/A/Rx/mRaMfdgKI2CdALKRza728FMDUgfw5XC3cLKzrYM0QGUKqwB2+A8iHHcUD5LV4DGeXXL6NKbNXISodMKaOhF7R/PqB5I4FAAphjn7OQm9BohBCInzye/swwOxyt/0/VvydHKA1oO49JFmiuNr7+scqfHX1dQbcf/rsj0b6XonUkMZ2aff4DQ1p6OWAWAS8s/nyCjq2dObcxlVLKCrGiHyb4ep/67cWovTerHCHaX6M5GbMyvS0EK9oP5oghGa0462FoxWZFupu3y6vxoU52+YxgB"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":496,"cache_read_input_tokens":51335,"cache_creation":{"ephemeral_5m_input_tokens":496,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard"}},"requestId":"req_011CWCzvRnofqgN6QNnZHtUn","type":"assistant","uuid":"4a2941fa-7a4f-4e21-bbab-56205c1a466a","timestamp":"2025-12-17T21:51:25.377Z"} | |
| {"parentUuid":"4a2941fa-7a4f-4e21-bbab-56205c1a466a","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"e2651abf-182b-466f-8fe6-4b422b5821f5","version":"2.0.71","gitBranch":"main","slug":"woolly-percolating-wren","message":{"model":"claude-opus-4-5-20251101","id":"msg_01KmBDKfXwmU5Eo59t3HaJDm","type":"message","role":"assistant","content":[{"type":"text","text":"Now I'll add the function to extract and copy user prompts. I'll add it after the `copyText` function."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":496,"cache_read_input_tokens":51335,"cache_creation":{"ephemeral_5m_input_tokens":496,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard"}},"requestId":"req_011CWCzvRnofqgN6QNnZHtUn","type":"assistant","uuid":"f25d6a0f-c5b1-4f16-b94c-cfd3b7b8f47e","timestamp":"2025-12-17T21:51:25.863Z"} | |
| {"parentUuid":"f25d6a0f-c5b1-4f16-b94c-cfd3b7b8f47e","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"e2651abf-182b-466f-8fe6-4b422b5821f5","version":"2.0.71","gitBranch":"main","slug":"woolly-percolating-wren","message":{"model":"claude-opus-4-5-20251101","id":"msg_01KmBDKfXwmU5Eo59t3HaJDm","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01QDjieVCpgGQGrYmiKgoL8V","name":"Edit","input":{"file_path":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","old_string":" 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 }","new_string":" 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 }"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":496,"cache_read_input_tokens":51335,"cache_creation":{"ephemeral_5m_input_tokens":496,"ephemeral_1h_input_tokens":0},"output_tokens":781,"service_tier":"standard"}},"requestId":"req_011CWCzvRnofqgN6QNnZHtUn","type":"assistant","uuid":"b8abef5f-5f9b-433f-9bf5-d2fd1b3df833","timestamp":"2025-12-17T21:51:32.276Z"} | |
| {"parentUuid":"b8abef5f-5f9b-433f-9bf5-d2fd1b3df833","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"e2651abf-182b-466f-8fe6-4b422b5821f5","version":"2.0.71","gitBranch":"main","slug":"woolly-percolating-wren","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01QDjieVCpgGQGrYmiKgoL8V","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 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→ }\n 2055→\n 2056→ function setTimezoneMode(mode) {\n 2057→ state.tzMode = mode;\n 2058→ els.tzLocal.classList.toggle('active', mode === 'local');"}]},"uuid":"141785bc-0f44-4074-ad26-f330dd8465d9","timestamp":"2025-12-17T21:51:32.303Z","toolUseResult":{"filePath":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","oldString":" 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 }","newString":" 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 }","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 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\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":2019,"oldLines":6,"newStart":2019,"newLines":40,"lines":[" }"," }"," ","+ function extractUserPrompts() {","+ const prompts = [];","+ for (const e of state.events) {","+ // Filter: type=user, content-type includes text, role=user","+ if (e.kind !== 'user') continue;","+ if (e.role !== 'user') continue;","+ if (!e.contentTypes.includes('text')) continue;","+","+ const message = e.obj?.message;","+ if (!message) continue;","+","+ const content = message.content;","+ if (typeof content === 'string') {","+ prompts.push(content);","+ } else if (Array.isArray(content)) {","+ for (const item of content) {","+ if (item?.type === 'text' && item.text) {","+ prompts.push(item.text);","+ }","+ }","+ }","+ }","+ return prompts.join('\\n\\n');","+ }","+","+ async function copyUserPrompts() {","+ const text = extractUserPrompts();","+ if (!text) {","+ setStatus('No user prompts found.', 'warn');","+ return;","+ }","+ await copyText(text);","+ }","+"," function setTimezoneMode(mode) {"," state.tzMode = mode;"," els.tzLocal.classList.toggle('active', mode === 'local');"]}],"userModified":false,"replaceAll":false}} | |
| {"parentUuid":"141785bc-0f44-4074-ad26-f330dd8465d9","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"e2651abf-182b-466f-8fe6-4b422b5821f5","version":"2.0.71","gitBranch":"main","slug":"woolly-percolating-wren","message":{"model":"claude-opus-4-5-20251101","id":"msg_01LoTDcyg5nycaLvD6kJyd22","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Now I need to add the event listener for the copy prompts button. Let me find where the other click handlers are added.","signature":"EqACCkYIChgCKkAO7dLkAoGVfOYnNLgx3GgKYE0Ugkw9mliMSdVQxR2YOwY217eCsCuPz0qKhJWtv3kQx/ThKsUlRRIca1SNL4epEgye8bvSqZi6O8EYrxYaDNQq52l5ASRW74cMoyIwQC6iysaLQc2+4r6gQZx/9gHK/8JgorxG69hMSb45c85ue0pPlbsCOSdAn57LRcOZKocBXwEjKtOtuaLykuXFoDhWW2CS2CG+DafzBBPzhl/yadPsbqDIgmLCO+Dfhi3am9buPOUnoqu1LbmNyWUsWTiWEgWE0iIaEH6OBHnCPNjEhWFd7M6vIqjoxAPg2ePmJpAldivBVtxwX3n0O+/X6nFGr9mfV0txfnwu2UWfponDBypHQ0HRByNMGAE="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":1705,"cache_read_input_tokens":51831,"cache_creation":{"ephemeral_5m_input_tokens":1705,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWCzwAmh8oqebHgxYU32R","type":"assistant","uuid":"7481df44-5d57-438c-ad95-b5bcfa7f65f5","timestamp":"2025-12-17T21:51:35.828Z"} | |
| {"parentUuid":"7481df44-5d57-438c-ad95-b5bcfa7f65f5","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"e2651abf-182b-466f-8fe6-4b422b5821f5","version":"2.0.71","gitBranch":"main","slug":"woolly-percolating-wren","message":{"model":"claude-opus-4-5-20251101","id":"msg_01LoTDcyg5nycaLvD6kJyd22","type":"message","role":"assistant","content":[{"type":"text","text":"Now I'll add the click handler for the button. I'll add it near the other copy button handlers."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":1705,"cache_read_input_tokens":51831,"cache_creation":{"ephemeral_5m_input_tokens":1705,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWCzwAmh8oqebHgxYU32R","type":"assistant","uuid":"6745d799-f070-4114-a775-3244f02d2c30","timestamp":"2025-12-17T21:51:35.917Z"} | |
| {"parentUuid":"6745d799-f070-4114-a775-3244f02d2c30","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"e2651abf-182b-466f-8fe6-4b422b5821f5","version":"2.0.71","gitBranch":"main","slug":"woolly-percolating-wren","message":{"model":"claude-opus-4-5-20251101","id":"msg_01LoTDcyg5nycaLvD6kJyd22","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_016NMddXEhZ28KSiGCnnErA5","name":"Edit","input":{"file_path":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","old_string":" 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 });","new_string":" 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);"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":1705,"cache_read_input_tokens":51831,"cache_creation":{"ephemeral_5m_input_tokens":1705,"ephemeral_1h_input_tokens":0},"output_tokens":346,"service_tier":"standard"}},"requestId":"req_011CWCzwAmh8oqebHgxYU32R","type":"assistant","uuid":"1bf3bc22-0740-48eb-ba54-8e03dd35dfab","timestamp":"2025-12-17T21:51:37.936Z"} | |
| {"parentUuid":"1bf3bc22-0740-48eb-ba54-8e03dd35dfab","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"e2651abf-182b-466f-8fe6-4b422b5821f5","version":"2.0.71","gitBranch":"main","slug":"woolly-percolating-wren","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_016NMddXEhZ28KSiGCnnErA5","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 2278→ applyFilters();\n 2279→ syncUrlHashFromControls();\n 2280→ });\n 2281→\n 2282→ els.copyJson.addEventListener('click', () => {\n 2283→ if (!state.selected) return;\n 2284→ copyText(stringifyJson(state.selected.obj, false));\n 2285→ });\n 2286→ els.copyRaw.addEventListener('click', () => {\n 2287→ if (!state.selected) return;\n 2288→ copyText(state.selected.raw);\n 2289→ });\n 2290→ els.copyPrompts.addEventListener('click', copyUserPrompts);\n 2291→\n 2292→ async function initFromQuery() {\n 2293→ const url = new URLSearchParams(window.location.search).get('url');\n 2294→ if (!url) return;"}]},"uuid":"3d1e2bdf-ccbc-4830-a9ef-e427afea4c6a","timestamp":"2025-12-17T21:51:37.964Z","toolUseResult":{"filePath":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","oldString":" 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 });","newString":" 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);","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 }\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\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":2287,"oldLines":6,"newStart":2287,"newLines":7,"lines":[" if (!state.selected) return;"," copyText(state.selected.raw);"," });","+ els.copyPrompts.addEventListener('click', copyUserPrompts);"," "," async function initFromQuery() {"," const url = new URLSearchParams(window.location.search).get('url');"]}],"userModified":false,"replaceAll":false}} | |
| {"parentUuid":"3d1e2bdf-ccbc-4830-a9ef-e427afea4c6a","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"e2651abf-182b-466f-8fe6-4b422b5821f5","version":"2.0.71","gitBranch":"main","slug":"woolly-percolating-wren","message":{"model":"claude-opus-4-5-20251101","id":"msg_01Hz7jer4cFDVgpn7atJmuD3","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Done! I've added:\n\n1. A \"Copy prompts\" button above the two-column display (the `<main>` element)\n2. Added the element reference to the `els` object\n3. Added an `extractUserPrompts()` function that:\n - Loops through all events\n - Filters for events where kind='user', role='user', and contentTypes includes 'text'\n - Extracts text content from the message\n - Joins all prompts with double newlines\n4. Added a `copyUserPrompts()` function that extracts and copies to clipboard\n5. Added the click event listener for the button\n\nThe button will concatenate all user prompts (where type=user, content-type=text, role=user) into a single block separated by double newlines and copy it to the clipboard.","signature":"EuUGCkYIChgCKkC4LPy5CRyB5HJD2QX7l6cMwAlzezV2c9SFMoBtvXD05JFm9g/ShM+Kj6dNBIReUULFr5LpYR/rMueUYPMiOPNTEgxtzSlC0dOFPMcgYZgaDElfI5hSB1qLAiv5ZyIwavplxCs/COPyItNb2CJjj6vAPnUMr5wpYgj+9eQQ60xhSU0pHZeAg2zg1TZXXhaxKswFc6i5WfUU8wKa24FE/YLd0b0V7emDFaV8XfxGdNkDeKnH9f2yEcaR4EKPqHNsgyGBb2Fwh3JkgyfpTzjvnEKpDkSdxLiA1okq/dSY7pzFarF0Z8QUnbddq387XRqZOhiO3avu8S1EwYG2yb8yqq/tSpCLHtp8hVa0DJn7z2uQYiwF8Lbj3ai+u1Vza18rLGZRwkuC/nUIB530DXWEUcodbmO4k5g/fRSAYpw4AuIZHO1teeOE2DhOQH7oO7ptoF0t/q9f+7637IMasNfBiNMPA97HofGqV6hXGD6ZkkZX9T0yT3l8UFlQtplMNm1H97t9namUrJrkcuOgt2w4nbRBsbTmBN6hl/tsV7BFtHSpUyDVkYT6VmXYKUSWNH7y0S+hPAnx5fCAnmgJ9Nfw5tXgPIeNkVXgGPtZZYTt6uEL54p2aO7X6ea3mTfifTNflx03RRsWjEo6QfM8XDjQvaf1eHQCs0CvOXoDWF4b52EsNSH3pgWyjoZvvIQEaQQzrsQETUNdsUnq28555vQqCLKZWVuPeaD0MYhqpRyYrDKy0Xx9GdLqQUQPMRokJ6OT0BMPnchNUPX90JAhg6t8oMVbXtAa3Pxsl5AQYsL1t/PZI/k7dIfPptAN3KIPkX89t642cOrfrNo01Nq6yzUe7h2xc9GCqkUmgiNQvPmAUGbyLQucdOUvpVUXUUqzPaRc87yM/NzLU5J8JElpmgNCg5vPYAs72xjoHbf81quq4d6HlEDWOCLtuxAmGEqgXyPOvOVMc6jMYRuDueRFvhgO/+1sVs2oHM/iRp3DPE0QoibpDYJWcVYU0Lfyf9us5gumtvjClWxb6j9HtzjfaXGGrjPV6/rRdQNGDgkZXCdEf1VmfCjyCC4UtdGBruf3Q/n7olAeWrb6qIeq1bLKA0SDkEKT0VeOpVF+GvYaeQfk+i95hgZLscqUeryOUB3emwQYAQ=="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":635,"cache_read_input_tokens":53536,"cache_creation":{"ephemeral_5m_input_tokens":635,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWCzwb6BQQNsDkEkEsbvy","type":"assistant","uuid":"b34ba1fc-6d4f-4b2b-b79a-dd38e16b6ac9","timestamp":"2025-12-17T21:51:44.000Z"} | |
| {"parentUuid":"b34ba1fc-6d4f-4b2b-b79a-dd38e16b6ac9","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"e2651abf-182b-466f-8fe6-4b422b5821f5","version":"2.0.71","gitBranch":"main","slug":"woolly-percolating-wren","message":{"model":"claude-opus-4-5-20251101","id":"msg_01Hz7jer4cFDVgpn7atJmuD3","type":"message","role":"assistant","content":[{"type":"text","text":"Done. I've added a \"Copy prompts\" button directly above the two-column display. When clicked, it:\n\n1. Filters events where `type=user`, `content-type` includes `text`, and `role=user`\n2. Extracts the text content from each matching message\n3. Concatenates them together (separated by double newlines)\n4. Copies the result to the clipboard\n\nIf no user prompts are found, it shows a warning message in the status area."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":635,"cache_read_input_tokens":53536,"cache_creation":{"ephemeral_5m_input_tokens":635,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWCzwb6BQQNsDkEkEsbvy","type":"assistant","uuid":"22f9a072-e516-4396-8240-69cc26f8ab01","timestamp":"2025-12-17T21:51:45.994Z"} | |
| {"type":"file-history-snapshot","messageId":"fe7a1b15-965e-42f7-89ca-c40cb3a21a3a","snapshot":{"messageId":"fe7a1b15-965e-42f7-89ca-c40cb3a21a3a","trackedFileBackups":{"claude-code-timeline.html":{"backupFileName":"cc3a5887c9685f80@v2","version":2,"backupTime":"2025-12-17T21:52:46.324Z"}},"timestamp":"2025-12-17T21:52:46.317Z"},"isSnapshotUpdate":false} | |
| {"parentUuid":"22f9a072-e516-4396-8240-69cc26f8ab01","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"e2651abf-182b-466f-8fe6-4b422b5821f5","version":"2.0.71","gitBranch":"main","slug":"woolly-percolating-wren","type":"user","message":{"role":"user","content":"Make that button switch text to Copied! for a few seconds after it is clicked"},"uuid":"fe7a1b15-965e-42f7-89ca-c40cb3a21a3a","timestamp":"2025-12-17T21:52:46.311Z","thinkingMetadata":{"level":"high","disabled":false,"triggers":[]},"todos":[]} | |
| {"parentUuid":"fe7a1b15-965e-42f7-89ca-c40cb3a21a3a","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"e2651abf-182b-466f-8fe6-4b422b5821f5","version":"2.0.71","gitBranch":"main","slug":"woolly-percolating-wren","message":{"model":"claude-opus-4-5-20251101","id":"msg_01594SM1Ed7ywRtmrD7JxLm7","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"The user wants the \"Copy prompts\" button to show \"Copied!\" text for a few seconds after being clicked, then revert back to \"Copy prompts\". I need to modify the copyUserPrompts function to handle this.","signature":"EvECCkYIChgCKkBZ6l8DdAwa0Dob4AQjZ35FCfPkznCDr+dM4IHAGO6VF9dZvhIFjzfFMJ+ZvMtXf1J8tZE3Z4v7JuMT5z3RsH+OEgwuYVB9ycaDekNGXHQaDCosrf+WejjQBv1NyiIw3sdZ1uoUg8geBJNp68pwe+Jcr3nd0yH+7OEYfvgndHW1KZ41BoeCvfjXVhZCzTCIKtgBdj3cZtwZKoHx2ryr7uuX99jlLKpvYzX2HrmmwPETh4MXL+p3zQHSIOcb0ZoT1K0AWNaGmaHfLmGHej+fW4GG1AE6WJ8wteRDc202kRmxK0K1XAe5roLLPlURudLFOYaS5LqbD6r51TRIhdHeJk+FqBDfkRzxPwVBSUuTlkIkRSRiVrWctnyM3ShbWdAPJTaN/yXD8O7Kj4dbgylBATqdUc5MDzE0BSydqTlGPxNyuRZzbkHInk7AoFiDXXalbSGWKJ2x46zejwA67HyXnCFkBSazejN3PtmfGAE="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":123,"cache_read_input_tokens":54479,"cache_creation":{"ephemeral_5m_input_tokens":123,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard"}},"requestId":"req_011CWD12dnnmno9XWyaaDR1P","type":"assistant","uuid":"8d8b6c66-260d-4cd8-ae8c-839a05c3c262","timestamp":"2025-12-17T21:52:49.905Z"} | |
| {"parentUuid":"8d8b6c66-260d-4cd8-ae8c-839a05c3c262","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"e2651abf-182b-466f-8fe6-4b422b5821f5","version":"2.0.71","gitBranch":"main","slug":"woolly-percolating-wren","message":{"model":"claude-opus-4-5-20251101","id":"msg_01594SM1Ed7ywRtmrD7JxLm7","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01UePyCDrJaTqWSBg8ML7SQC","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 }","new_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 }"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":123,"cache_read_input_tokens":54479,"cache_creation":{"ephemeral_5m_input_tokens":123,"ephemeral_1h_input_tokens":0},"output_tokens":344,"service_tier":"standard"}},"requestId":"req_011CWD12dnnmno9XWyaaDR1P","type":"assistant","uuid":"1dc12354-d3ae-4f2c-ba6c-ed68e09c78e9","timestamp":"2025-12-17T21:52:51.844Z"} | |
| {"parentUuid":"1dc12354-d3ae-4f2c-ba6c-ed68e09c78e9","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"e2651abf-182b-466f-8fe6-4b422b5821f5","version":"2.0.71","gitBranch":"main","slug":"woolly-percolating-wren","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01UePyCDrJaTqWSBg8ML7SQC","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 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');"}]},"uuid":"a0484d7a-1ade-4deb-9dbc-4877f1420e1e","timestamp":"2025-12-17T21:52:51.884Z","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 }","newString":" 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 }","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 }\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":2051,"oldLines":6,"newStart":2051,"newLines":11,"lines":[" return;"," }"," await copyText(text);","+ const originalText = els.copyPrompts.textContent;","+ els.copyPrompts.textContent = 'Copied!';","+ setTimeout(() => {","+ els.copyPrompts.textContent = originalText;","+ }, 2000);"," }"," "," function setTimezoneMode(mode) {"]}],"userModified":false,"replaceAll":false}} | |
| {"parentUuid":"a0484d7a-1ade-4deb-9dbc-4877f1420e1e","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"e2651abf-182b-466f-8fe6-4b422b5821f5","version":"2.0.71","gitBranch":"main","slug":"woolly-percolating-wren","message":{"model":"claude-opus-4-5-20251101","id":"msg_01XoQ4cXSRCUyqhTithNBZyL","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Done. The button will now show \"Copied!\" for 2 seconds after being clicked, then revert back to \"Copy prompts\".","signature":"EpcCCkYIChgCKkAVkPMCEYna8Iv+giJIHGD6tbqGhBg+LyO/BKPeB2PQrOz5D3KT52HEk7cib0aCFHbESh8En9+AMLOhMpKtyG0gEgzAtWXq04ndNLTdJ0caDJhPAbgixzvGdMV3ziIw58lSNPC1+ynt+EHcIBLgI6oHb3qA0Ymb9kwIpZu2TOmB0W2oZ5g6CSNFBHKm5UQLKn/Vn8rNZ3PVtERLeK2wYzSThfJWzwsOu7Vhc/DdDSj4dqeiG2lGQspdsUd8PPzNbCBoHJC2cVrHWoTuTJvdw1TeFrCWbHMK45jsYIihjBmG36Ejq05ApbS83cVQcsdy5/8fG+zLC+xZJjp/7r1uhOjAeipstWxY5qUaDXWzGwZqGAE="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":674,"cache_read_input_tokens":54602,"cache_creation":{"ephemeral_5m_input_tokens":674,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWD133fyLhYXkdmHT1e8c","type":"assistant","uuid":"561c60b0-79eb-4551-9286-1a63fba65cb1","timestamp":"2025-12-17T21:52:55.177Z"} | |
| {"parentUuid":"561c60b0-79eb-4551-9286-1a63fba65cb1","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"e2651abf-182b-466f-8fe6-4b422b5821f5","version":"2.0.71","gitBranch":"main","slug":"woolly-percolating-wren","message":{"model":"claude-opus-4-5-20251101","id":"msg_01XoQ4cXSRCUyqhTithNBZyL","type":"message","role":"assistant","content":[{"type":"text","text":"Done. The button now shows \"Copied!\" for 2 seconds after clicking, then reverts to \"Copy prompts\"."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":674,"cache_read_input_tokens":54602,"cache_creation":{"ephemeral_5m_input_tokens":674,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWD133fyLhYXkdmHT1e8c","type":"assistant","uuid":"014285fe-4b68-406e-9b31-3207f2ea18eb","timestamp":"2025-12-17T21:52:55.564Z"} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment