Skip to content

Instantly share code, notes, and snippets.

@simonw
Created December 17, 2025 21:48
Show Gist options
  • Select an option

  • Save simonw/46afe835d634e8145e54503438fce644 to your computer and use it in GitHub Desktop.

Select an option

Save simonw/46afe835d634e8145e54503438fce644 to your computer and use it in GitHub Desktop.
This file has been truncated, but you can view the full file.
{"type":"summary","summary":"Claude Code Timeline Event Detail Styling Improvements","leafUuid":"84cea148-6abc-45db-b496-b76521d56d66"}
{"type":"file-history-snapshot","messageId":"e762b279-f8cb-429f-bf6c-ccdd48bb8426","snapshot":{"messageId":"e762b279-f8cb-429f-bf6c-ccdd48bb8426","trackedFileBackups":{},"timestamp":"2025-12-17T20:58:42.037Z"},"isSnapshotUpdate":false}
{"parentUuid":null,"isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","type":"user","message":{"role":"user","content":"Inspect claude-code-timeline.html and see if there is any way that malicious HTML and script tags in a JSONL file might be rendered dangerously thanks to the new markdown rendering feature"},"uuid":"e762b279-f8cb-429f-bf6c-ccdd48bb8426","timestamp":"2025-12-17T20:58:42.031Z","thinkingMetadata":{"level":"high","disabled":false,"triggers":[]},"todos":[]}
{"parentUuid":"e762b279-f8cb-429f-bf6c-ccdd48bb8426","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","message":{"model":"claude-opus-4-5-20251101","id":"msg_017saMRbFhTTCP7qjpmbSnCa","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to inspect a file called `claude-code-timeline.html` to check for potential XSS vulnerabilities related to markdown rendering. This is a security review task. Let me start by reading the file to understand how it handles markdown and JSONL data.","signature":"ErADCkYIChgCKkAEvw/S5ZVRgxw/WTuEl6Q6663g1HUDIDtrIvlcY6BC/sLIT9IPQrCSYVs9JIt2W4P6WgR7zUxK5nmkF7mEjT2rEgzjUZEtzcZnm/5X43waDDxj71HJI9ke+ZJw1SIw6MoiEu66ofaTXmsidjJ9GzOJsV7e5jyUnxt8hQf8fpo4GLLKPW/mgB/QJubtHBvYKpcCTeoflW+YWWdz2JkLcCv8sQMME+XOftEQ3JaVAZK+P0TgSjR3lFX8Qm4Y57hcNf/rIHDJT1119ELPA2HAuBsEAH246kzVn1j30C2n/P72tRRy7ClC2P6XX2oxae4iBfjrYA4lz0izAOPVbyAqLAn5KLtg36wXwnki/4nZp7Y7NQgfaeqrC7A5g2WvkkqUj9PTpOzvFIplku1Wr0CeNd3FZHTPIo3zqjR1U5MP+zdzlEHT4uGotEznqMHagtoPW6Ivj5FLg0aR3B+CX/Dg2iK64bytonBrsbxve/QoxBLQLdG3bXShe20JzqemdkIWhHnvRBrf5qmV88F7DZc1zOrQGPR92+YrOcJPd1mAwp/XyTep2lOjZrreGAE="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":5699,"cache_read_input_tokens":12973,"cache_creation":{"ephemeral_5m_input_tokens":5699,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard"}},"requestId":"req_011CWCvuUvpoWUuFDT34EBrF","type":"assistant","uuid":"c815418d-196c-4307-8dc7-5ad290fa8e38","timestamp":"2025-12-17T20:58:45.935Z"}
{"parentUuid":"c815418d-196c-4307-8dc7-5ad290fa8e38","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","message":{"model":"claude-opus-4-5-20251101","id":"msg_017saMRbFhTTCP7qjpmbSnCa","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_019PJK6hwtGY6wAPfZ35sZot","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":5699,"cache_read_input_tokens":12973,"cache_creation":{"ephemeral_5m_input_tokens":5699,"ephemeral_1h_input_tokens":0},"output_tokens":138,"service_tier":"standard"}},"requestId":"req_011CWCvuUvpoWUuFDT34EBrF","type":"assistant","uuid":"e82cae27-1c46-4249-8340-9fe4c1044c93","timestamp":"2025-12-17T20:58:46.483Z"}
{"parentUuid":"e82cae27-1c46-4249-8340-9fe4c1044c93","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"logical-toasting-beacon","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_019PJK6hwtGY6wAPfZ35sZot","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→ <style>\n 8→ :root {\n 9→ color-scheme: light dark;\n 10→ --bg: #0b0c0f;\n 11→ --panel: #12141a;\n 12→ --panel-2: #0f1116;\n 13→ --text: #e8eaf0;\n 14→ --muted: #a6adbf;\n 15→ --line: rgba(255, 255, 255, 0.10);\n 16→ --line-2: rgba(255, 255, 255, 0.16);\n 17→ --accent: #6aa9ff;\n 18→ --accent-2: #8a5cff;\n 19→ --good: #46d39a;\n 20→ --warn: #ffcc66;\n 21→ --bad: #ff6b6b;\n 22→ --shadow: 0 18px 50px rgba(0, 0, 0, 0.35);\n 23→ --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\",\n 24→ \"Courier New\", monospace;\n 25→ --sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial,\n 26→ \"Apple Color Emoji\", \"Segoe UI Emoji\";\n 27→ }\n 28→\n 29→ @media (prefers-color-scheme: light) {\n 30→ :root {\n 31→ --bg: #f7f8fc;\n 32→ --panel: #ffffff;\n 33→ --panel-2: #ffffff;\n 34→ --text: #10121a;\n 35→ --muted: #5b647a;\n 36→ --line: rgba(16, 18, 26, 0.12);\n 37→ --line-2: rgba(16, 18, 26, 0.18);\n 38→ --shadow: 0 18px 50px rgba(16, 18, 26, 0.10);\n 39→ }\n 40→ }\n 41→\n 42→ * { box-sizing: border-box; }\n 43→ html, body { height: 100%; }\n 44→ body {\n 45→ margin: 0;\n 46→ font-family: var(--sans);\n 47→ background: radial-gradient(1200px 600px at 10% -10%, rgba(106, 169, 255, 0.18), transparent 45%),\n 48→ radial-gradient(1200px 600px at 90% -20%, rgba(138, 92, 255, 0.16), transparent 50%),\n 49→ var(--bg);\n 50→ color: var(--text);\n 51→ }\n 52→\n 53→ .wrap {\n 54→ max-width: none;\n 55→ margin: 0;\n 56→ padding: 22px 16px 42px;\n 57→ }\n 58→\n 59→ header {\n 60→ display: grid;\n 61→ gap: 10px;\n 62→ margin-bottom: 14px;\n 63→ }\n 64→\n 65→ h1 {\n 66→ margin: 0;\n 67→ font-size: clamp(22px, 3vw, 32px);\n 68→ letter-spacing: 0.2px;\n 69→ }\n 70→\n 71→ .sub {\n 72→ margin: 6px 0 0;\n 73→ color: var(--muted);\n 74→ line-height: 1.35;\n 75→ max-width: 70ch;\n 76→ }\n 77→\n 78→ .controls {\n 79→ display: flex;\n 80→ flex-wrap: wrap;\n 81→ gap: 10px;\n 82→ align-items: center;\n 83→ justify-content: flex-start;\n 84→ }\n 85→\n 86→ .controls .field {\n 87→ display: grid;\n 88→ gap: 4px;\n 89→ min-width: 180px;\n 90→ }\n 91→\n 92→ .label {\n 93→ font-size: 12px;\n 94→ color: var(--muted);\n 95→ }\n 96→\n 97→ input[type=\"search\"], input[type=\"url\"], select, textarea {\n 98→ font: inherit;\n 99→ color: inherit;\n 100→ border: 1px solid var(--line);\n 101→ background: color-mix(in srgb, var(--panel) 92%, transparent);\n 102→ border-radius: 10px;\n 103→ padding: 10px 11px;\n 104→ outline: none;\n 105→ }\n 106→\n 107→ input[type=\"search\"]:focus, input[type=\"url\"]:focus, select:focus, textarea:focus {\n 108→ border-color: color-mix(in srgb, var(--accent) 60%, var(--line));\n 109→ box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);\n 110→ }\n 111→\n 112→ .segmented {\n 113→ display: inline-flex;\n 114→ border: 1px solid var(--line);\n 115→ border-radius: 999px;\n 116→ overflow: hidden;\n 117→ background: color-mix(in srgb, var(--panel) 92%, transparent);\n 118→ }\n 119→\n 120→ .segmented button {\n 121→ border: none;\n 122→ padding: 9px 12px;\n 123→ font: inherit;\n 124→ background: transparent;\n 125→ color: var(--muted);\n 126→ cursor: pointer;\n 127→ min-width: 74px;\n 128→ }\n 129→\n 130→ .segmented button.active {\n 131→ background: color-mix(in srgb, var(--accent) 16%, transparent);\n 132→ color: var(--text);\n 133→ }\n 134→\n 135→ .segmented button:focus-visible {\n 136→ outline: 2px solid color-mix(in srgb, var(--accent) 70%, transparent);\n 137→ outline-offset: -2px;\n 138→ }\n 139→\n 140→ .btn {\n 141→ border: 1px solid var(--line);\n 142→ background: color-mix(in srgb, var(--panel) 92%, transparent);\n 143→ color: var(--text);\n 144→ border-radius: 10px;\n 145→ padding: 10px 12px;\n 146→ font: inherit;\n 147→ cursor: pointer;\n 148→ transition: transform 80ms ease, border-color 120ms ease;\n 149→ }\n 150→\n 151→ .btn:hover { border-color: var(--line-2); }\n 152→ .btn:active { transform: translateY(1px); }\n 153→ .btn.primary {\n 154→ border-color: color-mix(in srgb, var(--accent) 55%, var(--line));\n 155→ background: color-mix(in srgb, var(--accent) 14%, transparent);\n 156→ }\n 157→\n 158→ .btn:disabled {\n 159→ opacity: 0.55;\n 160→ cursor: not-allowed;\n 161→ }\n 162→\n 163→ .panel {\n 164→ background: var(--panel);\n 165→ border: 1px solid var(--line);\n 166→ border-radius: 16px;\n 167→ box-shadow: var(--shadow);\n 168→ }\n 169→\n 170→ .input-panel {\n 171→ padding: 14px;\n 172→ margin-bottom: 14px;\n 173→ }\n 174→\n 175→ .filters-panel {\n 176→ padding: 14px;\n 177→ margin-bottom: 14px;\n 178→ }\n 179→\n 180→ .input-grid {\n 181→ display: grid;\n 182→ grid-template-columns: 1fr;\n 183→ gap: 14px;\n 184→ align-items: stretch;\n 185→ }\n 186→\n 187→ @media (min-width: 920px) {\n 188→ .input-grid {\n 189→ grid-template-columns: 380px 1fr;\n 190→ }\n 191→ }\n 192→\n 193→ .dropzone {\n 194→ border: 1px dashed color-mix(in srgb, var(--accent) 40%, var(--line));\n 195→ border-radius: 14px;\n 196→ background: linear-gradient(\n 197→ 135deg,\n 198→ color-mix(in srgb, var(--accent) 12%, transparent),\n 199→ transparent 45%,\n 200→ color-mix(in srgb, var(--accent-2) 10%, transparent)\n 201→ );\n 202→ padding: 16px;\n 203→ display: grid;\n 204→ gap: 6px;\n 205→ cursor: pointer;\n 206→ min-height: 124px;\n 207→ align-content: center;\n 208→ }\n 209→\n 210→ .dropzone.dragover {\n 211→ border-style: solid;\n 212→ border-color: color-mix(in srgb, var(--accent) 70%, var(--line));\n 213→ box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);\n 214→ }\n 215→\n 216→ .dropzone strong { font-size: 15px; }\n 217→ .muted { color: var(--muted); }\n 218→ .tiny { font-size: 12px; color: var(--muted); }\n 219→\n 220→ .tabs {\n 221→ display: flex;\n 222→ gap: 8px;\n 223→ margin-bottom: 10px;\n 224→ flex-wrap: wrap;\n 225→ align-items: center;\n 226→ }\n 227→\n 228→ .tab {\n 229→ border: 1px solid var(--line);\n 230→ background: color-mix(in srgb, var(--panel-2) 94%, transparent);\n 231→ color: var(--muted);\n 232→ border-radius: 999px;\n 233→ padding: 7px 10px;\n 234→ cursor: pointer;\n 235→ font: inherit;\n 236→ }\n 237→\n 238→ .tab.active {\n 239→ color: var(--text);\n 240→ border-color: color-mix(in srgb, var(--accent) 55%, var(--line));\n 241→ background: color-mix(in srgb, var(--accent) 12%, transparent);\n 242→ }\n 243→\n 244→ textarea {\n 245→ width: 100%;\n 246→ min-height: 190px;\n 247→ resize: vertical;\n 248→ font-family: var(--mono);\n 249→ font-size: 12px;\n 250→ line-height: 1.4;\n 251→ }\n 252→\n 253→ .row {\n 254→ display: flex;\n 255→ gap: 10px;\n 256→ flex-wrap: wrap;\n 257→ align-items: center;\n 258→ margin-top: 10px;\n 259→ }\n 260→\n 261→ .status {\n 262→ margin-top: 12px;\n 263→ padding: 10px 12px;\n 264→ border-radius: 12px;\n 265→ border: 1px solid var(--line);\n 266→ background: color-mix(in srgb, var(--panel-2) 94%, transparent);\n 267→ display: none;\n 268→ white-space: pre-wrap;\n 269→ word-break: break-word;\n 270→ }\n 271→ .status.visible { display: block; }\n 272→ .status.error { border-color: color-mix(in srgb, var(--bad) 60%, var(--line)); }\n 273→ .status.good { border-color: color-mix(in srgb, var(--good) 55%, var(--line)); }\n 274→ .status.warn { border-color: color-mix(in srgb, var(--warn) 55%, var(--line)); }\n 275→\n 276→ main {\n 277→ display: grid;\n 278→ gap: 14px;\n 279→ grid-template-columns: 1fr;\n 280→ min-height: 560px;\n 281→ }\n 282→\n 283→ @media (min-width: 1060px) {\n 284→ main {\n 285→ grid-template-columns: 1fr 520px;\n 286→ align-items: stretch;\n 287→ }\n 288→ }\n 289→\n 290→ .timeline-panel {\n 291→ display: grid;\n 292→ grid-template-rows: auto 1fr;\n 293→ min-height: 560px;\n 294→ }\n 295→\n 296→ .stats {\n 297→ display: flex;\n 298→ gap: 12px;\n 299→ flex-wrap: wrap;\n 300→ align-items: baseline;\n 301→ justify-content: space-between;\n 302→ padding: 12px 14px;\n 303→ border-bottom: 1px solid var(--line);\n 304→ }\n 305→\n 306→ .stats .big {\n 307→ font-size: 14px;\n 308→ font-weight: 600;\n 309→ }\n 310→\n 311→ .timeline {\n 312→ overflow: auto;\n 313→ padding: 8px 0;\n 314→ }\n 315→\n 316→ .timeline:focus-visible {\n 317→ outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent);\n 318→ outline-offset: -2px;\n 319→ border-radius: 14px;\n 320→ }\n 321→\n 322→ .day-sep {\n 323→ position: sticky;\n 324→ top: 0;\n 325→ z-index: 2;\n 326→ padding: 8px 14px;\n 327→ background: color-mix(in srgb, var(--panel) 92%, transparent);\n 328→ border-top: 1px solid var(--line);\n 329→ border-bottom: 1px solid var(--line);\n 330→ font-size: 12px;\n 331→ color: var(--muted);\n 332→ backdrop-filter: blur(6px);\n 333→ }\n 334→\n 335→ .event {\n 336→ display: grid;\n 337→ grid-template-columns: 176px 1fr;\n 338→ gap: 12px;\n 339→ padding: 10px 14px;\n 340→ border-bottom: 1px solid color-mix(in srgb, var(--line) 85%, transparent);\n 341→ cursor: pointer;\n 342→ }\n 343→\n 344→ .event:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); }\n 345→ .event.selected {\n 346→ background: color-mix(in srgb, var(--accent) 12%, transparent);\n 347→ outline: 2px solid color-mix(in srgb, var(--accent) 35%, transparent);\n 348→ outline-offset: -2px;\n 349→ }\n 350→\n 351→ .time {\n 352→ font-family: var(--mono);\n 353→ font-size: 12px;\n 354→ line-height: 1.35;\n 355→ color: var(--muted);\n 356→ display: grid;\n 357→ gap: 3px;\n 358→ }\n 359→\n 360→ .time .ts {\n 361→ color: var(--text);\n 362→ font-weight: 600;\n 363→ }\n 364→\n 365→ .badges {\n 366→ display: flex;\n 367→ gap: 6px;\n 368→ flex-wrap: wrap;\n 369→ align-items: center;\n 370→ margin-bottom: 6px;\n 371→ }\n 372→\n 373→ .badge {\n 374→ font-size: 11px;\n 375→ font-family: var(--mono);\n 376→ padding: 2px 7px;\n 377→ border-radius: 999px;\n 378→ border: 1px solid var(--line);\n 379→ color: var(--muted);\n 380→ background: color-mix(in srgb, var(--panel-2) 90%, transparent);\n 381→ }\n 382→\n 383→ .badge.k-user { border-color: color-mix(in srgb, #00c8ff 55%, var(--line)); color: color-mix(in srgb, #00c8ff 78%, var(--text)); }\n 384→ .badge.k-assistant { border-color: color-mix(in srgb, #ff73c4 55%, var(--line)); color: color-mix(in srgb, #ff73c4 78%, var(--text)); }\n 385→ .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 386→\n 387→ .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 388→ .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 389→ .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 390→ .badge.ct-tool_result { border-color: color-mix(in srgb, #8690a8 55%, var(--line)); color: color-mix(in srgb, #8690a8 70%, var(--text)); }\n 391→ .badge.ct-image { border-color: color-mix(in srgb, #ff9f43 55%, var(--line)); color: color-mix(in srgb, #ff9f43 75%, var(--text)); }\n 392→\n 393→ .badge.role-user { border-color: color-mix(in srgb, #00c8ff 55%, var(--line)); color: color-mix(in srgb, #00c8ff 78%, var(--text)); }\n 394→ .badge.role-assistant { border-color: color-mix(in srgb, #ff73c4 55%, var(--line)); color: color-mix(in srgb, #ff73c4 78%, var(--text)); }\n 395→\n 396→ .summary {\n 397→ font-size: 13px;\n 398→ line-height: 1.35;\n 399→ color: var(--text);\n 400→ word-break: break-word;\n 401→ }\n 402→\n 403→ .summary code {\n 404→ font-family: var(--mono);\n 405→ font-size: 12px;\n 406→ background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n 407→ border: 1px solid var(--line);\n 408→ padding: 2px 5px;\n 409→ border-radius: 7px;\n 410→ }\n 411→\n 412→ .detail {\n 413→ display: grid;\n 414→ grid-template-rows: auto auto auto auto 1fr;\n 415→ min-height: 560px;\n 416→ }\n 417→\n 418→ @media (min-width: 1060px) {\n 419→ .detail {\n 420→ position: sticky;\n 421→ top: 16px;\n 422→ align-self: start;\n 423→ max-height: calc(100vh - 32px);\n 424→ }\n 425→ }\n 426→\n 427→ .detail.inline {\n 428→ position: static;\n 429→ top: auto;\n 430→ align-self: auto;\n 431→ max-height: none;\n 432→ min-height: 0;\n 433→ margin: 8px 14px 12px;\n 434→ box-shadow: none;\n 435→ }\n 436→\n 437→ .detail.inline pre {\n 438→ max-height: 52vh;\n 439→ }\n 440→\n 441→ .detail-head {\n 442→ padding: 12px 14px;\n 443→ border-bottom: 1px solid var(--line);\n 444→ display: grid;\n 445→ gap: 8px;\n 446→ }\n 447→\n 448→ .detail-title {\n 449→ font-size: 14px;\n 450→ font-weight: 700;\n 451→ line-height: 1.25;\n 452→ margin: 0;\n 453→ overflow: hidden;\n 454→ text-overflow: ellipsis;\n 455→ white-space: nowrap;\n 456→ }\n 457→\n 458→ .detail-actions {\n 459→ display: flex;\n 460→ gap: 8px;\n 461→ flex-wrap: wrap;\n 462→ align-items: center;\n 463→ justify-content: flex-start;\n 464→ }\n 465→\n 466→ .check {\n 467→ display: inline-flex;\n 468→ align-items: center;\n 469→ gap: 6px;\n 470→ color: var(--muted);\n 471→ font-size: 12px;\n 472→ user-select: none;\n 473→ }\n 474→\n 475→ pre {\n 476→ margin: 0;\n 477→ padding: 12px 14px 16px;\n 478→ overflow: auto;\n 479→ font-family: var(--mono);\n 480→ font-size: 12px;\n 481→ line-height: 1.45;\n 482→ color: color-mix(in srgb, var(--text) 92%, transparent);\n 483→ background: transparent;\n 484→ white-space: pre-wrap;\n 485→ word-break: break-word;\n 486→ }\n 487→\n 488→ .detail-images {\n 489→ display: flex;\n 490→ gap: 10px;\n 491→ padding: 12px 14px;\n 492→ border-bottom: 1px solid var(--line);\n 493→ overflow-x: auto;\n 494→ overscroll-behavior-x: contain;\n 495→ }\n 496→\n 497→ .thumb {\n 498→ border: 1px solid var(--line);\n 499→ background: color-mix(in srgb, var(--panel-2) 92%, transparent);\n 500→ border-radius: 12px;\n 501→ padding: 0;\n 502→ cursor: pointer;\n 503→ width: 78px;\n 504→ height: 58px;\n 505→ overflow: hidden;\n 506→ flex: 0 0 auto;\n 507→ display: grid;\n 508→ place-items: stretch;\n 509→ }\n 510→\n 511→ .thumb:hover { border-color: var(--line-2); }\n 512→\n 513→ .thumb img {\n 514→ width: 100%;\n 515→ height: 100%;\n 516→ display: block;\n 517→ object-fit: cover;\n 518→ }\n 519→\n 520→ .image-modal {\n 521→ position: fixed;\n 522→ inset: 0;\n 523→ z-index: 9999;\n 524→ background: rgba(0, 0, 0, 0.68);\n 525→ display: none;\n 526→ padding: 18px;\n 527→ place-items: center;\n 528→ }\n 529→\n 530→ .image-modal.open { display: grid; }\n 531→\n 532→ .image-modal .dialog {\n 533→ position: relative;\n 534→ border-radius: 16px;\n 535→ border: 1px solid var(--line);\n 536→ background: color-mix(in srgb, var(--panel) 98%, transparent);\n 537→ box-shadow: var(--shadow);\n 538→ padding: 14px;\n 539→ max-width: min(1200px, 96vw);\n 540→ max-height: 92vh;\n 541→ display: grid;\n 542→ place-items: center;\n 543→ }\n 544→\n 545→ .image-modal img {\n 546→ display: block;\n 547→ max-width: min(1140px, 92vw);\n 548→ max-height: 84vh;\n 549→ width: auto;\n 550→ height: auto;\n 551→ object-fit: contain;\n 552→ border-radius: 10px;\n 553→ background: color-mix(in srgb, var(--panel-2) 92%, transparent);\n 554→ }\n 555→\n 556→ .image-modal .close {\n 557→ position: absolute;\n 558→ top: 10px;\n 559→ right: 10px;\n 560→ border: 1px solid var(--line);\n 561→ background: color-mix(in srgb, var(--panel) 92%, transparent);\n 562→ color: var(--text);\n 563→ border-radius: 999px;\n 564→ padding: 8px 10px;\n 565→ font: inherit;\n 566→ cursor: pointer;\n 567→ }\n 568→\n 569→ .image-modal .close:hover { border-color: var(--line-2); }\n 570→\n 571→ .empty {\n 572→ padding: 14px;\n 573→ color: var(--muted);\n 574→ font-size: 13px;\n 575→ line-height: 1.35;\n 576→ }\n 577→\n 578→ /* Pretty view styles */\n 579→ .detail-pretty {\n 580→ padding: 14px;\n 581→ border-bottom: 1px solid var(--line);\n 582→ overflow: auto;\n 583→ max-height: 50vh;\n 584→ }\n 585→\n 586→ .detail.inline .detail-pretty {\n 587→ max-height: 40vh;\n 588→ }\n 589→\n 590→ .pretty-section {\n 591→ margin-bottom: 14px;\n 592→ }\n 593→\n 594→ .pretty-section:last-child {\n 595→ margin-bottom: 0;\n 596→ }\n 597→\n 598→ .pretty-label {\n 599→ font-size: 11px;\n 600→ font-weight: 600;\n 601→ text-transform: uppercase;\n 602→ letter-spacing: 0.5px;\n 603→ color: var(--muted);\n 604→ margin-bottom: 6px;\n 605→ }\n 606→\n 607→ .pretty-content {\n 608→ font-size: 13px;\n 609→ line-height: 1.5;\n 610→ }\n 611→\n 612→ .pretty-content p {\n 613→ margin: 0 0 10px;\n 614→ }\n 615→\n 616→ .pretty-content p:last-child {\n 617→ margin-bottom: 0;\n 618→ }\n 619→\n 620→ .pretty-content h1, .pretty-content h2, .pretty-content h3,\n 621→ .pretty-content h4, .pretty-content h5, .pretty-content h6 {\n 622→ margin: 14px 0 8px;\n 623→ font-weight: 600;\n 624→ line-height: 1.3;\n 625→ }\n 626→\n 627→ .pretty-content h1:first-child, .pretty-content h2:first-child,\n 628→ .pretty-content h3:first-child {\n 629→ margin-top: 0;\n 630→ }\n 631→\n 632→ .pretty-content h1 { font-size: 1.4em; }\n 633→ .pretty-content h2 { font-size: 1.25em; }\n 634→ .pretty-content h3 { font-size: 1.1em; }\n 635→\n 636→ .pretty-content ul, .pretty-content ol {\n 637→ margin: 8px 0;\n 638→ padding-left: 1.5em;\n 639→ list-style-position: outside;\n 640→ }\n 641→\n 642→ .pretty-content li {\n 643→ margin: 4px 0;\n 644→ }\n 645→\n 646→ .thinking-block ul, .thinking-block ol {\n 647→ padding-left: 2em;\n 648→ }\n 649→\n 650→ .pretty-content code {\n 651→ font-family: var(--mono);\n 652→ font-size: 0.9em;\n 653→ background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n 654→ border: 1px solid var(--line);\n 655→ padding: 2px 5px;\n 656→ border-radius: 5px;\n 657→ }\n 658→\n 659→ .pretty-content pre {\n 660→ margin: 10px 0;\n 661→ padding: 12px;\n 662→ background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n 663→ border: 1px solid var(--line);\n 664→ border-radius: 10px;\n 665→ overflow-x: auto;\n 666→ }\n 667→\n 668→ .pretty-content pre code {\n 669→ background: none;\n 670→ border: none;\n 671→ padding: 0;\n 672→ font-size: 12px;\n 673→ }\n 674→\n 675→ .pretty-content blockquote {\n 676→ margin: 10px 0;\n 677→ padding: 8px 12px;\n 678→ border-left: 3px solid var(--accent);\n 679→ background: color-mix(in srgb, var(--accent) 8%, transparent);\n 680→ border-radius: 0 8px 8px 0;\n 681→ }\n 682→\n 683→ .pretty-content a {\n 684→ color: var(--accent);\n 685→ text-decoration: none;\n 686→ }\n 687→\n 688→ .pretty-content a:hover {\n 689→ text-decoration: underline;\n 690→ }\n 691→\n 692→ .pretty-content strong {\n 693→ font-weight: 600;\n 694→ }\n 695→\n 696→ .pretty-content em {\n 697→ font-style: italic;\n 698→ }\n 699→\n 700→ .command-block {\n 701→ background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n 702→ border: 1px solid var(--line);\n 703→ border-radius: 10px;\n 704→ overflow: hidden;\n 705→ margin: 8px 0;\n 706→ }\n 707→\n 708→ .command-header {\n 709→ display: flex;\n 710→ align-items: center;\n 711→ gap: 8px;\n 712→ padding: 8px 12px;\n 713→ background: color-mix(in srgb, var(--accent) 12%, transparent);\n 714→ border-bottom: 1px solid var(--line);\n 715→ font-size: 12px;\n 716→ }\n 717→\n 718→ .command-header .tool-name {\n 719→ font-weight: 600;\n 720→ color: var(--accent);\n 721→ }\n 722→\n 723→ .command-header .tool-desc {\n 724→ color: var(--muted);\n 725→ }\n 726→\n 727→ .command-input {\n 728→ padding: 10px 12px;\n 729→ font-family: var(--mono);\n 730→ font-size: 12px;\n 731→ line-height: 1.4;\n 732→ white-space: pre-wrap;\n 733→ word-break: break-word;\n 734→ border-bottom: 1px solid var(--line);\n 735→ }\n 736→\n 737→ .command-input::before {\n 738→ content: '$ ';\n 739→ color: var(--good);\n 740→ font-weight: 600;\n 741→ }\n 742→\n 743→ .command-output {\n 744→ padding: 10px 12px;\n 745→ font-family: var(--mono);\n 746→ font-size: 12px;\n 747→ line-height: 1.4;\n 748→ white-space: pre-wrap;\n 749→ word-break: break-word;\n 750→ max-height: 300px;\n 751→ overflow: auto;\n 752→ color: var(--muted);\n 753→ }\n 754→\n 755→ .file-path {\n 756→ font-family: var(--mono);\n 757→ font-size: 12px;\n 758→ padding: 6px 10px;\n 759→ background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n 760→ border: 1px solid var(--line);\n 761→ border-radius: 8px;\n 762→ display: inline-block;\n 763→ margin: 4px 0;\n 764→ }\n 765→\n 766→ .tool-params {\n 767→ display: grid;\n 768→ gap: 8px;\n 769→ margin-top: 8px;\n 770→ }\n 771→\n 772→ .tool-param {\n 773→ display: grid;\n 774→ grid-template-columns: auto 1fr;\n 775→ gap: 8px;\n 776→ align-items: start;\n 777→ font-size: 12px;\n 778→ }\n 779→\n 780→ .tool-param-name {\n 781→ font-family: var(--mono);\n 782→ font-weight: 600;\n 783→ color: var(--accent);\n 784→ padding: 2px 6px;\n 785→ background: color-mix(in srgb, var(--accent) 12%, transparent);\n 786→ border-radius: 4px;\n 787→ }\n 788→\n 789→ .tool-param-value {\n 790→ font-family: var(--mono);\n 791→ white-space: pre-wrap;\n 792→ word-break: break-word;\n 793→ padding: 2px 0;\n 794→ }\n 795→\n 796→ .thinking-block {\n 797→ background: color-mix(in srgb, var(--accent-2) 10%, transparent);\n 798→ border: 1px solid color-mix(in srgb, var(--accent-2) 30%, var(--line));\n 799→ border-radius: 10px;\n 800→ padding: 12px 16px;\n 801→ font-size: 13px;\n 802→ line-height: 1.5;\n 803→ }\n 804→\n 805→ .result-block {\n 806→ background: color-mix(in srgb, var(--panel-2) 85%, transparent);\n 807→ border: 1px solid var(--line);\n 808→ border-radius: 10px;\n 809→ padding: 12px;\n 810→ font-family: var(--mono);\n 811→ font-size: 12px;\n 812→ line-height: 1.4;\n 813→ white-space: pre-wrap;\n 814→ word-break: break-word;\n 815→ max-height: 400px;\n 816→ overflow: auto;\n 817→ }\n 818→\n 819→ .result-error {\n 820→ border-color: color-mix(in srgb, var(--bad) 50%, var(--line));\n 821→ background: color-mix(in srgb, var(--bad) 8%, transparent);\n 822→ }\n 823→\n 824→ .json-toggle {\n 825→ display: flex;\n 826→ align-items: center;\n 827→ justify-content: space-between;\n 828→ padding: 8px 14px;\n 829→ border-bottom: 1px solid var(--line);\n 830→ background: color-mix(in srgb, var(--panel-2) 50%, transparent);\n 831→ }\n 832→\n 833→ .json-toggle-label {\n 834→ font-size: 12px;\n 835→ color: var(--muted);\n 836→ font-weight: 500;\n 837→ }\n 838→ </style>\n 839→</head>\n 840→ <body>\n 841→ <div class=\"wrap\">\n 842→ <header>\n 843→ <div>\n 844→ <h1>Claude Code Timeline Viewer</h1>\n 845→ <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 846→ </div>\n 847→ </header>\n 848→\n 849→ <section class=\"panel input-panel\" aria-label=\"Input\">\n 850→ <div class=\"input-grid\">\n 851→ <div>\n 852→ <div id=\"dropzone\" class=\"dropzone\" tabindex=\"0\">\n 853→ <strong>Drop a <code>.jsonl</code> file here</strong>\n 854→ <div class=\"muted\">or click to choose a file</div>\n 855→ <div class=\"tiny\">Supported: file picker, drag-and-drop, paste, or URL fetch (CORS-enabled).</div>\n 856→ <input id=\"file-input\" type=\"file\" accept=\".jsonl,application/json,text/plain\" hidden />\n 857→ </div>\n 858→ </div>\n 859→ <div>\n 860→ <div class=\"tabs\" role=\"tablist\" aria-label=\"Input tabs\">\n 861→ <button type=\"button\" class=\"tab active\" data-tab=\"paste\" aria-selected=\"true\">Paste</button>\n 862→ <button type=\"button\" class=\"tab\" data-tab=\"url\" aria-selected=\"false\">URL</button>\n 863→ </div>\n 864→\n 865→ <div id=\"panel-paste\" data-panel=\"paste\">\n 866→ <textarea id=\"paste-input\" placeholder=\"Paste JSONL content here...\"></textarea>\n 867→ <div class=\"row\">\n 868→ <button type=\"button\" class=\"btn primary\" id=\"render-paste\">Render pasted content</button>\n 869→ <div class=\"tiny\">Tip: the timeline uses one JSON object per line.</div>\n 870→ </div>\n 871→ </div>\n 872→\n 873→ <div id=\"panel-url\" data-panel=\"url\" hidden>\n 874→ <input id=\"url-input\" type=\"url\" placeholder=\"https://example.com/session.jsonl\" />\n 875→ <div class=\"row\">\n 876→ <button type=\"button\" class=\"btn primary\" id=\"fetch-url\">Fetch and render</button>\n 877→ <div class=\"tiny\">The URL must allow cross-origin requests (CORS).</div>\n 878→ </div>\n 879→ </div>\n 880→ </div>\n 881→ </div>\n 882→\n 883→ <div id=\"status\" class=\"status\" role=\"status\" aria-live=\"polite\"></div>\n 884→ </section>\n 885→\n 886→ <section class=\"panel filters-panel\" aria-label=\"Filters\">\n 887→ <div class=\"controls\" aria-label=\"Timeline controls\">\n 888→ <div class=\"field\" style=\"min-width: 170px\">\n 889→ <div class=\"label\">Timezone</div>\n 890→ <div class=\"segmented\" role=\"group\" aria-label=\"Timezone toggle\">\n 891→ <button type=\"button\" id=\"tz-local\" class=\"active\">Local</button>\n 892→ <button type=\"button\" id=\"tz-utc\">UTC</button>\n 893→ </div>\n 894→ </div>\n 895→ <div class=\"field\" style=\"min-width: 220px\">\n 896→ <div class=\"label\">Search</div>\n 897→ <input id=\"search\" type=\"search\" placeholder=\"message, tool name, cwd...\" autocomplete=\"off\" />\n 898→ </div>\n 899→ <div class=\"field\" style=\"min-width: 190px\">\n 900→ <div class=\"label\">Type</div>\n 901→ <select id=\"type-filter\"></select>\n 902→ </div>\n 903→ <div class=\"field\" style=\"min-width: 210px\">\n 904→ <div class=\"label\">Content Type</div>\n 905→ <select id=\"content-type-filter\"></select>\n 906→ </div>\n 907→ <div class=\"field\" style=\"min-width: 160px\">\n 908→ <div class=\"label\">Role</div>\n 909→ <select id=\"role-filter\"></select>\n 910→ </div>\n 911→ <label class=\"check\" title=\"Hide thinking blocks (can be verbose)\">\n 912→ <input type=\"checkbox\" id=\"hide-thinking\" />\n 913→ Hide thinking\n 914→ </label>\n 915→ <button type=\"button\" class=\"btn\" id=\"clear-filters\">Clear</button>\n 916→ </div>\n 917→ </section>\n 918→\n 919→ <main>\n 920→ <section class=\"panel timeline-panel\" aria-label=\"Timeline\">\n 921→ <div class=\"stats\">\n 922→ <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 923→ <div class=\"muted\" id=\"stats-range\">-</div>\n 924→ </div>\n 925→ <div id=\"timeline\" class=\"timeline\" role=\"list\" tabindex=\"0\" aria-label=\"Events list\"></div>\n 926→ </section>\n 927→\n 928→ <aside class=\"panel detail\" aria-label=\"Details\">\n 929→ <div class=\"detail-head\">\n 930→ <div class=\"detail-title\" id=\"detail-title\">Select an event</div>\n 931→ <div class=\"detail-actions\">\n 932→ <button type=\"button\" class=\"btn\" id=\"copy-json\" disabled>Copy JSON</button>\n 933→ <button type=\"button\" class=\"btn\" id=\"copy-raw\" disabled>Copy raw line</button>\n 934→ <label class=\"check\" title=\"Truncate very long strings in the JSON preview\">\n 935→ <input type=\"checkbox\" id=\"truncate-strings\" checked />\n 936→ Truncate long strings\n 937→ </label>\n 938→ </div>\n 939→ </div>\n 940→ <div id=\"detail-images\" class=\"detail-images\" hidden aria-label=\"Event images\"></div>\n 941→ <div id=\"detail-pretty\" class=\"detail-pretty\" hidden aria-label=\"Formatted event view\"></div>\n 942→ <div class=\"json-toggle\">\n 943→ <span class=\"json-toggle-label\">Raw JSON</span>\n 944→ </div>\n 945→ <pre id=\"detail-json\" aria-label=\"Selected event JSON\" class=\"empty\">Load a file to get started.</pre>\n 946→ </aside>\n 947→ </main>\n 948→ </div>\n 949→\n 950→ <div id=\"image-modal\" class=\"image-modal\" role=\"dialog\" aria-modal=\"true\" aria-hidden=\"true\">\n 951→ <div class=\"dialog\">\n 952→ <button type=\"button\" class=\"close\" id=\"image-modal-close\" aria-label=\"Close image\">Close</button>\n 953→ <img id=\"image-modal-img\" alt=\"\" />\n 954→ </div>\n 955→ </div>\n 956→\n 957→ <script>\n 958→ const els = {\n 959→ dropzone: document.getElementById('dropzone'),\n 960→ fileInput: document.getElementById('file-input'),\n 961→ pasteInput: document.getElementById('paste-input'),\n 962→ renderPaste: document.getElementById('render-paste'),\n 963→ urlInput: document.getElementById('url-input'),\n 964→ fetchUrl: document.getElementById('fetch-url'),\n 965→ status: document.getElementById('status'),\n 966→ timeline: document.getElementById('timeline'),\n 967→ detailPanel: document.querySelector('aside.detail'),\n 968→ detailTitle: document.getElementById('detail-title'),\n 969→ detailImages: document.getElementById('detail-images'),\n 970→ detailPretty: document.getElementById('detail-pretty'),\n 971→ detailJson: document.getElementById('detail-json'),\n 972→ copyJson: document.getElementById('copy-json'),\n 973→ copyRaw: document.getElementById('copy-raw'),\n 974→ truncateStrings: document.getElementById('truncate-strings'),\n 975→ tzLocal: document.getElementById('tz-local'),\n 976→ tzUtc: document.getElementById('tz-utc'),\n 977→ search: document.getElementById('search'),\n 978→ typeFilter: document.getElementById('type-filter'),\n 979→ contentTypeFilter: document.getElementById('content-type-filter'),\n 980→ roleFilter: document.getElementById('role-filter'),\n 981→ hideThinking: document.getElementById('hide-thinking'),\n 982→ statsShowing: document.getElementById('stats-showing'),\n 983→ statsTotal: document.getElementById('stats-total'),\n 984→ statsRange: document.getElementById('stats-range'),\n 985→ clearFilters: document.getElementById('clear-filters'),\n 986→ imageModal: document.getElementById('image-modal'),\n 987→ imageModalImg: document.getElementById('image-modal-img'),\n 988→ imageModalClose: document.getElementById('image-modal-close'),\n 989→ };\n 990→\n 991→ const state = {\n 992→ sourceLabel: null,\n 993→ tzMode: 'local',\n 994→ events: [],\n 995→ filtered: [],\n 996→ selectedId: null,\n 997→ selected: null,\n 998→ filters: {\n 999→ query: '',\n 1000→ type: 'all',\n 1001→ contentType: 'all',\n 1002→ role: 'all',\n 1003→ hideThinking: false,\n 1004→ },\n 1005→ };\n 1006→\n 1007→ let lastActiveElementBeforeModal = null;\n 1008→ let lastBodyOverflow = null;\n 1009→ let suppressUrlStateSync = false;\n 1010→ const wideLayoutMq = window.matchMedia('(min-width: 1060px)');\n 1011→ const detailDock = document.createElement('div');\n 1012→ detailDock.id = 'detail-dock';\n 1013→ detailDock.style.display = 'none';\n 1014→ els.detailPanel.parentElement.insertBefore(detailDock, els.detailPanel);\n 1015→\n 1016→ function updateDetailPlacement() {\n 1017→ const inline = !wideLayoutMq.matches;\n 1018→\n 1019→ if (!inline) {\n 1020→ if (els.detailPanel.previousSibling !== detailDock) detailDock.after(els.detailPanel);\n 1021→ els.detailPanel.classList.remove('inline');\n 1022→ return;\n 1023→ }\n 1024→\n 1025→ const selectedNode = els.timeline.querySelector('.event.selected');\n 1026→ if (!selectedNode) {\n 1027→ if (els.detailPanel.previousSibling !== detailDock) detailDock.after(els.detailPanel);\n 1028→ els.detailPanel.classList.remove('inline');\n 1029→ return;\n 1030→ }\n 1031→\n 1032→ if (selectedNode.nextSibling !== els.detailPanel) selectedNode.after(els.detailPanel);\n 1033→ els.detailPanel.classList.add('inline');\n 1034→ }\n 1035→\n 1036→ function setStatus(message, kind = 'good') {\n 1037→ if (!message) {\n 1038→ els.status.className = 'status';\n 1039→ els.status.textContent = '';\n 1040→ return;\n 1041→ }\n 1042→ els.status.className = `status visible ${kind}`;\n 1043→ els.status.textContent = message;\n 1044→ }\n 1045→\n 1046→ function pad2(number) {\n 1047→ return String(number).padStart(2, '0');\n 1048→ }\n 1049→\n 1050→ function pad3(number) {\n 1051→ return String(number).padStart(3, '0');\n 1052→ }\n 1053→\n 1054→ function getOffsetString(date) {\n 1055→ const offsetMinutes = -date.getTimezoneOffset();\n 1056→ const sign = offsetMinutes >= 0 ? '+' : '-';\n 1057→ const abs = Math.abs(offsetMinutes);\n 1058→ const hours = Math.floor(abs / 60);\n 1059→ const minutes = abs % 60;\n 1060→ return `${sign}${pad2(hours)}:${pad2(minutes)}`;\n 1061→ }\n 1062→\n 1063→ function formatTimestamp(date, tzMode) {\n 1064→ if (!(date instanceof Date) || Number.isNaN(date.getTime())) return '-';\n 1065→ if (tzMode === 'utc') {\n 1066→ return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ` +\n 1067→ `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}:${pad2(date.getUTCSeconds())}.` +\n 1068→ `${pad3(date.getUTCMilliseconds())}Z`;\n 1069→ }\n 1070→ return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ` +\n 1071→ `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}.` +\n 1072→ `${pad3(date.getMilliseconds())} ${getOffsetString(date)}`;\n 1073→ }\n 1074→\n 1075→ function dayKey(date, tzMode) {\n 1076→ if (!(date instanceof Date) || Number.isNaN(date.getTime())) return '-';\n 1077→ if (tzMode === 'utc') {\n 1078→ return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())}`;\n 1079→ }\n 1080→ return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;\n 1081→ }\n 1082→\n 1083→ function formatDelta(ms) {\n 1084→ if (typeof ms !== 'number' || !Number.isFinite(ms)) return '';\n 1085→ const abs = Math.abs(ms);\n 1086→ const sign = ms < 0 ? '-' : '+';\n 1087→ if (abs < 1000) return `${sign}${Math.round(abs)}ms`;\n 1088→ if (abs < 60_000) return `${sign}${(abs / 1000).toFixed(abs < 10_000 ? 2 : 1)}s`;\n 1089→ if (abs < 3_600_000) {\n 1090→ const m = Math.floor(abs / 60_000);\n 1091→ const s = Math.floor((abs % 60_000) / 1000);\n 1092→ return `${sign}${m}m ${pad2(s)}s`;\n 1093→ }\n 1094→ if (abs < 86_400_000) {\n 1095→ const h = Math.floor(abs / 3_600_000);\n 1096→ const m = Math.floor((abs % 3_600_000) / 60_000);\n 1097→ return `${sign}${h}h ${m}m`;\n 1098→ }\n 1099→ const d = Math.floor(abs / 86_400_000);\n 1100→ const h = Math.floor((abs % 86_400_000) / 3_600_000);\n 1101→ return `${sign}${d}d ${h}h`;\n 1102→ }\n 1103→\n 1104→ function safeJsonParse(text) {\n 1105→ try {\n 1106→ return { ok: true, value: JSON.parse(text) };\n 1107→ } catch (error) {\n 1108→ return { ok: false, error };\n 1109→ }\n 1110→ }\n 1111→\n 1112→ function extractMessageText(message) {\n 1113→ if (!message || typeof message !== 'object') return null;\n 1114→ const content = message.content;\n 1115→ if (typeof content === 'string') return content;\n 1116→ if (!Array.isArray(content)) return null;\n 1117→ const parts = [];\n 1118→ for (const item of content) {\n 1119→ if (!item || typeof item !== 'object') continue;\n 1120→ if (typeof item.text === 'string') parts.push(item.text);\n 1121→ else if (typeof item.thinking === 'string') parts.push(item.thinking);\n 1122→ }\n 1123→ const joined = parts.join('');\n 1124→ return joined || null;\n 1125→ }\n 1126→\n 1127→ function getContentTypes(message) {\n 1128→ if (!message || typeof message !== 'object') return [];\n 1129→ const content = message.content;\n 1130→ if (typeof content === 'string') return ['text'];\n 1131→ if (!Array.isArray(content)) return [];\n 1132→ const types = new Set();\n 1133→ for (const item of content) {\n 1134→ if (!item || typeof item !== 'object') continue;\n 1135→ if (item.type) types.add(item.type);\n 1136→ }\n 1137→ return Array.from(types);\n 1138→ }\n 1139→\n 1140→ function firstLine(text) {\n 1141→ if (typeof text !== 'string') return null;\n 1142→ const trimmed = text.trim();\n 1143→ if (!trimmed) return null;\n 1144→ const idx = trimmed.indexOf('\\n');\n 1145→ return idx === -1 ? trimmed : trimmed.slice(0, idx).trim();\n 1146→ }\n 1147→\n 1148→ function normalizeSummaryText(text, maxLen = 260) {\n 1149→ if (typeof text !== 'string') return '';\n 1150→ const singleLine = text.replace(/\\s+/g, ' ').trim();\n 1151→ if (singleLine.length <= maxLen) return singleLine;\n 1152→ return singleLine.slice(0, maxLen - 1) + '...';\n 1153→ }\n 1154→\n 1155→ function summarizeEvent(obj) {\n 1156→ const kind = obj?.type || '';\n 1157→ const message = obj?.message;\n 1158→\n 1159→ if (kind === 'file-history-snapshot') {\n 1160→ const msgId = obj?.messageId ? `msg=${obj.messageId.slice(0, 8)}...` : null;\n 1161→ return normalizeSummaryText(msgId || 'file-history-snapshot');\n 1162→ }\n 1163→\n 1164→ if (kind === 'user' || kind === 'assistant') {\n 1165→ const role = message?.role || kind;\n 1166→ const content = message?.content;\n 1167→\n 1168→ if (typeof content === 'string') {\n 1169→ return normalizeSummaryText(`${role}: ${content}`);\n 1170→ }\n 1171→\n 1172→ if (Array.isArray(content) && content.length > 0) {\n 1173→ for (const item of content) {\n 1174→ if (item?.type === 'text' && item.text) {\n 1175→ return normalizeSummaryText(`${role}: ${item.text}`);\n 1176→ }\n 1177→ if (item?.type === 'thinking' && item.thinking) {\n 1178→ return normalizeSummaryText(`${role} (thinking): ${item.thinking}`);\n 1179→ }\n 1180→ if (item?.type === 'tool_use') {\n 1181→ const name = item.name || 'tool';\n 1182→ const input = item.input;\n 1183→ if (name === 'Bash' && input?.command) {\n 1184→ return normalizeSummaryText(`${name}: ${input.command}`);\n 1185→ }\n 1186→ if (name === 'Read' && input?.file_path) {\n 1187→ return normalizeSummaryText(`${name}: ${input.file_path}`);\n 1188→ }\n 1189→ if (name === 'Write' && input?.file_path) {\n 1190→ return normalizeSummaryText(`${name}: ${input.file_path}`);\n 1191→ }\n 1192→ if (name === 'Edit' && input?.file_path) {\n 1193→ return normalizeSummaryText(`${name}: ${input.file_path}`);\n 1194→ }\n 1195→ if (name === 'Glob' && input?.pattern) {\n 1196→ return normalizeSummaryText(`${name}: ${input.pattern}`);\n 1197→ }\n 1198→ if (name === 'Grep' && input?.pattern) {\n 1199→ return normalizeSummaryText(`${name}: ${input.pattern}`);\n 1200→ }\n 1201→ if (name === 'Task' && input?.description) {\n 1202→ return normalizeSummaryText(`${name}: ${input.description}`);\n 1203→ }\n 1204→ if (input && typeof input === 'object') {\n 1205→ const keys = Object.keys(input).slice(0, 3);\n 1206→ return normalizeSummaryText(`${name}(${keys.join(', ')}${Object.keys(input).length > 3 ? ', ...' : ''})`);\n 1207→ }\n 1208→ return normalizeSummaryText(`${name}()`);\n 1209→ }\n 1210→ if (item?.type === 'tool_result') {\n 1211→ const toolUseId = item.tool_use_id ? item.tool_use_id.slice(0, 12) : 'result';\n 1212→ const resultContent = item.content;\n 1213→ if (typeof resultContent === 'string') {\n 1214→ return normalizeSummaryText(`tool_result: ${firstLine(resultContent) || toolUseId}`);\n 1215→ }\n 1216→ if (Array.isArray(resultContent)) {\n 1217→ for (const rc of resultContent) {\n 1218→ if (rc?.type === 'text' && rc.text) {\n 1219→ return normalizeSummaryText(`tool_result: ${firstLine(rc.text) || toolUseId}`);\n 1220→ }\n 1221→ if (rc?.type === 'image') {\n 1222→ return normalizeSummaryText(`tool_result: [image]`);\n 1223→ }\n 1224→ }\n 1225→ }\n 1226→ return normalizeSummaryText(`tool_result: ${toolUseId}`);\n 1227→ }\n 1228→ if (item?.type === 'image') {\n 1229→ return normalizeSummaryText(`${role}: [image]`);\n 1230→ }\n 1231→ }\n 1232→ return normalizeSummaryText(`${role}: [${content.length} content items]`);\n 1233→ }\n 1234→\n 1235→ return kind;\n 1236→ }\n 1237→\n 1238→ return kind || 'event';\n 1239→ }\n 1240→\n 1241→ function buildSearchText(obj, summary) {\n 1242→ const pieces = [];\n 1243→ pieces.push(obj?.type || '');\n 1244→ pieces.push(obj?.uuid || '');\n 1245→ pieces.push(obj?.sessionId || '');\n 1246→ pieces.push(obj?.cwd || '');\n 1247→ pieces.push(obj?.gitBranch || '');\n 1248→ pieces.push(obj?.slug || '');\n 1249→ const message = obj?.message;\n 1250→ if (message && typeof message === 'object') {\n 1251→ pieces.push(message.role || '');\n 1252→ const msg = extractMessageText(message);\n 1253→ if (msg) pieces.push(msg);\n 1254→ const content = message.content;\n 1255→ if (Array.isArray(content)) {\n 1256→ for (const item of content) {\n 1257→ if (item?.type === 'tool_use') {\n 1258→ pieces.push(item.name || '');\n 1259→ if (item.input && typeof item.input === 'object') {\n 1260→ pieces.push(JSON.stringify(item.input).slice(0, 500));\n 1261→ }\n 1262→ }\n 1263→ if (item?.type === 'tool_result' && typeof item.content === 'string') {\n 1264→ pieces.push(item.content.slice(0, 500));\n 1265→ }\n 1266→ }\n 1267→ }\n 1268→ }\n 1269→ if (typeof summary === 'string') pieces.push(summary);\n 1270→ return pieces.join('\\n').toLowerCase();\n 1271→ }\n 1272→\n 1273→ function stringifyJson(value, truncateLongStrings) {\n 1274→ const maxLen = 8000;\n 1275→ return JSON.stringify(value, (key, v) => {\n 1276→ if (!truncateLongStrings) return v;\n 1277→ if (typeof v === 'string' && v.length > maxLen) {\n 1278→ return v.slice(0, maxLen) + `... [truncated ${v.length - maxLen} chars]`;\n 1279→ }\n 1280→ return v;\n 1281→ }, 2);\n 1282→ }\n 1283→\n 1284→ function updateFilterOptions(events) {\n 1285→ const typeSet = new Set();\n 1286→ const contentTypeSet = new Set();\n 1287→ const roleSet = new Set();\n 1288→ for (const e of events) {\n 1289→ if (e.kind) typeSet.add(e.kind);\n 1290→ for (const ct of e.contentTypes) {\n 1291→ contentTypeSet.add(ct);\n 1292→ }\n 1293→ if (!e.contentTypes.length) contentTypeSet.add('(none)');\n 1294→ if (e.role) roleSet.add(e.role);\n 1295→ }\n 1296→\n 1297→ function setOptions(select, items, current) {\n 1298→ select.innerHTML = '';\n 1299→ const optAll = document.createElement('option');\n 1300→ optAll.value = 'all';\n 1301→ optAll.textContent = 'All';\n 1302→ select.appendChild(optAll);\n 1303→ for (const item of items) {\n 1304→ const opt = document.createElement('option');\n 1305→ opt.value = item;\n 1306→ opt.textContent = item;\n 1307→ select.appendChild(opt);\n 1308→ }\n 1309→ select.value = current;\n 1310→ }\n 1311→\n 1312→ setOptions(els.typeFilter, Array.from(typeSet).sort(), state.filters.type);\n 1313→ setOptions(els.contentTypeFilter, Array.from(contentTypeSet).sort(), state.filters.contentType);\n 1314→ setOptions(els.roleFilter, Array.from(roleSet).sort(), state.filters.role);\n 1315→\n 1316→ if (events.length) {\n 1317→ state.filters.type = els.typeFilter.value || 'all';\n 1318→ state.filters.contentType = els.contentTypeFilter.value || 'all';\n 1319→ state.filters.role = els.roleFilter.value || 'all';\n 1320→ }\n 1321→ }\n 1322→\n 1323→ function readHashParams() {\n 1324→ const raw = window.location.hash && window.location.hash.startsWith('#')\n 1325→ ? window.location.hash.slice(1)\n 1326→ : '';\n 1327→ return new URLSearchParams(raw);\n 1328→ }\n 1329→\n 1330→ function writeHashParams(params) {\n 1331→ const base = window.location.href.split('#')[0];\n 1332→ const hash = params.toString();\n 1333→ const next = base + (hash ? `#${hash}` : '');\n 1334→ window.history.replaceState(null, '', next);\n 1335→ }\n 1336→\n 1337→ function syncUrlHashFromControls({ clearSel = false } = {}) {\n 1338→ if (suppressUrlStateSync) return;\n 1339→ const params = readHashParams();\n 1340→ params.set('tz', state.tzMode);\n 1341→ params.set('q', (els.search.value || '').trim());\n 1342→ params.set('type', state.filters.type || 'all');\n 1343→ params.set('ct', state.filters.contentType || 'all');\n 1344→ params.set('role', state.filters.role || 'all');\n 1345→ params.set('hide', state.filters.hideThinking ? '1' : '0');\n 1346→ params.set('truncate', els.truncateStrings.checked ? '1' : '0');\n 1347→ const selectedLine = state.selected?.line;\n 1348→ if (typeof selectedLine === 'number' && Number.isFinite(selectedLine)) {\n 1349→ params.set('sel', String(selectedLine));\n 1350→ } else if (clearSel) {\n 1351→ params.delete('sel');\n 1352→ }\n 1353→ writeHashParams(params);\n 1354→ }\n 1355→\n 1356→ function applySelectionFromHash() {\n 1357→ if (!state.events.length) return;\n 1358→ const params = readHashParams();\n 1359→ const raw = params.get('sel');\n 1360→ if (!raw) return;\n 1361→ const line = Number(raw);\n 1362→ if (!Number.isFinite(line)) return;\n 1363→ const found = state.events.find(e => e.line === line);\n 1364→ if (!found) return;\n 1365→ if (state.selectedId === found.id) return;\n 1366→ suppressUrlStateSync = true;\n 1367→ try {\n 1368→ selectEvent(found.id, { scroll: true });\n 1369→ } finally {\n 1370→ suppressUrlStateSync = false;\n 1371→ }\n 1372→ }\n 1373→\n 1374→ function applyControlsFromHash() {\n 1375→ const params = readHashParams();\n 1376→ suppressUrlStateSync = true;\n 1377→ try {\n 1378→ const q = params.get('q');\n 1379→ if (q !== null) {\n 1380→ els.search.value = q;\n 1381→ state.filters.query = q.trim().toLowerCase();\n 1382→ }\n 1383→\n 1384→ const type = params.get('type');\n 1385→ if (type !== null) state.filters.type = type;\n 1386→ const ct = params.get('ct');\n 1387→ if (ct !== null) state.filters.contentType = ct;\n 1388→ const role = params.get('role');\n 1389→ if (role !== null) state.filters.role = role;\n 1390→\n 1391→ const hide = params.get('hide');\n 1392→ if (hide !== null) {\n 1393→ els.hideThinking.checked = hide === '1' || hide === 'true';\n 1394→ state.filters.hideThinking = els.hideThinking.checked;\n 1395→ }\n 1396→\n 1397→ const truncate = params.get('truncate');\n 1398→ if (truncate !== null) {\n 1399→ els.truncateStrings.checked = truncate === '1' || truncate === 'true';\n 1400→ }\n 1401→\n 1402→ const tz = params.get('tz');\n 1403→ if (tz === 'utc' || tz === 'local') {\n 1404→ state.tzMode = tz;\n 1405→ els.tzLocal.classList.toggle('active', tz === 'local');\n 1406→ els.tzUtc.classList.toggle('active', tz === 'utc');\n 1407→ }\n 1408→ } finally {\n 1409→ suppressUrlStateSync = false;\n 1410→ }\n 1411→ }\n 1412→\n 1413→ function applyFilters() {\n 1414→ const query = state.filters.query;\n 1415→ const type = state.filters.type;\n 1416→ const contentType = state.filters.contentType;\n 1417→ const role = state.filters.role;\n 1418→ const hideThinking = state.filters.hideThinking;\n 1419→\n 1420→ const out = [];\n 1421→ for (const e of state.events) {\n 1422→ if (hideThinking && e.contentTypes.includes('thinking') && e.contentTypes.length === 1) continue;\n 1423→ if (type !== 'all' && e.kind !== type) continue;\n 1424→ if (contentType !== 'all') {\n 1425→ const ct = e.contentTypes.length ? e.contentTypes : ['(none)'];\n 1426→ if (!ct.includes(contentType)) continue;\n 1427→ }\n 1428→ if (role !== 'all' && e.role !== role) continue;\n 1429→ if (query && !e.search.includes(query)) continue;\n 1430→ out.push(e);\n 1431→ }\n 1432→\n 1433→ state.filtered = out;\n 1434→ els.statsShowing.textContent = String(out.length);\n 1435→ els.statsTotal.textContent = String(state.events.length);\n 1436→\n 1437→ const start = out.find(e => e.date instanceof Date && !Number.isNaN(e.date.getTime()))?.date;\n 1438→ const end = [...out].reverse().find(e => e.date instanceof Date && !Number.isNaN(e.date.getTime()))?.date;\n 1439→ if (start && end) {\n 1440→ els.statsRange.textContent = `${formatTimestamp(start, state.tzMode)} -> ${formatTimestamp(end, state.tzMode)}`;\n 1441→ } else {\n 1442→ els.statsRange.textContent = '-';\n 1443→ }\n 1444→\n 1445→ renderTimeline();\n 1446→ }\n 1447→\n 1448→ function clearSelection({ syncHash = true } = {}) {\n 1449→ state.selectedId = null;\n 1450→ state.selected = null;\n 1451→ els.detailTitle.textContent = 'Select an event';\n 1452→ renderDetailImages(null);\n 1453→ renderPrettyView(null);\n 1454→ els.detailJson.textContent = state.events.length ? 'Select an event from the timeline.' : 'Load a file to get started.';\n 1455→ els.detailJson.classList.add('empty');\n 1456→ els.copyJson.disabled = true;\n 1457→ els.copyRaw.disabled = true;\n 1458→ for (const node of els.timeline.querySelectorAll('.event.selected')) {\n 1459→ node.classList.remove('selected');\n 1460→ }\n 1461→ updateDetailPlacement();\n 1462→ if (syncHash) syncUrlHashFromControls({ clearSel: true });\n 1463→ }\n 1464→\n 1465→ function renderTimeline() {\n 1466→ els.timeline.innerHTML = '';\n 1467→\n 1468→ if (!state.filtered.length) {\n 1469→ const empty = document.createElement('div');\n 1470→ empty.className = 'empty';\n 1471→ empty.textContent = state.events.length ? 'No events match your filters.' : 'Load a file to see a timeline.';\n 1472→ els.timeline.appendChild(empty);\n 1473→ clearSelection();\n 1474→ return;\n 1475→ }\n 1476→\n 1477→ const fragment = document.createDocumentFragment();\n 1478→ let lastDay = null;\n 1479→ let prevDate = null;\n 1480→\n 1481→ for (const e of state.filtered) {\n 1482→ const day = dayKey(e.date, state.tzMode);\n 1483→ if (day !== lastDay) {\n 1484→ lastDay = day;\n 1485→ const sep = document.createElement('div');\n 1486→ sep.className = 'day-sep';\n 1487→ sep.textContent = day;\n 1488→ fragment.appendChild(sep);\n 1489→ }\n 1490→\n 1491→ const row = document.createElement('div');\n 1492→ row.className = 'event';\n 1493→ row.setAttribute('role', 'listitem');\n 1494→ row.dataset.eventId = String(e.id);\n 1495→\n 1496→ const time = document.createElement('div');\n 1497→ time.className = 'time';\n 1498→\n 1499→ const ts = document.createElement('div');\n 1500→ ts.className = 'ts';\n 1501→ ts.textContent = formatTimestamp(e.date, state.tzMode);\n 1502→ ts.dataset.kind = 'timestamp';\n 1503→\n 1504→ const meta = document.createElement('div');\n 1505→ meta.textContent = [\n 1506→ `#${e.line}`,\n 1507→ (prevDate && e.date) ? formatDelta(e.date.getTime() - prevDate.getTime()) : null,\n 1508→ ].filter(Boolean).join(' . ');\n 1509→\n 1510→ time.appendChild(ts);\n 1511→ time.appendChild(meta);\n 1512→\n 1513→ const body = document.createElement('div');\n 1514→\n 1515→ const badges = document.createElement('div');\n 1516→ badges.className = 'badges';\n 1517→ const badgeKind = document.createElement('span');\n 1518→ badgeKind.className = `badge k-${e.kind}`;\n 1519→ badgeKind.textContent = e.kind;\n 1520→ badges.appendChild(badgeKind);\n 1521→\n 1522→ for (const ct of e.contentTypes.slice(0, 2)) {\n 1523→ const badgeCt = document.createElement('span');\n 1524→ badgeCt.className = `badge ct-${ct}`;\n 1525→ badgeCt.textContent = ct;\n 1526→ badges.appendChild(badgeCt);\n 1527→ }\n 1528→ if (e.contentTypes.length > 2) {\n 1529→ const badgeMore = document.createElement('span');\n 1530→ badgeMore.className = 'badge';\n 1531→ badgeMore.textContent = `+${e.contentTypes.length - 2}`;\n 1532→ badges.appendChild(badgeMore);\n 1533→ }\n 1534→\n 1535→ if (e.role) {\n 1536→ const badgeRole = document.createElement('span');\n 1537→ badgeRole.className = `badge role-${e.role}`;\n 1538→ badgeRole.textContent = e.role;\n 1539→ badges.appendChild(badgeRole);\n 1540→ }\n 1541→\n 1542→ const summary = document.createElement('div');\n 1543→ summary.className = 'summary';\n 1544→ summary.textContent = e.summary;\n 1545→\n 1546→ body.appendChild(badges);\n 1547→ body.appendChild(summary);\n 1548→\n 1549→ row.appendChild(time);\n 1550→ row.appendChild(body);\n 1551→\n 1552→ row.addEventListener('click', () => selectEvent(e.id, { focusTimeline: true }));\n 1553→ fragment.appendChild(row);\n 1554→\n 1555→ if (e.date instanceof Date && !Number.isNaN(e.date.getTime())) prevDate = e.date;\n 1556→ }\n 1557→\n 1558→ els.timeline.appendChild(fragment);\n 1559→\n 1560→ if (state.selectedId !== null) {\n 1561→ const stillThere = state.filtered.some(e => e.id === state.selectedId);\n 1562→ if (!stillThere) clearSelection();\n 1563→ else selectEvent(state.selectedId, { scroll: false });\n 1564→ } else {\n 1565→ clearSelection({ syncHash: false });\n 1566→ }\n 1567→ }\n 1568→\n 1569→ function selectEvent(id, { scroll = false, focusTimeline = false } = {}) {\n 1570→ const e = state.filtered.find(item => item.id === id) || state.events.find(item => item.id === id);\n 1571→ if (!e) return;\n 1572→ state.selectedId = id;\n 1573→ state.selected = e;\n 1574→\n 1575→ if (focusTimeline) {\n 1576→ els.timeline.focus({ preventScroll: true });\n 1577→ }\n 1578→\n 1579→ for (const node of els.timeline.querySelectorAll('.event.selected')) {\n 1580→ node.classList.remove('selected');\n 1581→ }\n 1582→ const node = els.timeline.querySelector(`.event[data-event-id=\"${CSS.escape(String(id))}\"]`);\n 1583→ if (node) {\n 1584→ node.classList.add('selected');\n 1585→ updateDetailPlacement();\n 1586→ if (scroll) node.scrollIntoView({ block: 'center' });\n 1587→ }\n 1588→\n 1589→ els.detailJson.classList.remove('empty');\n 1590→ const titleParts = [\n 1591→ formatTimestamp(e.date, state.tzMode),\n 1592→ `#${e.line}`,\n 1593→ e.kind,\n 1594→ e.role ? `(${e.role})` : null,\n 1595→ ].filter(Boolean);\n 1596→ els.detailTitle.textContent = titleParts.join(' ');\n 1597→\n 1598→ const shouldTruncate = els.truncateStrings.checked;\n 1599→ renderDetailImages(e.obj);\n 1600→ renderPrettyView(e.obj);\n 1601→ els.detailJson.textContent = stringifyJson(e.obj, shouldTruncate) || '';\n 1602→ els.copyJson.disabled = false;\n 1603→ els.copyRaw.disabled = false;\n 1604→ updateDetailPlacement();\n 1605→ syncUrlHashFromControls();\n 1606→ }\n 1607→\n 1608→ function extractImageUrls(obj) {\n 1609→ const urls = [];\n 1610→ const message = obj?.message;\n 1611→ if (!message || typeof message !== 'object') return urls;\n 1612→ const content = message.content;\n 1613→ if (!Array.isArray(content)) return urls;\n 1614→\n 1615→ for (const item of content) {\n 1616→ if (!item || typeof item !== 'object') continue;\n 1617→\n 1618→ // Direct image in content\n 1619→ if (item.type === 'image' && item.source?.type === 'base64' && item.source?.data) {\n 1620→ const mediaType = item.source.media_type || 'image/png';\n 1621→ urls.push(`data:${mediaType};base64,${item.source.data}`);\n 1622→ }\n 1623→\n 1624→ // Image in tool_result content\n 1625→ if (item.type === 'tool_result' && Array.isArray(item.content)) {\n 1626→ for (const rc of item.content) {\n 1627→ if (rc?.type === 'image' && rc.source?.type === 'base64' && rc.source?.data) {\n 1628→ const mediaType = rc.source.media_type || 'image/png';\n 1629→ urls.push(`data:${mediaType};base64,${rc.source.data}`);\n 1630→ }\n 1631→ }\n 1632→ }\n 1633→ }\n 1634→\n 1635→ return urls;\n 1636→ }\n 1637→\n 1638→ function escapeHtml(text) {\n 1639→ const div = document.createElement('div');\n 1640→ div.textContent = text;\n 1641→ return div.innerHTML;\n 1642→ }\n 1643→\n 1644→ function renderMarkdown(text) {\n 1645→ if (typeof text !== 'string') return '';\n 1646→\n 1647→ // Escape HTML first\n 1648→ let html = escapeHtml(text);\n 1649→\n 1650→ // Code blocks (```...```)\n 1651→ html = html.replace(/```(\\w*)\\n([\\s\\S]*?)```/g, (match, lang, code) => {\n 1652→ return `<pre><code class=\"language-${lang}\">${code.trim()}</code></pre>`;\n 1653→ });\n 1654→\n 1655→ // Inline code (`...`)\n 1656→ html = html.replace(/`([^`]+)`/g, '<code>$1</code>');\n 1657→\n 1658→ // Headers\n 1659→ html = html.replace(/^######\\s+(.+)$/gm, '<h6>$1</h6>');\n 1660→ html = html.replace(/^#####\\s+(.+)$/gm, '<h5>$1</h5>');\n 1661→ html = html.replace(/^####\\s+(.+)$/gm, '<h4>$1</h4>');\n 1662→ html = html.replace(/^###\\s+(.+)$/gm, '<h3>$1</h3>');\n 1663→ html = html.replace(/^##\\s+(.+)$/gm, '<h2>$1</h2>');\n 1664→ html = html.replace(/^#\\s+(.+)$/gm, '<h1>$1</h1>');\n 1665→\n 1666→ // Bold and italic\n 1667→ html = html.replace(/\\*\\*\\*([^*]+)\\*\\*\\*/g, '<strong><em>$1</em></strong>');\n 1668→ html = html.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');\n 1669→ html = html.replace(/\\*([^*]+)\\*/g, '<em>$1</em>');\n 1670→ html = html.replace(/___([^_]+)___/g, '<strong><em>$1</em></strong>');\n 1671→ html = html.replace(/__([^_]+)__/g, '<strong>$1</strong>');\n 1672→ html = html.replace(/_([^_]+)_/g, '<em>$1</em>');\n 1673→\n 1674→ // Links\n 1675→ html = html.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '<a href=\"$2\" target=\"_blank\" rel=\"noopener\">$1</a>');\n 1676→\n 1677→ // Blockquotes\n 1678→ html = html.replace(/^&gt;\\s+(.+)$/gm, '<blockquote>$1</blockquote>');\n 1679→\n 1680→ // Unordered lists\n 1681→ html = html.replace(/^[\\*\\-]\\s+(.+)$/gm, '<li>$1</li>');\n 1682→ html = html.replace(/(<li>.*<\\/li>\\n?)+/g, '<ul>$&</ul>');\n 1683→\n 1684→ // Ordered lists\n 1685→ html = html.replace(/^\\d+\\.\\s+(.+)$/gm, '<li>$1</li>');\n 1686→\n 1687→ // Line breaks - convert double newlines to paragraphs\n 1688→ html = html.split(/\\n\\n+/).map(para => {\n 1689→ para = para.trim();\n 1690→ if (!para) return '';\n 1691→ // Don't wrap if already wrapped in block element\n 1692→ if (/^<(h[1-6]|ul|ol|li|pre|blockquote|div)/.test(para)) return para;\n 1693→ return `<p>${para.replace(/\\n/g, '<br>')}</p>`;\n 1694→ }).join('\\n');\n 1695→\n 1696→ return html;\n 1697→ }\n 1698→\n 1699→ function renderPrettyView(obj) {\n 1700→ els.detailPretty.innerHTML = '';\n 1701→\n 1702→ if (!obj || typeof obj !== 'object') {\n 1703→ els.detailPretty.hidden = true;\n 1704→ return;\n 1705→ }\n 1706→\n 1707→ const message = obj.message;\n 1708→ if (!message || typeof message !== 'object') {\n 1709→ els.detailPretty.hidden = true;\n 1710→ return;\n 1711→ }\n 1712→\n 1713→ const content = message.content;\n 1714→ const fragment = document.createDocumentFragment();\n 1715→ let hasContent = false;\n 1716→\n 1717→ // Handle string content (simple text message)\n 1718→ if (typeof content === 'string') {\n 1719→ const section = document.createElement('div');\n 1720→ section.className = 'pretty-section';\n 1721→ const label = document.createElement('div');\n 1722→ label.className = 'pretty-label';\n 1723→ label.textContent = message.role || 'Message';\n 1724→ const body = document.createElement('div');\n 1725→ body.className = 'pretty-content';\n 1726→ body.innerHTML = renderMarkdown(content);\n 1727→ section.appendChild(label);\n 1728→ section.appendChild(body);\n 1729→ fragment.appendChild(section);\n 1730→ hasContent = true;\n 1731→ }\n 1732→\n 1733→ // Handle array content\n 1734→ if (Array.isArray(content)) {\n 1735→ for (const item of content) {\n 1736→ if (!item || typeof item !== 'object') continue;\n 1737→\n 1738→ // Text content\n 1739→ if (item.type === 'text' && item.text) {\n 1740→ const section = document.createElement('div');\n 1741→ section.className = 'pretty-section';\n 1742→ const label = document.createElement('div');\n 1743→ label.className = 'pretty-label';\n 1744→ label.textContent = message.role === 'assistant' ? 'Response' : 'Message';\n 1745→ const body = document.createElement('div');\n 1746→ body.className = 'pretty-content';\n 1747→ body.innerHTML = renderMarkdown(item.text);\n 1748→ section.appendChild(label);\n 1749→ section.appendChild(body);\n 1750→ fragment.appendChild(section);\n 1751→ hasContent = true;\n 1752→ }\n 1753→\n 1754→ // Thinking content\n 1755→ if (item.type === 'thinking' && item.thinking) {\n 1756→ const section = document.createElement('div');\n 1757→ section.className = 'pretty-section';\n 1758→ const label = document.createElement('div');\n 1759→ label.className = 'pretty-label';\n 1760→ label.textContent = 'Thinking';\n 1761→ const body = document.createElement('div');\n 1762→ body.className = 'thinking-block pretty-content';\n 1763→ body.innerHTML = renderMarkdown(item.thinking);\n 1764→ section.appendChild(label);\n 1765→ section.appendChild(body);\n 1766→ fragment.appendChild(section);\n 1767→ hasContent = true;\n 1768→ }\n 1769→\n 1770→ // Tool use\n 1771→ if (item.type === 'tool_use') {\n 1772→ const section = document.createElement('div');\n 1773→ section.className = 'pretty-section';\n 1774→ const label = document.createElement('div');\n 1775→ label.className = 'pretty-label';\n 1776→ label.textContent = 'Tool Call';\n 1777→\n 1778→ const toolName = item.name || 'Unknown Tool';\n 1779→ const input = item.input || {};\n 1780→\n 1781→ // Special handling for Bash command\n 1782→ if (toolName === 'Bash' && input.command) {\n 1783→ const block = document.createElement('div');\n 1784→ block.className = 'command-block';\n 1785→\n 1786→ const header = document.createElement('div');\n 1787→ header.className = 'command-header';\n 1788→ const nameSpan = document.createElement('span');\n 1789→ nameSpan.className = 'tool-name';\n 1790→ nameSpan.textContent = toolName;\n 1791→ header.appendChild(nameSpan);\n 1792→ if (input.description) {\n 1793→ const descSpan = document.createElement('span');\n 1794→ descSpan.className = 'tool-desc';\n 1795→ descSpan.textContent = input.description;\n 1796→ header.appendChild(descSpan);\n 1797→ }\n 1798→\n 1799→ const cmdInput = document.createElement('div');\n 1800→ cmdInput.className = 'command-input';\n 1801→ cmdInput.textContent = input.command;\n 1802→\n 1803→ block.appendChild(header);\n 1804→ block.appendChild(cmdInput);\n 1805→ section.appendChild(label);\n 1806→ section.appendChild(block);\n 1807→ }\n 1808→ // Special handling for file operations\n 1809→ else if (['Read', 'Write', 'Edit', 'Glob', 'Grep'].includes(toolName)) {\n 1810→ const block = document.createElement('div');\n 1811→ block.className = 'command-block';\n 1812→\n 1813→ const header = document.createElement('div');\n 1814→ header.className = 'command-header';\n 1815→ const nameSpan = document.createElement('span');\n 1816→ nameSpan.className = 'tool-name';\n 1817→ nameSpan.textContent = toolName;\n 1818→ header.appendChild(nameSpan);\n 1819→\n 1820→ block.appendChild(header);\n 1821→\n 1822→ const params = document.createElement('div');\n 1823→ params.className = 'tool-params';\n 1824→ params.style.padding = '10px 12px';\n 1825→\n 1826→ for (const [key, value] of Object.entries(input)) {\n 1827→ const param = document.createElement('div');\n 1828→ param.className = 'tool-param';\n 1829→ const paramName = document.createElement('span');\n 1830→ paramName.className = 'tool-param-name';\n 1831→ paramName.textContent = key;\n 1832→ const paramValue = document.createElement('span');\n 1833→ paramValue.className = 'tool-param-value';\n 1834→ if (typeof value === 'string') {\n 1835→ paramValue.textContent = value.length > 500 ? value.slice(0, 500) + '...' : value;\n 1836→ } else {\n 1837→ paramValue.textContent = JSON.stringify(value, null, 2);\n 1838→ }\n 1839→ param.appendChild(paramName);\n 1840→ param.appendChild(paramValue);\n 1841→ params.appendChild(param);\n 1842→ }\n 1843→\n 1844→ block.appendChild(params);\n 1845→ section.appendChild(label);\n 1846→ section.appendChild(block);\n 1847→ }\n 1848→ // Generic tool display\n 1849→ else {\n 1850→ const block = document.createElement('div');\n 1851→ block.className = 'command-block';\n 1852→\n 1853→ const header = document.createElement('div');\n 1854→ header.className = 'command-header';\n 1855→ const nameSpan = document.createElement('span');\n 1856→ nameSpan.className = 'tool-name';\n 1857→ nameSpan.textContent = toolName;\n 1858→ header.appendChild(nameSpan);\n 1859→\n 1860→ block.appendChild(header);\n 1861→\n 1862→ if (Object.keys(input).length > 0) {\n 1863→ const params = document.createElement('div');\n 1864→ params.className = 'tool-params';\n 1865→ params.style.padding = '10px 12px';\n 1866→\n 1867→ for (const [key, value] of Object.entries(input)) {\n 1868→ const param = document.createElement('div');\n 1869→ param.className = 'tool-param';\n 1870→ const paramName = document.createElement('span');\n 1871→ paramName.className = 'tool-param-name';\n 1872→ paramName.textContent = key;\n 1873→ const paramValue = document.createElement('span');\n 1874→ paramValue.className = 'tool-param-value';\n 1875→ if (typeof value === 'string') {\n 1876→ paramValue.textContent = value.length > 1000 ? value.slice(0, 1000) + '...' : value;\n 1877→ } else {\n 1878→ paramValue.textContent = JSON.stringify(value, null, 2);\n 1879→ }\n 1880→ param.appendChild(paramName);\n 1881→ param.appendChild(paramValue);\n 1882→ params.appendChild(param);\n 1883→ }\n 1884→\n 1885→ block.appendChild(params);\n 1886→ }\n 1887→\n 1888→ section.appendChild(label);\n 1889→ section.appendChild(block);\n 1890→ }\n 1891→\n 1892→ fragment.appendChild(section);\n 1893→ hasContent = true;\n 1894→ }\n 1895→\n 1896→ // Tool result\n 1897→ if (item.type === 'tool_result') {\n 1898→ const section = document.createElement('div');\n 1899→ section.className = 'pretty-section';\n 1900→ const label = document.createElement('div');\n 1901→ label.className = 'pretty-label';\n 1902→ label.textContent = item.is_error ? 'Tool Error' : 'Tool Result';\n 1903→\n 1904→ const resultContent = item.content;\n 1905→ let resultText = '';\n 1906→\n 1907→ if (typeof resultContent === 'string') {\n 1908→ resultText = resultContent;\n 1909→ } else if (Array.isArray(resultContent)) {\n 1910→ for (const rc of resultContent) {\n 1911→ if (rc?.type === 'text' && rc.text) {\n 1912→ resultText += rc.text;\n 1913→ }\n 1914→ }\n 1915→ }\n 1916→\n 1917→ if (resultText) {\n 1918→ const block = document.createElement('div');\n 1919→ block.className = 'result-block' + (item.is_error ? ' result-error' : '');\n 1920→ block.textContent = resultText.length > 5000 ? resultText.slice(0, 5000) + '\\n... [truncated]' : resultText;\n 1921→ section.appendChild(label);\n 1922→ section.appendChild(block);\n 1923→ fragment.appendChild(section);\n 1924→ hasContent = true;\n 1925→ }\n 1926→ }\n 1927→ }\n 1928→ }\n 1929→\n 1930→ if (hasContent) {\n 1931→ els.detailPretty.appendChild(fragment);\n 1932→ els.detailPretty.hidden = false;\n 1933→ } else {\n 1934→ els.detailPretty.hidden = true;\n 1935→ }\n 1936→ }\n 1937→\n 1938→ function openImageModal(src, altText = '') {\n 1939→ if (!src) return;\n 1940→ lastActiveElementBeforeModal = document.activeElement instanceof HTMLElement ? document.activeElement : null;\n 1941→ lastBodyOverflow = document.body.style.overflow;\n 1942→ document.body.style.overflow = 'hidden';\n 1943→\n 1944→ els.imageModalImg.src = src;\n 1945→ els.imageModalImg.alt = altText;\n 1946→ els.imageModal.classList.add('open');\n 1947→ els.imageModal.setAttribute('aria-hidden', 'false');\n 1948→ els.imageModalClose.focus();\n 1949→ }\n 1950→\n 1951→ function closeImageModal() {\n 1952→ if (!els.imageModal.classList.contains('open')) return;\n 1953→ els.imageModal.classList.remove('open');\n 1954→ els.imageModal.setAttribute('aria-hidden', 'true');\n 1955→ els.imageModalImg.removeAttribute('src');\n 1956→ els.imageModalImg.alt = '';\n 1957→ document.body.style.overflow = lastBodyOverflow ?? '';\n 1958→\n 1959→ if (lastActiveElementBeforeModal) {\n 1960→ lastActiveElementBeforeModal.focus();\n 1961→ }\n 1962→ lastActiveElementBeforeModal = null;\n 1963→ lastBodyOverflow = null;\n 1964→ }\n 1965→\n 1966→ function renderDetailImages(obj) {\n 1967→ const urls = extractImageUrls(obj);\n 1968→ els.detailImages.innerHTML = '';\n 1969→ if (!urls.length) {\n 1970→ els.detailImages.hidden = true;\n 1971→ return;\n 1972→ }\n 1973→ const fragment = document.createDocumentFragment();\n 1974→ urls.forEach((src, idx) => {\n 1975→ const btn = document.createElement('button');\n 1976→ btn.type = 'button';\n 1977→ btn.className = 'thumb';\n 1978→ btn.title = `Open image ${idx + 1}`;\n 1979→ const img = document.createElement('img');\n 1980→ img.loading = 'lazy';\n 1981→ img.src = src;\n 1982→ img.alt = `Image ${idx + 1}`;\n 1983→ btn.appendChild(img);\n 1984→ btn.addEventListener('click', () => openImageModal(src, `Image ${idx + 1}`));\n 1985→ fragment.appendChild(btn);\n 1986→ });\n 1987→ els.detailImages.appendChild(fragment);\n 1988→ els.detailImages.hidden = false;\n 1989→ }\n 1990→\n 1991→ function isEditableElement(el) {\n 1992→ if (!(el instanceof HTMLElement)) return false;\n 1993→ if (el.isContentEditable) return true;\n 1994→ const tag = el.tagName;\n 1995→ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';\n 1996→ }\n 1997→\n 1998→ function moveSelection(delta) {\n 1999→ if (!state.filtered.length) return;\n 2000→ const idx = state.filtered.findIndex(e => e.id === state.selectedId);\n 2001→ if (idx === -1) {\n 2002→ selectEvent(state.filtered[0].id, { scroll: true });\n 2003→ return;\n 2004→ }\n 2005→ const nextIdx = Math.max(0, Math.min(state.filtered.length - 1, idx + delta));\n 2006→ if (nextIdx === idx) return;\n 2007→ selectEvent(state.filtered[nextIdx].id, { scroll: true });\n 2008→ }\n 2009→\n 2010→ async function copyText(text) {\n 2011→ if (typeof text !== 'string') return;\n 2012→ try {\n 2013→ await navigator.clipboard.writeText(text);\n 2014→ setStatus('Copied to clipboard.', 'good');\n 2015→ } catch (error) {\n 2016→ const ta = document.createElement('textarea');\n 2017→ ta.value = text;\n 2018→ ta.style.position = 'fixed';\n 2019→ ta.style.left = '-9999px';\n 2020→ document.body.appendChild(ta);\n 2021→ ta.select();\n 2022→ document.execCommand('copy');\n 2023→ document.body.removeChild(ta);\n 2024→ setStatus('Copied to clipboard.', 'good');\n 2025→ }\n 2026→ }\n 2027→\n 2028→ function setTimezoneMode(mode) {\n 2029→ state.tzMode = mode;\n 2030→ els.tzLocal.classList.toggle('active', mode === 'local');\n 2031→ els.tzUtc.classList.toggle('active', mode === 'utc');\n 2032→ applyFilters();\n 2033→ if (state.selectedId !== null) selectEvent(state.selectedId);\n 2034→ syncUrlHashFromControls();\n 2035→ }\n 2036→\n 2037→ els.imageModalClose.addEventListener('click', closeImageModal);\n 2038→ els.imageModal.addEventListener('click', (event) => {\n 2039→ if (event.target === els.imageModal) closeImageModal();\n 2040→ });\n 2041→ document.addEventListener('keydown', (event) => {\n 2042→ if (event.key === 'Escape') closeImageModal();\n 2043→ });\n 2044→\n 2045→ document.addEventListener('keydown', (event) => {\n 2046→ if (els.imageModal.classList.contains('open')) return;\n 2047→ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;\n 2048→ if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;\n 2049→ if (isEditableElement(document.activeElement)) return;\n 2050→ if (document.activeElement !== els.timeline && !els.timeline.contains(document.activeElement)) return;\n 2051→ if (state.selectedId === null) return;\n 2052→ event.preventDefault();\n 2053→ moveSelection(event.key === 'ArrowUp' ? -1 : 1);\n 2054→ });\n 2055→\n 2056→ if (typeof wideLayoutMq.addEventListener === 'function') {\n 2057→ wideLayoutMq.addEventListener('change', updateDetailPlacement);\n 2058→ } else if (typeof wideLayoutMq.addListener === 'function') {\n 2059→ wideLayoutMq.addListener(updateDetailPlacement);\n 2060→ }\n 2061→\n 2062→ function setTab(tabName) {\n 2063→ for (const tab of document.querySelectorAll('.tab')) {\n 2064→ const active = tab.dataset.tab === tabName;\n 2065→ tab.classList.toggle('active', active);\n 2066→ tab.setAttribute('aria-selected', active ? 'true' : 'false');\n 2067→ }\n 2068→ for (const panel of document.querySelectorAll('[data-panel]')) {\n 2069→ panel.hidden = panel.dataset.panel !== tabName;\n 2070→ }\n 2071→ }\n 2072→\n 2073→ function setShareUrlParam(url) {\n 2074→ const current = new URL(window.location.href);\n 2075→ if (url) current.searchParams.set('url', url);\n 2076→ else current.searchParams.delete('url');\n 2077→ window.history.replaceState(null, '', current);\n 2078→ }\n 2079→\n 2080→ async function fetchAndRenderUrl(url, { syncUrl = true } = {}) {\n 2081→ const trimmed = (url || '').trim();\n 2082→ if (!trimmed) {\n 2083→ setStatus('Enter a URL first.', 'error');\n 2084→ return;\n 2085→ }\n 2086→ if (syncUrl) setShareUrlParam(trimmed);\n 2087→ setStatus(`Fetching ${trimmed}...`, 'warn');\n 2088→ try {\n 2089→ const resp = await fetch(trimmed, { mode: 'cors' });\n 2090→ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);\n 2091→ const text = await resp.text();\n 2092→ await loadText(text, trimmed);\n 2093→ } catch (error) {\n 2094→ setStatus(`Failed to fetch URL: ${String(error)}`, 'error');\n 2095→ }\n 2096→ }\n 2097→\n 2098→ async function loadText(text, sourceLabel) {\n 2099→ const started = performance.now();\n 2100→ state.sourceLabel = sourceLabel || null;\n 2101→ setStatus(`Parsing ${sourceLabel || 'content'}...`, 'warn');\n 2102→ clearSelection({ syncHash: false });\n 2103→\n 2104→ const lines = text.split(/\\r?\\n/);\n 2105→ const events = [];\n 2106→ const errors = [];\n 2107→ let idCounter = 0;\n 2108→\n 2109→ for (let i = 0; i < lines.length; i++) {\n 2110→ const line = lines[i];\n 2111→ if (!line.trim()) continue;\n 2112→ const parsed = safeJsonParse(line);\n 2113→ if (!parsed.ok) {\n 2114→ errors.push({ line: i + 1, error: parsed.error });\n 2115→ continue;\n 2116→ }\n 2117→ const obj = parsed.value;\n 2118→ const ts = typeof obj.timestamp === 'string' ? new Date(obj.timestamp) : null;\n 2119→ const kind = obj.type || '(unknown)';\n 2120→ const message = obj.message && typeof obj.message === 'object' ? obj.message : null;\n 2121→ const contentTypes = message ? getContentTypes(message) : [];\n 2122→ const role = message?.role ?? null;\n 2123→\n 2124→ const summary = summarizeEvent(obj);\n 2125→ events.push({\n 2126→ id: ++idCounter,\n 2127→ line: i + 1,\n 2128→ raw: line,\n 2129→ obj,\n 2130→ date: ts,\n 2131→ kind,\n 2132→ contentTypes,\n 2133→ role,\n 2134→ summary,\n 2135→ search: buildSearchText(obj, summary),\n 2136→ });\n 2137→ }\n 2138→\n 2139→ state.events = events;\n 2140→ updateFilterOptions(events);\n 2141→ applyFilters();\n 2142→ applySelectionFromHash();\n 2143→ syncUrlHashFromControls();\n 2144→\n 2145→ const durationMs = Math.round(performance.now() - started);\n 2146→ if (errors.length) {\n 2147→ const first = errors[0];\n 2148→ setStatus(\n 2149→ `Loaded ${events.length} events from ${sourceLabel || 'content'} in ${durationMs}ms.\\n` +\n 2150→ `Could not parse ${errors.length} line(s). First error at line ${first.line}: ${String(first.error)}`,\n 2151→ 'warn'\n 2152→ );\n 2153→ } else {\n 2154→ setStatus(`Loaded ${events.length} events from ${sourceLabel || 'content'} in ${durationMs}ms.`, 'good');\n 2155→ }\n 2156→ }\n 2157→\n 2158→ async function handleFile(file) {\n 2159→ if (!file) return;\n 2160→ setShareUrlParam(null);\n 2161→ try {\n 2162→ const text = await file.text();\n 2163→ await loadText(text, file.name);\n 2164→ } catch (error) {\n 2165→ setStatus(`Failed to read file: ${String(error)}`, 'error');\n 2166→ }\n 2167→ }\n 2168→\n 2169→ els.dropzone.addEventListener('click', () => els.fileInput.click());\n 2170→ els.dropzone.addEventListener('keydown', (e) => {\n 2171→ if (e.key === 'Enter' || e.key === ' ') {\n 2172→ e.preventDefault();\n 2173→ els.fileInput.click();\n 2174→ }\n 2175→ });\n 2176→ els.fileInput.addEventListener('change', () => handleFile(els.fileInput.files?.[0]));\n 2177→\n 2178→ els.dropzone.addEventListener('dragover', (e) => {\n 2179→ e.preventDefault();\n 2180→ els.dropzone.classList.add('dragover');\n 2181→ });\n 2182→ els.dropzone.addEventListener('dragleave', () => els.dropzone.classList.remove('dragover'));\n 2183→ els.dropzone.addEventListener('drop', (e) => {\n 2184→ e.preventDefault();\n 2185→ els.dropzone.classList.remove('dragover');\n 2186→ const file = e.dataTransfer?.files?.[0];\n 2187→ if (file) handleFile(file);\n 2188→ });\n 2189→\n 2190→ els.renderPaste.addEventListener('click', async () => {\n 2191→ const text = els.pasteInput.value || '';\n 2192→ if (!text.trim()) {\n 2193→ setStatus('Paste some JSONL content first.', 'error');\n 2194→ return;\n 2195→ }\n 2196→ setShareUrlParam(null);\n 2197→ await loadText(text, 'pasted content');\n 2198→ });\n 2199→\n 2200→ els.fetchUrl.addEventListener('click', async () => {\n 2201→ await fetchAndRenderUrl(els.urlInput.value, { syncUrl: true });\n 2202→ });\n 2203→\n 2204→ for (const tab of document.querySelectorAll('.tab')) {\n 2205→ tab.addEventListener('click', () => setTab(tab.dataset.tab));\n 2206→ }\n 2207→\n 2208→ els.tzLocal.addEventListener('click', () => setTimezoneMode('local'));\n 2209→ els.tzUtc.addEventListener('click', () => setTimezoneMode('utc'));\n 2210→\n 2211→ els.search.addEventListener('input', () => {\n 2212→ state.filters.query = els.search.value.trim().toLowerCase();\n 2213→ applyFilters();\n 2214→ syncUrlHashFromControls();\n 2215→ });\n 2216→ els.typeFilter.addEventListener('change', () => {\n 2217→ state.filters.type = els.typeFilter.value;\n 2218→ applyFilters();\n 2219→ syncUrlHashFromControls();\n 2220→ });\n 2221→ els.contentTypeFilter.addEventListener('change', () => {\n 2222→ state.filters.contentType = els.contentTypeFilter.value;\n 2223→ applyFilters();\n 2224→ syncUrlHashFromControls();\n 2225→ });\n 2226→ els.roleFilter.addEventListener('change', () => {\n 2227→ state.filters.role = els.roleFilter.value;\n 2228→ applyFilters();\n 2229→ syncUrlHashFromControls();\n 2230→ });\n 2231→ els.hideThinking.addEventListener('change', () => {\n 2232→ state.filters.hideThinking = els.hideThinking.checked;\n 2233→ applyFilters();\n 2234→ syncUrlHashFromControls();\n 2235→ });\n 2236→ els.truncateStrings.addEventListener('change', () => {\n 2237→ if (state.selectedId !== null) selectEvent(state.selectedId);\n 2238→ syncUrlHashFromControls();\n 2239→ });\n 2240→\n 2241→ els.clearFilters.addEventListener('click', () => {\n 2242→ els.search.value = '';\n 2243→ state.filters.query = '';\n 2244→ state.filters.type = 'all';\n 2245→ state.filters.contentType = 'all';\n 2246→ state.filters.role = 'all';\n 2247→ state.filters.hideThinking = false;\n 2248→ els.hideThinking.checked = false;\n 2249→ updateFilterOptions(state.events);\n 2250→ applyFilters();\n 2251→ syncUrlHashFromControls();\n 2252→ });\n 2253→\n 2254→ els.copyJson.addEventListener('click', () => {\n 2255→ if (!state.selected) return;\n 2256→ copyText(stringifyJson(state.selected.obj, false));\n 2257→ });\n 2258→ els.copyRaw.addEventListener('click', () => {\n 2259→ if (!state.selected) return;\n 2260→ copyText(state.selected.raw);\n 2261→ });\n 2262→\n 2263→ async function initFromQuery() {\n 2264→ const url = new URLSearchParams(window.location.search).get('url');\n 2265→ if (!url) return;\n 2266→ setTab('url');\n 2267→ els.urlInput.value = url;\n 2268→ await fetchAndRenderUrl(url, { syncUrl: false });\n 2269→ }\n 2270→\n 2271→ // Initialize select options for the empty state.\n 2272→ applyControlsFromHash();\n 2273→ updateFilterOptions([]);\n 2274→ syncUrlHashFromControls();\n 2275→ window.addEventListener('hashchange', () => {\n 2276→ applyControlsFromHash();\n 2277→ updateFilterOptions(state.events);\n 2278→ applyFilters();\n 2279→ applySelectionFromHash();\n 2280→ });\n 2281→ void initFromQuery();\n 2282→ </script>\n 2283→</body>\n 2284→</html>\n 2285→\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":"b5bd8de5-40ce-4e0b-b75c-995ae90f9cbe","timestamp":"2025-12-17T20:58:46.788Z","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 <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 .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 function renderMarkdown(text) {\n if (typeof text !== 'string') return '';\n\n // Escape HTML first\n let html = escapeHtml(text);\n\n // Code blocks (```...```)\n html = html.replace(/```(\\w*)\\n([\\s\\S]*?)```/g, (match, lang, code) => {\n return `<pre><code class=\"language-${lang}\">${code.trim()}</code></pre>`;\n });\n\n // Inline code (`...`)\n html = html.replace(/`([^`]+)`/g, '<code>$1</code>');\n\n // Headers\n html = html.replace(/^######\\s+(.+)$/gm, '<h6>$1</h6>');\n html = html.replace(/^#####\\s+(.+)$/gm, '<h5>$1</h5>');\n html = html.replace(/^####\\s+(.+)$/gm, '<h4>$1</h4>');\n html = html.replace(/^###\\s+(.+)$/gm, '<h3>$1</h3>');\n html = html.replace(/^##\\s+(.+)$/gm, '<h2>$1</h2>');\n html = html.replace(/^#\\s+(.+)$/gm, '<h1>$1</h1>');\n\n // Bold and italic\n html = html.replace(/\\*\\*\\*([^*]+)\\*\\*\\*/g, '<strong><em>$1</em></strong>');\n html = html.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');\n html = html.replace(/\\*([^*]+)\\*/g, '<em>$1</em>');\n html = html.replace(/___([^_]+)___/g, '<strong><em>$1</em></strong>');\n html = html.replace(/__([^_]+)__/g, '<strong>$1</strong>');\n html = html.replace(/_([^_]+)_/g, '<em>$1</em>');\n\n // Links\n html = html.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '<a href=\"$2\" target=\"_blank\" rel=\"noopener\">$1</a>');\n\n // Blockquotes\n html = html.replace(/^&gt;\\s+(.+)$/gm, '<blockquote>$1</blockquote>');\n\n // Unordered lists\n html = html.replace(/^[\\*\\-]\\s+(.+)$/gm, '<li>$1</li>');\n html = html.replace(/(<li>.*<\\/li>\\n?)+/g, '<ul>$&</ul>');\n\n // Ordered lists\n html = html.replace(/^\\d+\\.\\s+(.+)$/gm, '<li>$1</li>');\n\n // Line breaks - convert double newlines to paragraphs\n html = html.split(/\\n\\n+/).map(para => {\n para = para.trim();\n if (!para) return '';\n // Don't wrap if already wrapped in block element\n if (/^<(h[1-6]|ul|ol|li|pre|blockquote|div)/.test(para)) return para;\n return `<p>${para.replace(/\\n/g, '<br>')}</p>`;\n }).join('\\n');\n\n return 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 = 'result-block' + (item.is_error ? ' result-error' : '');\n block.textContent = resultText.length > 5000 ? resultText.slice(0, 5000) + '\\n... [truncated]' : resultText;\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":2285,"startLine":1,"totalLines":2285}}}
{"parentUuid":"b5bd8de5-40ce-4e0b-b75c-995ae90f9cbe","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"logical-toasting-beacon","message":{"model":"claude-opus-4-5-20251101","id":"msg_01WN7rMkQQQEh8PZMqxf2GEc","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to check for XSS vulnerabilities in the markdown rendering feature. Let me analyze the code carefully.\n\nThe key function to look at is `renderMarkdown(text)` at line 1644:\n\n```javascript\nfunction renderMarkdown(text) {\n if (typeof text !== 'string') return '';\n\n // Escape HTML first\n let html = escapeHtml(text);\n // ... rest of the markdown rendering\n}\n```\n\nAnd the `escapeHtml` function at line 1638:\n\n```javascript\nfunction escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n}\n```\n\nThis is the standard DOM-based HTML escaping technique - it sets the text as `textContent` (which escapes HTML entities) and then reads back the `innerHTML`. This is a reliable way to escape HTML.\n\nNow let me trace through the markdown rendering to see if there are any vulnerabilities:\n\n1. The text is first escaped via `escapeHtml(text)` - this should escape `<`, `>`, `&`, `\"`, etc.\n\n2. Then markdown patterns are applied with regex replacements. Let me check each one:\n\n```javascript\n// Code blocks (```...```)\nhtml = html.replace(/```(\\w*)\\n([\\s\\S]*?)```/g, (match, lang, code) => {\n return `<pre><code class=\"language-${lang}\">${code.trim()}</code></pre>`;\n});\n```\n\nThe `lang` comes from `(\\w*)` which only matches word characters, so no injection there. The `code` is already escaped HTML.\n\n```javascript\n// Inline code (`...`)\nhtml = html.replace(/`([^`]+)`/g, '<code>$1</code>');\n```\n\nAlready escaped.\n\n```javascript\n// Headers - already escaped\n\n// Bold and italic - already escaped\n\n\n```\n\nI notice a potential vulnerability in the link parsing. The URL is directly inserted into the `href` attribute without additional sanitization. This could allow malicious URLs like `javascript:` or `data:` schemes that might execute arbitrary JavaScript. \n\nIf an attacker provides a link like `[click me](javascript:alert(1))`, it could potentially bypass the HTML escaping and introduce an XSS risk. The current implementation lacks URL validation or sanitization, which could lead to script execution.\n\nI need to implement stricter URL handling to prevent potential security exploits, ensuring only safe, expected URL schemes are permitted.\n\nThe current escaping mechanism doesn't fully protect against JavaScript injection risks. Single quotes remain unescaped in HTML content, which could enable malicious script execution when embedded in link URLs.\n\nI'll need a comprehensive validation strategy that explicitly blocks `javascript:` URLs and sanitizes all potential input vectors to prevent cross-site scripting vulnerabilities.\n\nThe markdown rendering function provides some protection by pre-escaping HTML, but targeted URL checks are still necessary to ensure complete security. Specific attention must be paid to link generation and attribute handling to neutralize potential attack paths.\n\nDouble quotes in URLs could potentially allow attribute injection, enabling script execution through maliciously crafted link inputs. The current escaping mechanism leaves critical vulnerabilities in quote handling that might permit XSS exploitation.\n\nI need to carefully validate how markdown link parsing handles potentially dangerous URL constructions, especially where unescaped quotes could break attribute boundaries and introduce executable code.\n\nThe specific risk involves an attacker inserting an onclick handler by strategically placing quotes within the link URL, which current sanitization methods fail to prevent.\n\nI've identified two critical XSS injection points: unescaped quotes allowing attribute breakout and potential execution of malicious JavaScript or data URLs. The code block and header parsing seem inherently safer due to stricter character matching and HTML escaping.\n\nThe core vulnerability centers on link parsing, where uncontrolled URL input can compromise attribute integrity and enable script injection.","signature":"EoY1CkYIChgCKkC+TuLHqCIfa2b41Txfbh1jIrsfHUw3q3SYS2i6Us0liEXAq3X6vSUIUw7KWzCDuFkmu4Z4Bvw56bt2zHUsj4GtEgy1qKdN+2FGg00CNFEaDLuJVmsmJavyKBmsYyIwtJD/B/wlM/Q6RkUzZhdB944bridxmvG8X9+/jj324IKs+Y+WlZHFvUOholHMAzXLKu0zGVKyB5z7jj8Hz2g4j6NGCkQO52Kyv7SxtjfpgMXlTo8/G0a038utIhF4avMiD/P6dSwmrIWBjMrr99z3PAjXJSMBKOm+4/ClN6jOfFiONI1Q689fHdj2a648XrM4YiKlpEqXH1XQqTrMTZVim6/zQUdUR4cwbV/ei7g+fUuaL2e+OBB41S4P/NpF+o95CzooDV/V/RWfaiXkJ83xRVFtepgefFSCGl9ErScXB/AvtshSM8PgRP6D6ek1ahafrLfHXUUneuRRLEX3Q3cr2/nFyJ5spvfnD5wbfczd05f7+SpGmoKIa9M+uHDSBNbkSV0V1QIrWQAU3TepqfZUG6XBsBhwThRkQib/SQ/nIdSnxkPz1MLD8M/SJ6uSZEBox9zx9f+VodlbntzbhR8kxNHtjQtiTMHcLhcF7Suo3kzEtjECgK4NNXV7MBZRHVNF99edPh8JUMuahqGLgs2VZM0enauMs21kWV9D/XpH97P5Nsp0MOgZaYXzRb2gaarxcP0fLnX2hzEwtdL+V8XoL80vfuXjgkBrAOGiGVsD2dwdS3u6HcH31lMowkVgWJBRzG75ZOygfTNd/vkq8C7uQSxxiFdBH6FRXNJ8oCffAV7Jcrpd8VUXP37oS0JheoH+sxX2r3ViHQC+saGLGUlU+Oc+AMYLfyRS6hKAH+GWhLBAy7qhAzKGk5OF/BFiNq8oVm7nKywE8uCyxW37Yp0AqkhXeGVuhObxPL1AvUldx/BbxTRZbcPzaIl+Tg7/d1ey8reif6zCtUdDC0SO33Ly3+/ma5V7q0jv4Ryp1jOGO+hlqH2AQsudZs/MoYnosIc9OnOqRSGnaGIiQvqvFFr8loOti/8a1ybHluju58vLtFJegccXUM/7VYI+hYCXVQKzuF1ptzlS1sazYbB7ocs+mCUUPCF/3WaV26AnREp5xYjjH/SXjeTB+wY/TD4tWLh8kVXQDHQQvy9AtOgV13UDmpsCMr/qSQpcg3PziIVjHu8AbiQ/f2o4f57nbK0w+vPt/RhdndCbjEFvgD6bGCoPCD9hptOO5Hj+hPB+1H0tKLnH00OGAV7YEsOThnb8KtBYnmIOjMPkaP9f1aLe69JC+eJUOe105jlRUukfxSRfbFwu3pRzqPlCsR3pNQdG930Q+JGZ0y7QNrfx67gMuWiGjV2aW9AslVVW5VPoevkYjlx5kFZZd+XiWkhv35JuU3qjhOzau4nJPAkBO34zQkNI0hZatjAM5P7dD2l9QOlnUugczzdZjI5AC/BpwpJn599GDB2hKPpZgZM01O/KpWETS6FkDEdJXNE5svTPBS/a8VDZiQkDM28qg/8YcUkJC4qA6jFUBlVpQP5Il7+VTdhiS/GTgg10G1+wDrTPaZq6S9FxjDQ3CHZJHiiGfQeuZVV25j29g3IIPeP4uuPV0eb5cEWk7SPTmslDx/+EvPHUQzJK2ytDRXBqlKXk5StTuzvF5oJW7XZcllHlUup/OBUif0ILeDDZA8uRm4y/9ZnV/TJpl/Vjurqp6BFvQuUgk8t4UPvCPFqx9EWGWuGHopZm/zFI2AXYKodh1MNQQIXOb16Gz2XZWiAD0q+5p88eZiI5SasziQ3qxcL436KFRavGTwhibPmDcc/kXBF2xmeukh/XAZeCKtWOgw2cFd9PqhZQNsrmrOKSbIF977Q/j/kObTSeZkrXG2p2jmZtg/znsNrY2xp7YtKRflxAdNFM9T2jN/RLc7+427JvRTfRaOp45XjvykhR1Zc7srtxpPKCijp1dDNv/pNj+Te7hwkYpbkUpdfuRdG3uPndzXDn3xgthrKULhuuvgnW5ZSowvfEFjlGET3rRg1tdzUT2HKRVJ8o4clITmPEYyaRJ7XB1tLwjVQLMGfLASBrf75PY1wcxzA/WFdHEyx6FO5vh0VrXUNXhFtdkXOpqoJjbwzQleRMgjcUaUNMSqH2EaAbai/fw9ylaPPYOTHj0E8h7IMQfCOcDpoxNTOiAeJRhsl2ecizWkEbhe8IUYQtZWTn/iPku4kaBi/B8EfeJcm0zA+C/sqmwk4ZaaR70CBnb+mTve8nWG3UjV0LKYEKI7UD61dHvq3iVXpI2duQJHwcAkYVYR7HKoUHt+FQylWNqf+evnV42x6zE7K64T4dE+5CZPloyjUQBaVksOoQlYSewZI//tjrs8fbUBZBOJR3dkfE+a98DZNuVEgU5VoUKAIXU3bbcDEOHH+1bWgDePmdO0ZOxhkLakGsoz3gsOmaWosZ1jctkC+FmTF/U1JxVhVcGfe/kSDXPgUCWuUXBD/sFqv1OTadhMbyL2DYZXZz4vJikDXNbGDjrbVSuTOIKk2S9uMDZDzUoConFbZg44govM4gvzubDGMAVNm3jV0gOl3bgqLzyv+HI3vLqiyuN1BfXozzJHZGhS5/F9yUSNZ/hhfsPA9dQ4RBm3/PvhFj/y16341uVq+26Xs8TQG7Ri2tSZmxybXTGIz7yy+sRDqr/M34yZacFqeepHO6+6y0I8pPH2u2aNwprERjmlUt4UHQWnif1E13OHksm4OGX7M9nlGoDc6c8DRAn5GSVWaUFuhjY2AIFsGGHZYnqp3l7diYIdhKm35Y73EkekbfW65xRCQOPZtgBjo1wiuYkBOwapKZyiFCDUnOZgZbWZoh2zd58L8b05sXEvcJXiao6ZAgbnzwDOXxiNifARbfPbQpGB+pyfNdq4h86/5walR9VJDEMFfkm9ayGrQXfSdoZkq7dAEOg4snKIbL1x+7ubh34JWq5fo9nq4t06YCjhqSD4VyHVkZnJ/CZITY+C5YrteDQqlMBv0N7Fm2GufymbzXrqqkh/ViCJRpfMaq2DepJQJ7C/9Dw4XFBb/bQ92h/jnf+as/N/DPrlsFElouPpTohTdKRETv+rvrYlSpzDrkBRAFfNnXE8xnfDbYSNhXokrfxtqHQn6U/aduuJB+RuHffjdMfPZjjyAYaiRvNKWyeloVXUVeN7qZJlln9eGNedqbsR8dDhjY95IGL2Nfar4FrsNnL5KLO4PBK1kW1nkfXY1fB2wAF2/Q7AMUt5fkGIZhbJZh4moAEf4+1MmcCV1INrL3uCwRYYGvFq3UeuEw4kpMO9nAUmgezezJY9/x9/6uVK/+w4Re/HD8LosjnY/UDoUgwzWhP4arSzWm5Kkx+DyGscw2QJo4kmkHcD8bJAibedmqN4utE4+GG21H069NLeg2+QA4ic2JJ4Sx05gzvFoFKYYbdM0yoNAwno1udI1hQbH1ZaB9DaFLCoW7d1jTy64TyWJ96PnpNlqtVfi5SeZDPWUWYCQ8D8f96DJrF5A5zBKWf89SHz69Upa1dUi0GdJxnM/yAs8MvCAgAp9lLuqhYEaKx8U5PYKpAkolI0bgAeUr4Hj8vyv7Z9kNg6hS5HVbSI9U2lgCrMLhRIoIbiB9UvoRRfeFETcQ+KC11wnb5Sara+yvvHPZ5dNJDg4agLpTzUXAEuFR45T/LgK3x8Nhu5ovHqk5TFxx5QkWEuXRRnd6I1JeyoJ03JrKrT6PuH9ZWuC+qlbWxYhElbm6cgVXVMnY/bD5KORBRtrX6SMUbUKruN0OwLYdmTDBnbugttSNXgEUxlSs++8vKCmUKy4JQ9xIrq2wiyy0uyBlv2oHKjivJ0YEejkTRXQU+V3GHyVD76BImZbUCkUyvhYQw596uHqXM5AgDV0wYOabtomPoBDoFLMYmi4FU6KGcaLdxT21xoNqJGEDM36B0QI/L2a5vkPB+j8p65sAWjrUI7AtGRt4o2ORE2QMLUtb3WTP3pYZl3HZsAbfvNU4Sdl+rYOOR5C0nS1ORKWpjPMdfpdvSEPoDVdGB6JDY0R0Wp2OfLeru8V7uCgARcmTusXAe1jt5bB172X/8WrfdvxB3HCwIqHuiuK2rANUbuT4hjlja8m7RDv0EYtY2392mG5+44XIAs3XRjE7EV2GuaN6U0fByW/SWC86n54c8ETBgSHQKIRqruMmZk2l2r7wX4NK+RWllpDCeSdIZbmdtUuqjKW4Qrt4GPqtjS1MJ9DzH2m5LhPHwGUYNosYcwn5lHASWW8kEnQtU5P+tkaLbhnTQCiVEPEDwg9fzXIzjUJTxtOzc7/VlEGOd4q+ZwTYXYe7mslYsmpOXngc4l3pDyONL5T5fA8eBYnwFg0lPaDF9OLu7fWkrLlCXH5E6J7rRsSzMfd330N30AgBCm8gxGzNxZR+J5wrOeVH5PQJ78J3+TgjikQGq2jODCnmIvAh3kcoXBhm+uBX8jOO/0BiS6cLs7hhcIMhk3stMNLDkc2PvFy5SimzGIMEUFL3+M/GRcN0ahXRZX9P3wkEmG92AQh8cBKbG7Im5jZgf1i8NTJUz6ac4RPjeVl90Baa8MPNboRqIQ3iVoFqMKzEfqV6GgK6HTWulTVPS4f/i2bGgpPohAb08ZUZ9oMkQ/LPJHlMvEyR/BBCNstdwgDJyqrSaZ/zXggIFJSLIBiw7oef8i3WuqGNVxVvlTLUL0T2O/85DGGcKMWvGKqwKHRr3CP1IfiB9NZcCOAyBwSpFt4tAYYfWATy3cxzoclb1L4TpVqSBJrB37dqUKEeYdO/6eb7NW51E4Hx7k/K/J63mvNQrBvAQ/+51tOA7CyeLf82+5UDDIilU96NmQqt1OU39DLNvgGcytF8CdgSx04HIKMn1ToHBYVVZ76runwO5E2Z5EpS2f/Zzm2fY4QfX/sBlEGbGDQL9ggraQW8SXaOWD8Cn+jg3ZLk937/C0dZrEvwnPUt4Y0nSu4gi+qrlPhyLfIk/uQ/3YHxm0TG8fFHF8/HNMtkMkzaHTJ3eDBsvOBpAtuMaOyPQWsnX7Rm+x0/bPUsRL+gEhEMNyTtdGc5IpVSYMUXSBOpUGi+ajJpeY5C5E4zUmhVrLEwIrw0a1jrhrXuSztx+PbSm9fVEgFyHYlgJX/Y1bjtV+rI2Hh6UPEXzwS4+z5NJt+QcNACZBQTWQpz2DnQ7PLnw7yrbGX0osL69+2DuTMKXf6tdo0F57q5X9dKDdesoWDH9LTP/VW/easbOjoMwbdmUSv285RcM+6ek0AvTqyUFJY35ZheAd+vXPF5Ofe4ao1Og3spazAf30G3V4StPtpLYG4jaj/mP/C3qaJr15XKsPYYCTPYtk8NArx0dBIpnngoga+bgMbpM2K4IxvPODWsUNIsqiOsl6KhEBJ1R+ZWj2XhD9GaorV3NZSil+Iw6VuzczEGOlvf1iQDIx5HKQllKzSpYiHXluMK9cQ+TaVzWXXPgHCgrPiwXIpdgpWTF5mttkK8peceoRsIAa5SyMuCcdJsxAEiz9At6MfnwAWKzcaTxwhh85B4YfxzRoS4C+KmI1iVO9RpJBO/3JlKuQKS7zjnCo1ItahgpezMNCAPCCKfUhveGp5IPK0VBkIK1UUAa02lwvqc5HGQizAlvvv/YYxJmsQN2Jhfo2fwJDmwhdGe1fmBbg1NUDSW80S0TouyxpeBLhx+ai/YnPHOCGqi6UEv/fti2GpmKE/i97RmQmj5d62ZadlWNrqPg1MxV4+WaKPjyhBBCFo3slsbJg//U57lNDLQsc+vaMVKtQA3gTBSdjzyWJ3+2f4110LkgoCv1sPL0a2PkB8qEZzFiGXEKt3I2xgCbBx1lj/DAjnMancmDEcAGPIc8RYl4XFuHNrDytSWWUZ/FP8O4gMdo0g0q/PoXqtT79J2HN5uFfuWEvp8DhWQCNdiRXjn2uY1FuV/Odwx3+N4cMpVj1okVbuXnvdf8mdf5TskIePduEw2GnjXOPfKhrjv4IVF81lF9USaPo20cnaIsg1PXM3OhRytXTQD0UdPlJAPc7y2Ve+NYKA3N60D58UoMTUTjdqsGVFD+nS8RPobRKwBppybTHek7BEcGRWV3N2mH3+EMtLg6DiFuLAFfH7UxMh+pwJFDXh7+nI50QxJ4SSfyWnXZArkftnvqCpF9ikW6p+rXAdvT8TWKl4pUxmu8M15otBnI19j7r252b4REBovznhmVSRyVlzcqzbPxyQH/8F/mNapZPZ2oG5PSO8ATF5CKzwgHjcMVbhGI1gTehrZ2XAaidY8jzAt2nOZrSBs5nJO4UH0qDpcTk9quadxuMecu2rOifrmsa0BxxbNYVTTGnjP8KHfjd1OcDJjfyzsXT/5AxSQAt4TlXyJ04WBC6bEyZIse+eMYDBAgjW0IJ1lFkGUvmHLIY6FbyVWFrGTUGNEzumQGDcXAgXDNWzKq+qfJPS7veEyntAJq1USJhamvLRVnA8K5KagvxxwfjuaBpPuj4BV/FrygGCAltjjQPeXhad8S1fhp59gRU4oG/ey76y9fmK3rSJzGnwfsQjns9HHXl0wxp3ca25oc4PkpzNQvvR4GtIhtYrxt8GSTvSiCp8CelYDZk10xYXcpD0DC9MH4FP4oNTgw0sGmi8GlmA/DKDYkKQYxfRQPQZVb2a+FeBwBOzMxZFKJurLXeNkWVsrZnPD1GzSUUxffQX4XrjDixgX73x4ZJU/naxoVv8nYRIOunCUWyjNTZ6WnG3axCWGBEKN9COgW9Qj1wr3kY8LAQWWG6C1jWH2zpirAOrviaU/9okZoMZzQg2LVjhcjf3SFWFuCTfCrsVsIpmKFQafE0fKBezlBYftPaiH99+SUMF0qKom3cMnXHL4gEoPGt9w+brFeLwtBR0wq4D24199s0SzP5fo9wG/zkNkKzPWaTkCxb0uJ2W4ocoHHZsKDCses/azQ0jPLD+QmJJS8zGlC7RaKhyqsCVBFLCgGs9FksQhn7PMHTTfcAnGhDVyBiweYpxw82pmpFpp1jmz+VIi5gnJdyJlLmrCjsdeJq46BR5bhlekQAie4U4y0pDLsnnznVCmoYJ4g+6u0PQxQjLaZzM5dJYRKnmXNMssGnQHoVlgii0Hl/Ub3wUdmwUobqaIbR953Kf5xxLOVI4mGSMXknJynkatDqPloiEMcvUg7VCFL6NjbuE7gOhHk/tDyUb+7qFvE1JnswJkjv6Tq9G3biJlDYvoCVQRhGMmkfSW6rtV+2g6reFNmhtxsten67Ne87m7/GdZ5doivva5/zBwRfJV4r5WzkxOKes1+vc3R/ObFZ2zz1KNKLoImrY+9T0c5IXioqQWK+UssHuvmCU/ytRC3KjxL/BeayJlrXkvFMXY+fWKgSaWlwzuYpC9eJ0urF5Bu3rAf2C88Fs7qZGAQi0D/WSO6mItsVwP3cbQyNgmPy/0rdd+4J4dbq3OTZ4HZKyCaMP67Ybgo3DgbgPfT8EErncgsoyHvUtc68A8yxqI6188DBCevrK3Tf54baYZtX8lEJuPABiD6d/P5knuhPEfBY/S/NpIiR2zlg13tOfXiD5Ma69BxG3S0gSUSKj5noWvc5aBRsKxIELB1GK7GX/MzJPg6+3e5xgwF8RONya4RuqflLpW/XFOyWgo8L9AAHDPXWYE9XiteQT73t2+Mu5otGFeDunTsvOzR9nvBXdWmTxMnLCQqywZyD4u/sm7QSgU27SG2rBehYsbtP5wMfqwfTLEvrlwJOS04hKRpJ6YSOpmc071OlGAiG1SXM3zOY8ZBlZfd4KaVFgMrxlNDzyi8BwN9GZ5u0klrkJoAhKOnu0mOR3QRcJC7QlT0IJrKRHVsLhYp8ZA24mEWCLz37YFZZJaUSsdNgBI6NzB5mZnDQbId5OXTCp1e/nnd+/5Tg0GE/PfFyBKPE2oP5yGxlCKXcsYn0mCi9O9xE3DaL29pNcX8mHP+i3ssFXJ05K5gETNH4jHGMcEHhTp9dgNFljF14NShDWq+B6X8Pz1YEhajlJ+Yke2NScCMx8AZ9Do4O6iabFbGAjCx7NpxTSBhy2UV2ZjERsriGfgLaR0GGPMMduzEvxdYMKwFhUZX371BTphX2wHn0lwjRhJ5kD5nC5GeducsNUxdoDPFECNZ5KP0Eb+DtYj8s5kjnUReReZ0UstHegw10cE/AmUC8XrQl5CEqm2bqeFIEZIEUKBB4j4uZ+1hK0VIN9iBp3SFklozlkfvLKgfh3xF0FTFJg1iR6swZVYSnlCzU/3k6p0lnT4All3KY7ijc7vsVfEnYhg4FIq+YtPkwBtySO8gjZQ7gVflE3/cYwU4Ua5ZDWkAbDeCbySkLgBRPDbsaJ9OWfLeCEvbbctJmWKqFE1r/iSAdxBOSZCtR+QEN/t2MUME/YmOo4eeric0bYbGQF7HJ94fF9iDiztbCz/aukJYWpUewf4+TiJX5B/aOnUkD0OFXPntQ60hTRmZlQ+bFFiVv4cIa2VBrL084Ay1VDEFYQInVs569I2fTEYfBGfmO8uq2IcE1TMx/PFd2zk/CQjHIZXuHDUwJR784z18FdIfX9SyFplQoPQRe8uhTrUy0e1iZsCfYze9bXvl77VwrmlFY/IUuFTxBlM8n/21LeCrkiPQLGe9VTphbxDEi+sncCZCV4s+NktUkDNXCq88Yo2W8lBhxnf+FEm8T4K3R9Y7AU8BaIwSlXul86FqG3THQRA7gEawUtaYwBnSFTmd54hgfjneITy4KxwjNI7H4JjhaYqOXv4ZonEFYmz+Q+51AwSXg2Sg/DicjVXscl1cbPX4ymSjt7LRKCtpVh8cpWLte1Pf8fjPpfUKqpcOAzBQ12Ayxr6qO5Pj2XpRqjICak5Hrg+OB0yKTSnaV4XfdGwldUkgxATfa5ppKsfgOqakb95qdNmR8IBq/i9p5/H7YhDeew868HtHugju/qfp/r1JeLMKdv8M91RlpUDgji+JCg9jeaKFORAiy4LWlKye89LSQRCGWwmya3+hEVoqnyoBOryCnD+EGkPwr6PDQlttdtXMMaYCMDuxGRTVFfcQly5Uk90f9KNJXaL0jRefxgB"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":32147,"cache_read_input_tokens":18672,"cache_creation":{"ephemeral_5m_input_tokens":32147,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWCvupsNnQrdUeMmmMwox","type":"assistant","uuid":"e8a6973b-f671-49ea-8bf2-b2556f8ac55d","timestamp":"2025-12-17T20:59:27.180Z"}
{"parentUuid":"e8a6973b-f671-49ea-8bf2-b2556f8ac55d","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"logical-toasting-beacon","message":{"model":"claude-opus-4-5-20251101","id":"msg_01WN7rMkQQQEh8PZMqxf2GEc","type":"message","role":"assistant","content":[{"type":"text","text":"I found a significant XSS vulnerability in the markdown rendering. Let me trace through the issue.\n\n## The Problem\n\nThe `renderMarkdown` function at line 1644 uses `escapeHtml()` first, which only escapes `<`, `>`, and `&`:\n\n```javascript\nfunction escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n}\n```\n\nThis **does NOT escape quotes** because quotes aren't special in HTML text content. But then the link regex at line 1675 inserts user content directly into an attribute:\n\n```javascript\nhtml = html.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '<a href=\"$2\" target=\"_blank\" rel=\"noopener\">$1</a>');\n```\n\n## Exploitation Vectors\n\n**1. Attribute breakout via double quotes:**\n\nA malicious JSONL entry with markdown like:\n```\n[click me](foo\" onclick=\"alert(document.cookie))\n```\n\nAfter the link regex, this becomes:\n```html\n<a href=\"foo\" onclick=\"alert(document.cookie)\" target=\"_blank\" rel=\"noopener\">click me</a>\n```\n\nThe double quote breaks out of the `href` attribute and injects an `onclick` handler.\n\n**2. JavaScript URLs:**\n\n```\n[click me](javascript:alert(1))\n```\n\nBecomes:\n```html\n<a href=\"javascript:alert(1)\" target=\"_blank\" rel=\"noopener\">click me</a>\n```\n\nClicking executes arbitrary JavaScript.\n\n## Fix Recommendations\n\nThe link regex should sanitize the URL:\n\n```javascript\n// Links - sanitize URL to prevent XSS\nhtml = html.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, (match, text, url) => {\n // Escape quotes for attribute context\n const safeUrl = url.replace(/\"/g, '&quot;');\n // Block javascript: and data: URLs\n if (/^(javascript|data|vbscript):/i.test(safeUrl.trim())) {\n return text; // Just return the text without making it a link\n }\n return `<a href=\"${safeUrl}\" target=\"_blank\" rel=\"noopener\">${text}</a>`;\n});\n```\n\nWould you like me to apply this fix?"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":32147,"cache_read_input_tokens":18672,"cache_creation":{"ephemeral_5m_input_tokens":32147,"ephemeral_1h_input_tokens":0},"output_tokens":2543,"service_tier":"standard"}},"requestId":"req_011CWCvupsNnQrdUeMmmMwox","type":"assistant","uuid":"066a4673-720f-49d1-bd7d-978962eca622","timestamp":"2025-12-17T20:59:35.496Z"}
{"type":"file-history-snapshot","messageId":"ca4b7704-43e8-4082-8067-e866bb529e4d","snapshot":{"messageId":"ca4b7704-43e8-4082-8067-e866bb529e4d","trackedFileBackups":{},"timestamp":"2025-12-17T20:59:57.448Z"},"isSnapshotUpdate":false}
{"parentUuid":"066a4673-720f-49d1-bd7d-978962eca622","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"logical-toasting-beacon","type":"user","message":{"role":"user","content":"Fix it with that purify library instead"},"uuid":"ca4b7704-43e8-4082-8067-e866bb529e4d","timestamp":"2025-12-17T20:59:57.442Z","thinkingMetadata":{"level":"high","disabled":false,"triggers":[]},"todos":[]}
{"parentUuid":"ca4b7704-43e8-4082-8067-e866bb529e4d","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"logical-toasting-beacon","message":{"model":"claude-opus-4-5-20251101","id":"msg_01CpNYTCdFhB1Gyk5ebhNuZe","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to use DOMPurify to sanitize the HTML output instead of manually fixing the regex. DOMPurify is a well-established library for sanitizing HTML to prevent XSS attacks.\n\nLet me implement this fix by:\n1. Adding DOMPurify from a CDN\n2. Using it to sanitize the output of renderMarkdown before inserting into the DOM\n\nI should look at where renderMarkdown output is used and sanitize it there, or sanitize at the end of renderMarkdown itself.","signature":"EuoECkYIChgCKkAbrEARpA7+jQTQSDdDLZAcBQVzyhHD3w54+pWFU24G6ig+SmMsftT2Wusn7A1GzubCVLKWwokFftlUnhV0tj6lEgy5aXy3ZbmlE5YZvHcaDJqWSnYY37XGiYlJ6SIwH+ZZFn9f7yq8rRhmfMoTYRQU3+329QszKl6OEXNNk64Q0k5n/DTg+011HIuff3HsKtEDaTc1YiXduLv7f4z5gc/8Rt5592AYYW3GxE8dqqx79IV1o7HlbWrNUQvwoIfEAxwWLxRqh0h4k1w6ClMHipV5aY/kbBs1UjwHJOu9BaHdzDy/MPboQd9fz5cOzHpiQrCK7cFX2SnpGv9OGHQoGsW1wlZcA9AGbfrZscn9/jYbGTcO/WvmEALJXq4DCRUT4+/BNyI3KMS+3N1P42liFfIG4WlKq9p718SPB6lgbL2QJVxCDEYLyQ+tW8gIMcDQGGJo1irhyzIVhIaTMAsymyUoA6V/87ADxHhSxuVeFzyQiks3nfEIpflFuOzaBv2sDfQiXH9nj6dRSSUc1pYfn/LuAkh1FJyQTALzCP+X8vAZ7fTbr7HOXBv9H15+tziNCInPz5jVnKC69Cui5wbbj7d8sLJSvf40ghmiXPWuC4g0LEPfNFj9IgybmeqrzndjtzPPUQjkCEMTCnkscgFthapAl6Pqo6Cu9Jj8U0bTLTsNG80+SWmW8axDVMzP3BGzOMEiFsvtIwcSpYsX4P2GBgcnR0DwuWZkgnr4gZGBfqcqaFLiibf6+TosdvHTgv0FqDXaNMbDk8buwK0glW9rwftlKhPDmk6JFAuO6VSsMJs25JQuGAE="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":11,"cache_read_input_tokens":53312,"cache_creation":{"ephemeral_5m_input_tokens":11,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard"}},"requestId":"req_011CWCw13UJ8aPkH7RJmuj7p","type":"assistant","uuid":"2d3c423d-aee8-486a-9b0f-7ad61b7daf5d","timestamp":"2025-12-17T21:00:01.673Z"}
{"parentUuid":"2d3c423d-aee8-486a-9b0f-7ad61b7daf5d","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"logical-toasting-beacon","message":{"model":"claude-opus-4-5-20251101","id":"msg_01CpNYTCdFhB1Gyk5ebhNuZe","type":"message","role":"assistant","content":[{"type":"text","text":"I'll add DOMPurify to sanitize the markdown output. Let me make the fix."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":11,"cache_read_input_tokens":53312,"cache_creation":{"ephemeral_5m_input_tokens":11,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard"}},"requestId":"req_011CWCw13UJ8aPkH7RJmuj7p","type":"assistant","uuid":"766d731e-094c-4198-a500-ba8f55016212","timestamp":"2025-12-17T21:00:02.204Z"}
{"parentUuid":"766d731e-094c-4198-a500-ba8f55016212","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"logical-toasting-beacon","message":{"model":"claude-opus-4-5-20251101","id":"msg_01CpNYTCdFhB1Gyk5ebhNuZe","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01HFK7kq1tA6jCdfH53ZWRRo","name":"Edit","input":{"file_path":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","old_string":"<meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>Claude Code Timeline Viewer</title>","new_string":"<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=\"sha512-Pk20VxuOhSM2Yj7vqVyL9EtCnxlRH3BszG2kN8vM/zxJNMYyH9GA0PO5P3iHl91NY9QpWBdVGdFn5ECDE9GSMQ==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":11,"cache_read_input_tokens":53312,"cache_creation":{"ephemeral_5m_input_tokens":11,"ephemeral_1h_input_tokens":0},"output_tokens":490,"service_tier":"standard"}},"requestId":"req_011CWCw13UJ8aPkH7RJmuj7p","type":"assistant","uuid":"999aae04-2ad5-4e49-a520-0355a59ce6fe","timestamp":"2025-12-17T21:00:08.145Z"}
{"type":"file-history-snapshot","messageId":"999aae04-2ad5-4e49-a520-0355a59ce6fe","snapshot":{"messageId":"ca4b7704-43e8-4082-8067-e866bb529e4d","trackedFileBackups":{"claude-code-timeline.html":{"backupFileName":"cc3a5887c9685f80@v1","version":1,"backupTime":"2025-12-17T21:00:08.194Z"}},"timestamp":"2025-12-17T20:59:57.448Z"},"isSnapshotUpdate":true}
{"parentUuid":"999aae04-2ad5-4e49-a520-0355a59ce6fe","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"logical-toasting-beacon","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01HFK7kq1tA6jCdfH53ZWRRo","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 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=\"sha512-Pk20VxuOhSM2Yj7vqVyL9EtCnxlRH3BszG2kN8vM/zxJNMYyH9GA0PO5P3iHl91NY9QpWBdVGdFn5ECDE9GSMQ==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>\n 8→ <style>\n 9→ :root {\n 10→ color-scheme: light dark;\n 11→ --bg: #0b0c0f;"}]},"uuid":"ce0fdab1-7b95-4fc7-9624-e324455c8e61","timestamp":"2025-12-17T21:00:08.184Z","toolUseResult":{"filePath":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","oldString":"<meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>Claude Code Timeline Viewer</title>","newString":"<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=\"sha512-Pk20VxuOhSM2Yj7vqVyL9EtCnxlRH3BszG2kN8vM/zxJNMYyH9GA0PO5P3iHl91NY9QpWBdVGdFn5ECDE9GSMQ==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>","originalFile":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>Claude Code Timeline Viewer</title>\n <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 .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 function renderMarkdown(text) {\n if (typeof text !== 'string') return '';\n\n // Escape HTML first\n let html = escapeHtml(text);\n\n // Code blocks (```...```)\n html = html.replace(/```(\\w*)\\n([\\s\\S]*?)```/g, (match, lang, code) => {\n return `<pre><code class=\"language-${lang}\">${code.trim()}</code></pre>`;\n });\n\n // Inline code (`...`)\n html = html.replace(/`([^`]+)`/g, '<code>$1</code>');\n\n // Headers\n html = html.replace(/^######\\s+(.+)$/gm, '<h6>$1</h6>');\n html = html.replace(/^#####\\s+(.+)$/gm, '<h5>$1</h5>');\n html = html.replace(/^####\\s+(.+)$/gm, '<h4>$1</h4>');\n html = html.replace(/^###\\s+(.+)$/gm, '<h3>$1</h3>');\n html = html.replace(/^##\\s+(.+)$/gm, '<h2>$1</h2>');\n html = html.replace(/^#\\s+(.+)$/gm, '<h1>$1</h1>');\n\n // Bold and italic\n html = html.replace(/\\*\\*\\*([^*]+)\\*\\*\\*/g, '<strong><em>$1</em></strong>');\n html = html.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');\n html = html.replace(/\\*([^*]+)\\*/g, '<em>$1</em>');\n html = html.replace(/___([^_]+)___/g, '<strong><em>$1</em></strong>');\n html = html.replace(/__([^_]+)__/g, '<strong>$1</strong>');\n html = html.replace(/_([^_]+)_/g, '<em>$1</em>');\n\n // Links\n html = html.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '<a href=\"$2\" target=\"_blank\" rel=\"noopener\">$1</a>');\n\n // Blockquotes\n html = html.replace(/^&gt;\\s+(.+)$/gm, '<blockquote>$1</blockquote>');\n\n // Unordered lists\n html = html.replace(/^[\\*\\-]\\s+(.+)$/gm, '<li>$1</li>');\n html = html.replace(/(<li>.*<\\/li>\\n?)+/g, '<ul>$&</ul>');\n\n // Ordered lists\n html = html.replace(/^\\d+\\.\\s+(.+)$/gm, '<li>$1</li>');\n\n // Line breaks - convert double newlines to paragraphs\n html = html.split(/\\n\\n+/).map(para => {\n para = para.trim();\n if (!para) return '';\n // Don't wrap if already wrapped in block element\n if (/^<(h[1-6]|ul|ol|li|pre|blockquote|div)/.test(para)) return para;\n return `<p>${para.replace(/\\n/g, '<br>')}</p>`;\n }).join('\\n');\n\n return 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 = 'result-block' + (item.is_error ? ' result-error' : '');\n block.textContent = resultText.length > 5000 ? resultText.slice(0, 5000) + '\\n... [truncated]' : resultText;\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":4,"oldLines":6,"newStart":4,"newLines":7,"lines":[" <meta charset=\"UTF-8\" />"," <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />"," <title>Claude Code Timeline Viewer</title>","+ <script src=\"https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js\" integrity=\"sha512-Pk20VxuOhSM2Yj7vqVyL9EtCnxlRH3BszG2kN8vM/zxJNMYyH9GA0PO5P3iHl91NY9QpWBdVGdFn5ECDE9GSMQ==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>"," <style>"," :root {"," color-scheme: light dark;"]}],"userModified":false,"replaceAll":false}}
{"parentUuid":"ce0fdab1-7b95-4fc7-9624-e324455c8e61","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"logical-toasting-beacon","message":{"model":"claude-opus-4-5-20251101","id":"msg_01QVtNYz1RiLBfpKN5CgUGwW","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Now I need to use DOMPurify to sanitize the output of renderMarkdown. I'll modify the renderMarkdown function to sanitize the output before returning it.","signature":"EsICCkYIChgCKkCfHnVpDYGQ2lrMa6TD4M2PR3z+MdG7zf7JdQCspJIQygZJixIpz0su6VbhoAiBHxuA2eyifEQDCeSaRsgbmZbCEgwkms/PMiHawMu8vC8aDEfIZWzAP/n3YsLCvCIwvZL/4W+J+bCQK44Fzh8TcNNsy5y8YzSsdnm+9LysaGLNY7kVvcUr4bysdOSLtgeHKqkBSQSHmEOY0igRjLXcsoB/FPZyHTicfB3a3D4OBkO4gD5laQNG9C0k+JOK/0voGYkbYSAvWp5XG5uLOCcg/wdc29W4X45zdtY5lqCktR1+muoTkGzmY4bCoSE6EaVb8NOWhWBoLwdJHmgA0ZRTJ8vt87/9I6LsM5bn1MMFJQ9C9AjF9eYDfK46B0D0QBvKoP5zfcO9MgzgYoXLxWhSL2oHNc5TwJTQqyuClRgB"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":827,"cache_read_input_tokens":53323,"cache_creation":{"ephemeral_5m_input_tokens":827,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWCw1qWk5nwzJkgA5YnsH","type":"assistant","uuid":"41a0398a-ac3b-471e-b039-f40c4b35711a","timestamp":"2025-12-17T21:00:11.089Z"}
{"parentUuid":"41a0398a-ac3b-471e-b039-f40c4b35711a","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"logical-toasting-beacon","message":{"model":"claude-opus-4-5-20251101","id":"msg_01QVtNYz1RiLBfpKN5CgUGwW","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01NwUR9dVV53xgmPzsmrwdtG","name":"Edit","input":{"file_path":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","old_string":"function renderMarkdown(text) {\n if (typeof text !== 'string') return '';\n\n // Escape HTML first\n let html = escapeHtml(text);","new_string":"function renderMarkdown(text) {\n if (typeof text !== 'string') return '';\n\n // Escape HTML first to prevent raw HTML injection\n let html = escapeHtml(text);"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":827,"cache_read_input_tokens":53323,"cache_creation":{"ephemeral_5m_input_tokens":827,"ephemeral_1h_input_tokens":0},"output_tokens":238,"service_tier":"standard"}},"requestId":"req_011CWCw1qWk5nwzJkgA5YnsH","type":"assistant","uuid":"1553bd24-70af-4e1f-87c6-41d1bb8ce980","timestamp":"2025-12-17T21:00:13.436Z"}
{"parentUuid":"1553bd24-70af-4e1f-87c6-41d1bb8ce980","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"logical-toasting-beacon","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01NwUR9dVV53xgmPzsmrwdtG","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 1641→ div.textContent = text;\n 1642→ return div.innerHTML;\n 1643→ }\n 1644→\n 1645→ function renderMarkdown(text) {\n 1646→ if (typeof text !== 'string') return '';\n 1647→\n 1648→ // Escape HTML first to prevent raw HTML injection\n 1649→ let html = escapeHtml(text);\n 1650→\n 1651→ // Code blocks (```...```)\n 1652→ html = html.replace(/```(\\w*)\\n([\\s\\S]*?)```/g, (match, lang, code) => {\n 1653→ return `<pre><code class=\"language-${lang}\">${code.trim()}</code></pre>`;"}]},"uuid":"5d01d198-cb82-4a32-88ca-7e0e35876c3b","timestamp":"2025-12-17T21:00:13.471Z","toolUseResult":{"filePath":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","oldString":"function renderMarkdown(text) {\n if (typeof text !== 'string') return '';\n\n // Escape HTML first\n let html = escapeHtml(text);","newString":"function renderMarkdown(text) {\n if (typeof text !== 'string') return '';\n\n // Escape HTML first to prevent raw HTML injection\n let html = escapeHtml(text);","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=\"sha512-Pk20VxuOhSM2Yj7vqVyL9EtCnxlRH3BszG2kN8vM/zxJNMYyH9GA0PO5P3iHl91NY9QpWBdVGdFn5ECDE9GSMQ==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></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 .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 function renderMarkdown(text) {\n if (typeof text !== 'string') return '';\n\n // Escape HTML first\n let html = escapeHtml(text);\n\n // Code blocks (```...```)\n html = html.replace(/```(\\w*)\\n([\\s\\S]*?)```/g, (match, lang, code) => {\n return `<pre><code class=\"language-${lang}\">${code.trim()}</code></pre>`;\n });\n\n // Inline code (`...`)\n html = html.replace(/`([^`]+)`/g, '<code>$1</code>');\n\n // Headers\n html = html.replace(/^######\\s+(.+)$/gm, '<h6>$1</h6>');\n html = html.replace(/^#####\\s+(.+)$/gm, '<h5>$1</h5>');\n html = html.replace(/^####\\s+(.+)$/gm, '<h4>$1</h4>');\n html = html.replace(/^###\\s+(.+)$/gm, '<h3>$1</h3>');\n html = html.replace(/^##\\s+(.+)$/gm, '<h2>$1</h2>');\n html = html.replace(/^#\\s+(.+)$/gm, '<h1>$1</h1>');\n\n // Bold and italic\n html = html.replace(/\\*\\*\\*([^*]+)\\*\\*\\*/g, '<strong><em>$1</em></strong>');\n html = html.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');\n html = html.replace(/\\*([^*]+)\\*/g, '<em>$1</em>');\n html = html.replace(/___([^_]+)___/g, '<strong><em>$1</em></strong>');\n html = html.replace(/__([^_]+)__/g, '<strong>$1</strong>');\n html = html.replace(/_([^_]+)_/g, '<em>$1</em>');\n\n // Links\n html = html.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '<a href=\"$2\" target=\"_blank\" rel=\"noopener\">$1</a>');\n\n // Blockquotes\n html = html.replace(/^&gt;\\s+(.+)$/gm, '<blockquote>$1</blockquote>');\n\n // Unordered lists\n html = html.replace(/^[\\*\\-]\\s+(.+)$/gm, '<li>$1</li>');\n html = html.replace(/(<li>.*<\\/li>\\n?)+/g, '<ul>$&</ul>');\n\n // Ordered lists\n html = html.replace(/^\\d+\\.\\s+(.+)$/gm, '<li>$1</li>');\n\n // Line breaks - convert double newlines to paragraphs\n html = html.split(/\\n\\n+/).map(para => {\n para = para.trim();\n if (!para) return '';\n // Don't wrap if already wrapped in block element\n if (/^<(h[1-6]|ul|ol|li|pre|blockquote|div)/.test(para)) return para;\n return `<p>${para.replace(/\\n/g, '<br>')}</p>`;\n }).join('\\n');\n\n return 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 = 'result-block' + (item.is_error ? ' result-error' : '');\n block.textContent = resultText.length > 5000 ? resultText.slice(0, 5000) + '\\n... [truncated]' : resultText;\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":1645,"oldLines":7,"newStart":1645,"newLines":7,"lines":[" function renderMarkdown(text) {"," if (typeof text !== 'string') return '';"," ","- // Escape HTML first","+ // Escape HTML first to prevent raw HTML injection"," let html = escapeHtml(text);"," "," // Code blocks (```...```)"]}],"userModified":false,"replaceAll":false}}
{"parentUuid":"5d01d198-cb82-4a32-88ca-7e0e35876c3b","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"logical-toasting-beacon","message":{"model":"claude-opus-4-5-20251101","id":"msg_01KAgNknadWz1NwWac1iSGpD","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Now I need to add DOMPurify sanitization at the end of the renderMarkdown function. Let me find the end of that function.","signature":"EqICCkYIChgCKkAp8Eanec+iy5PgP6KZHDcpbbXs4CvfB1fSr9tvF3AxlJFzCOaMtaM3Vflm+3uqHm4DZtK0t0fM3F1SFPvHA2AZEgyiImx4WetIakA45NgaDOAw7YFrniiyRVGFDSIw7MAfkCg/wnIO/WCTrNKD+liD/pehnwwyoGhR7swZ/5MQpfwYyMd81c3LEz0qlPqKKokBaIApgjl7L2deSFc/cmz+lpWcnc3uXVW6gEA0oBRhEj+KSz6JwP+6qZF/sQMI15pUeZcuxtGSBNf7xoGKCoVjviNJCkuypq5yRMjKcSaj6yBOnhyyHS19t66kwAxA3RW1uXnde7KczdvDy7IdRX39ut1cKZBRRe+pxtiLZxVoBvNxa+S7f2aYH/oYAQ=="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":492,"cache_read_input_tokens":54150,"cache_creation":{"ephemeral_5m_input_tokens":492,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard"}},"requestId":"req_011CWCw2DU6ZSwUqtZxn3gu8","type":"assistant","uuid":"562d52b7-9c82-436e-9377-b4db5f2f6167","timestamp":"2025-12-17T21:00:16.233Z"}
{"parentUuid":"562d52b7-9c82-436e-9377-b4db5f2f6167","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"logical-toasting-beacon","message":{"model":"claude-opus-4-5-20251101","id":"msg_01KAgNknadWz1NwWac1iSGpD","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Uxjcp2i64XadRgSwj3qZX4","name":"Edit","input":{"file_path":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","old_string":"// Line breaks - convert double newlines to paragraphs\n html = html.split(/\\n\\n+/).map(para => {\n para = para.trim();\n if (!para) return '';\n // Don't wrap if already wrapped in block element\n if (/^<(h[1-6]|ul|ol|li|pre|blockquote|div)/.test(para)) return para;\n return `<p>${para.replace(/\\n/g, '<br>')}</p>`;\n }).join('\\n');\n\n return html;\n }","new_string":"// Line breaks - convert double newlines to paragraphs\n html = html.split(/\\n\\n+/).map(para => {\n para = para.trim();\n if (!para) return '';\n // Don't wrap if already wrapped in block element\n if (/^<(h[1-6]|ul|ol|li|pre|blockquote|div)/.test(para)) return para;\n return `<p>${para.replace(/\\n/g, '<br>')}</p>`;\n }).join('\\n');\n\n // Sanitize with DOMPurify to prevent XSS from crafted markdown\n // (e.g., javascript: URLs in links, attribute injection via quotes)\n return DOMPurify.sanitize(html);\n }"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":492,"cache_read_input_tokens":54150,"cache_creation":{"ephemeral_5m_input_tokens":492,"ephemeral_1h_input_tokens":0},"output_tokens":464,"service_tier":"standard"}},"requestId":"req_011CWCw2DU6ZSwUqtZxn3gu8","type":"assistant","uuid":"57e154cc-e29e-4d3a-981f-a40eb39aad7e","timestamp":"2025-12-17T21:00:22.272Z"}
{"parentUuid":"57e154cc-e29e-4d3a-981f-a40eb39aad7e","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"logical-toasting-beacon","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Uxjcp2i64XadRgSwj3qZX4","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 1684→\n 1685→ // Ordered lists\n 1686→ html = html.replace(/^\\d+\\.\\s+(.+)$/gm, '<li>$1</li>');\n 1687→\n 1688→ // Line breaks - convert double newlines to paragraphs\n 1689→ html = html.split(/\\n\\n+/).map(para => {\n 1690→ para = para.trim();\n 1691→ if (!para) return '';\n 1692→ // Don't wrap if already wrapped in block element\n 1693→ if (/^<(h[1-6]|ul|ol|li|pre|blockquote|div)/.test(para)) return para;\n 1694→ return `<p>${para.replace(/\\n/g, '<br>')}</p>`;\n 1695→ }).join('\\n');\n 1696→\n 1697→ // Sanitize with DOMPurify to prevent XSS from crafted markdown\n 1698→ // (e.g., javascript: URLs in links, attribute injection via quotes)\n 1699→ return DOMPurify.sanitize(html);\n 1700→ }\n 1701→\n 1702→ function renderPrettyView(obj) {\n 1703→ els.detailPretty.innerHTML = '';\n 1704→"}]},"uuid":"2c670c79-1e6f-4d7d-a68c-82c99f30c98a","timestamp":"2025-12-17T21:00:22.302Z","toolUseResult":{"filePath":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","oldString":"// Line breaks - convert double newlines to paragraphs\n html = html.split(/\\n\\n+/).map(para => {\n para = para.trim();\n if (!para) return '';\n // Don't wrap if already wrapped in block element\n if (/^<(h[1-6]|ul|ol|li|pre|blockquote|div)/.test(para)) return para;\n return `<p>${para.replace(/\\n/g, '<br>')}</p>`;\n }).join('\\n');\n\n return html;\n }","newString":"// Line breaks - convert double newlines to paragraphs\n html = html.split(/\\n\\n+/).map(para => {\n para = para.trim();\n if (!para) return '';\n // Don't wrap if already wrapped in block element\n if (/^<(h[1-6]|ul|ol|li|pre|blockquote|div)/.test(para)) return para;\n return `<p>${para.replace(/\\n/g, '<br>')}</p>`;\n }).join('\\n');\n\n // Sanitize with DOMPurify to prevent XSS from crafted markdown\n // (e.g., javascript: URLs in links, attribute injection via quotes)\n return DOMPurify.sanitize(html);\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=\"sha512-Pk20VxuOhSM2Yj7vqVyL9EtCnxlRH3BszG2kN8vM/zxJNMYyH9GA0PO5P3iHl91NY9QpWBdVGdFn5ECDE9GSMQ==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></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 .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 function renderMarkdown(text) {\n if (typeof text !== 'string') return '';\n\n // Escape HTML first to prevent raw HTML injection\n let html = escapeHtml(text);\n\n // Code blocks (```...```)\n html = html.replace(/```(\\w*)\\n([\\s\\S]*?)```/g, (match, lang, code) => {\n return `<pre><code class=\"language-${lang}\">${code.trim()}</code></pre>`;\n });\n\n // Inline code (`...`)\n html = html.replace(/`([^`]+)`/g, '<code>$1</code>');\n\n // Headers\n html = html.replace(/^######\\s+(.+)$/gm, '<h6>$1</h6>');\n html = html.replace(/^#####\\s+(.+)$/gm, '<h5>$1</h5>');\n html = html.replace(/^####\\s+(.+)$/gm, '<h4>$1</h4>');\n html = html.replace(/^###\\s+(.+)$/gm, '<h3>$1</h3>');\n html = html.replace(/^##\\s+(.+)$/gm, '<h2>$1</h2>');\n html = html.replace(/^#\\s+(.+)$/gm, '<h1>$1</h1>');\n\n // Bold and italic\n html = html.replace(/\\*\\*\\*([^*]+)\\*\\*\\*/g, '<strong><em>$1</em></strong>');\n html = html.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');\n html = html.replace(/\\*([^*]+)\\*/g, '<em>$1</em>');\n html = html.replace(/___([^_]+)___/g, '<strong><em>$1</em></strong>');\n html = html.replace(/__([^_]+)__/g, '<strong>$1</strong>');\n html = html.replace(/_([^_]+)_/g, '<em>$1</em>');\n\n // Links\n html = html.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '<a href=\"$2\" target=\"_blank\" rel=\"noopener\">$1</a>');\n\n // Blockquotes\n html = html.replace(/^&gt;\\s+(.+)$/gm, '<blockquote>$1</blockquote>');\n\n // Unordered lists\n html = html.replace(/^[\\*\\-]\\s+(.+)$/gm, '<li>$1</li>');\n html = html.replace(/(<li>.*<\\/li>\\n?)+/g, '<ul>$&</ul>');\n\n // Ordered lists\n html = html.replace(/^\\d+\\.\\s+(.+)$/gm, '<li>$1</li>');\n\n // Line breaks - convert double newlines to paragraphs\n html = html.split(/\\n\\n+/).map(para => {\n para = para.trim();\n if (!para) return '';\n // Don't wrap if already wrapped in block element\n if (/^<(h[1-6]|ul|ol|li|pre|blockquote|div)/.test(para)) return para;\n return `<p>${para.replace(/\\n/g, '<br>')}</p>`;\n }).join('\\n');\n\n return 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 = 'result-block' + (item.is_error ? ' result-error' : '');\n block.textContent = resultText.length > 5000 ? resultText.slice(0, 5000) + '\\n... [truncated]' : resultText;\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":1694,"oldLines":7,"newStart":1694,"newLines":9,"lines":[" return `<p>${para.replace(/\\n/g, '<br>')}</p>`;"," }).join('\\n');"," ","- return html;","+ // Sanitize with DOMPurify to prevent XSS from crafted markdown","+ // (e.g., javascript: URLs in links, attribute injection via quotes)","+ return DOMPurify.sanitize(html);"," }"," "," function renderPrettyView(obj) {"]}],"userModified":false,"replaceAll":false}}
{"parentUuid":"2c670c79-1e6f-4d7d-a68c-82c99f30c98a","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"logical-toasting-beacon","message":{"model":"claude-opus-4-5-20251101","id":"msg_017SjhpUi7N5AHi9GiqXxbMJ","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Good, I've added DOMPurify to sanitize the output of renderMarkdown. DOMPurify will:\n1. Remove `javascript:`, `data:`, and other dangerous URL schemes from href attributes\n2. Remove event handlers like `onclick` that might be injected via attribute breakout\n3. Generally sanitize the HTML to prevent XSS\n\nThe fix is now complete. DOMPurify is a well-tested library that handles all the edge cases for HTML sanitization.","signature":"EskECkYIChgCKkCFroGQBaW+kmKRVVeY+A8PUZh3M9xKOc/91C0uQ6m49QoM72T53qYvIQJJYowJCTSmnn8yi94gD5mqaa+VZxliEgwRhnw9jt4udcSdq18aDAjlZFpgc7cXkwwqdiIw2rbW0urqghnyVSTqXFKQ3L0+Lsh4gv6ZRRT55yG6A8OW3WYL+GODqo8BlYfvfTE1KrADrrGY1A1yJXYFxoCLgp2R6o4GxCDneZy+19LzmvpVgDv9Dona5TY4+B7xqlG5DYAx16dksdl8DcDnylW91I+q7znxopuYhlzoRxI3OOBpiX92Xwwn5MU0s+rYWi0lEBmr7jeC2qahjkLeswF40yWnokuIq0aTiJnhkVOihjTmzoWwZfgZ7PfGoE67rCGOvt8oWutXp5e85nPY7xQBYsjILI1q66zAxCo53YBJFLSgPPm3nsJ1yWJp9iVyoR+j3OnmOYsALmPRf8M0YCQ+8Cvjw+eFzpcQVro4ke9uSRP61vQuoF4afFC9D0pajk9Nk7SipKNZYGi4FvQZ4EbwL7CXmdN6CqKAlv/xaO6eZjLDP16Hr8Me9uUgD2z5nP9ax0Rt+AZGk5hwKvtSbrhCdbJA6A9P1pGambJxOkDhqxBVqa0JGik4z0UUEbHrhNIPgSB8w8cNVwgJ+giPMhtHg7fGkXAz4hTE5HlDYAatXQNAc6oFSc+hVFQr585qsjwIfHwpobo1G1EOq2ADS0J0NwEE4fVO2xK8U09Z2QiWFrQ2zTx+AdpR1lv9A6H7A5JtQO/3GAE="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":864,"cache_read_input_tokens":54642,"cache_creation":{"ephemeral_5m_input_tokens":864,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWCw2sDi3vCYdJ5QQgKeY","type":"assistant","uuid":"ed98342b-c81e-4616-8258-8f151a77ba21","timestamp":"2025-12-17T21:00:26.717Z"}
{"parentUuid":"ed98342b-c81e-4616-8258-8f151a77ba21","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"logical-toasting-beacon","message":{"model":"claude-opus-4-5-20251101","id":"msg_017SjhpUi7N5AHi9GiqXxbMJ","type":"message","role":"assistant","content":[{"type":"text","text":"Done. The fix adds DOMPurify (v3.2.3 with SRI hash for integrity) and sanitizes the markdown output. DOMPurify will:\n\n1. **Strip `javascript:` URLs** - links like `[click](javascript:alert(1))` become harmless\n2. **Block attribute injection** - `[test](foo\" onclick=\"alert(1))` has the injected handler removed\n3. **Remove dangerous protocols** - `data:`, `vbscript:`, etc. are also blocked\n\nThis is more robust than manual fixes since DOMPurify handles edge cases like:\n- URL encoding tricks (`java&#115;cript:`)\n- Mixed case (`JaVaScRiPt:`)\n- Various browser quirks"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":864,"cache_read_input_tokens":54642,"cache_creation":{"ephemeral_5m_input_tokens":864,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWCw2sDi3vCYdJ5QQgKeY","type":"assistant","uuid":"cdf98c22-8624-4572-8c47-45ca9dbc52a9","timestamp":"2025-12-17T21:00:31.416Z"}
{"type":"file-history-snapshot","messageId":"8c5c2e98-423c-47c1-9822-e5a9d052587c","snapshot":{"messageId":"8c5c2e98-423c-47c1-9822-e5a9d052587c","trackedFileBackups":{"claude-code-timeline.html":{"backupFileName":"cc3a5887c9685f80@v2","version":2,"backupTime":"2025-12-17T21:04:42.045Z"}},"timestamp":"2025-12-17T21:04:42.035Z"},"isSnapshotUpdate":false}
{"parentUuid":"cdf98c22-8624-4572-8c47-45ca9dbc52a9","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","type":"user","message":{"role":"user","content":"Colud that have entirely disabled markdown rendering? How did markdown rendering work in the firest place?"},"uuid":"8c5c2e98-423c-47c1-9822-e5a9d052587c","timestamp":"2025-12-17T21:04:42.031Z","thinkingMetadata":{"level":"high","disabled":false,"triggers":[]}}
{"parentUuid":"8c5c2e98-423c-47c1-9822-e5a9d052587c","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","message":{"model":"claude-opus-4-5-20251101","id":"msg_015LghcJyDU9r6MrsfjLMMXq","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"The user is asking two things:\n1. Could the DOMPurify sanitization have entirely disabled markdown rendering?\n2. How did markdown rendering work in the first place?\n\nLet me think about this. The `renderMarkdown` function:\n1. First escapes HTML with `escapeHtml(text)` - this converts `<` to `&lt;`, `>` to `&gt;`, `&` to `&amp;`\n2. Then applies regex replacements to convert markdown syntax to HTML tags\n3. Finally sanitizes with DOMPurify\n\nThe issue is that after step 1, all the HTML special characters are escaped. Then in step 2, the regex replacements create NEW HTML tags (like `<pre>`, `<code>`, `<strong>`, `<a>`, etc.). These new tags are legitimate and should be allowed.\n\nDOMPurify should NOT strip these legitimate tags - it's designed to allow safe HTML tags like `<p>`, `<strong>`, `<em>`, `<a>` (with safe hrefs), `<code>`, `<pre>`, `<h1>`-`<h6>`, `<ul>`, `<ol>`, `<li>`, `<blockquote>`, `<br>`.\n\nSo the markdown rendering should still work fine. DOMPurify will:\n- Keep the legitimate HTML tags created by the markdown regex\n- Strip dangerous attributes (like `onclick`)\n- Sanitize dangerous href values (like `javascript:`)\n\nThe markdown rendering should work the same as before, just safer. Let me explain this to the user.\n\nActually, wait - I should double-check. The default DOMPurify configuration allows most common HTML tags. Let me verify what tags the markdown renderer creates:\n- `<pre>`, `<code>` - allowed\n- `<h1>` through `<h6>` - allowed\n- `<strong>`, `<em>` - allowed\n- `<a>` with href - allowed (but javascript: URLs stripped)\n\n\n- `<blockquote>` - allowed\n- `<ul>`, `<ol>`, `<li>` - allowed\n- `<p>`, `<br>` - allowed\n\nI'll confirm that all markdown-generated tags are standard and safe. DOMPurify's default settings preserve these tags while blocking potentially malicious content. The sanitization process protects against harmful scripts without disrupting the core markdown rendering functionality.\n\nThe key protection mechanisms involve:\n1. Preserving legitimate structural HTML tags\n2. Removing dangerous URL schemes\n3. Stripping potentially harmful attributes\n\nMarkdown rendering remains intact, with added security layers preventing potential cross-site scripting vulnerabilities.","signature":"Eq4RCkYIChgCKkCKnAvei39iXQzpMfvKaU8OYefMQ3AzZsOxszzInYD11WprCEIUjK+cgrT7TDvg6S1/6l4TEzP5rRcvPXyo3+yyEgzctrUmmxrRTyXhlxsaDBu6cv+mC1dTGU1ibSIwc2lNL1TvNQO7lEK6zFzXwzr9a/dXG/ss3OlxiI7eW7iawzRF61qGv3Qacn0ifuMpKpUQGwY6dtQ1XvI5UGJpd63Z2xdnlsuT33xc7VJIzlrqa0Y3oIOu1j0YQOpMdxC+ZNrIgraX8OxwmPtoMX9TZN22AZwurDuR3vll6558/yp1xzM9/hubUCHXWRI2dEXNxdA1R5ygUMj95u7SGBL+2ALzivo4ZaeSjvKvfZ6EQl32zIc4plzg+UncsIHYYamfksX/fZ3zvleE46pxep4YkHP1mnU4w9BShqbWxLYPTKJ+AVvltQDfFtEpvhH3Zobn3YT8TAmskKm+cZJ0g95n1YK5Inn9+d3/BP5eBytYX5YFlLnVrhQDwQ4iI4d8hiSLoTZAl8MGProeS0tKhUiwqfx7OWHw/rn88KEO+0v0GVvSfO9It1d0ZbW6oIDx53/NEsTiaAYbpsTMwgH8clGk9D+DbiVLdBwjhXWoycLbfr6FM7IBr99LGpYDSwMFaqLThq14WA+GebMw8QtTLmP96mX8XkTPEKmrvQiEUyGNPQ1oxkFXAv8EfVFCZRZfXP0fWzxpmFEhdcJ5XIf9QoCinUD8z3PsLJdnVR2rphQBJia15qOcVg2Ox0gS+L0LeQF4QMc9hZpQ7j5gOp/Vo03VVsl0TIZv/8U5DR97HGswMzWQVErcCm2XdgphR09nKnWG8U2vkLal+U15gJGs1koUBMyiNE7GGvdHVJJvACPc2ShUs9uPARrPG+C6DDpIH/KitDXGvKYkD8IDSR40E5KzTjr+NWgtF0Qb4BDKfGaHaNJYsV0Huo57FRPXAYM7aYie83ZlnWCrhwnvZiVpLlVEXo/vL0C2XfqmNbXcJy51CAWIYNyOwPO/OmzSYv/PkFhzE3DLhwZTnxjyroOtEtABAJLPBHurigitYQMSGWlu05JDuMVjAI/6tC+S1fcGXs2ltJu4sab+PVlApLNB5niFjkEFWjgy+wasK8HfAGhVZ7fvGurc7s8ybsjHLdQhG/ls5qjPu002d6OHFRHxet6eAwXnnjeSpb+23NY3E5rS3nrr4i56nev+rXJNXlNGqMzJa/bpPPoF5IKPevxCQNvG5CGHlF/8eIoflbPUV1ksZa6PYOoMIoRgbkh8G67nIMjXLrFUnO99HYqZ7fBAJM0BQO5mLcroe8g1R5i6JnyTdhUalI/DcPrsHybTTioRomNLPJz/x2FKAeF/1GWgeXUF4FhlETZMQQgG1wzkdziKjSpDB76Tbarghx5WXM5ZmVxJYcA1lQNbgRFF9DP7F5eoX24xLE4vSdnlCkyn2T5J8+fDw6CDNbqcY9fdxnNRFhZA+xE3fREZl7Z22NR+EKMRdJ9BIVMd0j9+LSDGGZ0huvDHVlsZD+dSvLBK8n4zK2y4CsRsGm7gTeaJZlGodBqGQ70KAR3qyIdUFwiaGhSQJGsWJ/R1dmYTLAb2JtQO4BTf7yi3uIVhGV+9fSGnz4BEwORg7omZzOKl3vl+z6LOtedKhL8qkppWhaKyYwO1zNBy6mHeRd+27YJkA4f2Z10TKpxUDmyIRwDl5BTngwh/TDmO7PmULf//mWjOtD+ik9kfsYEFy4MIR+wFtQFKQmCM8x7323p2y4TLNeQGVJhRhCzAIYsefGzenV3vo3pJ/SqDu5uq1oDrrGH+CI5Khie629sfY8raV/5l+nVXcEuW3prHbz8gRjcrvyprypzdWL/kDid7uglol8duPOZ7CgXxidL2I59fbDMWCOJtXZ1nl80DEssAUyB3EHcF5bPCOekYTXutoxCQxKhLXDZgpF+CI+PkVmIcFcVoUSeo1ki8c8CYVv4JA3jM6ENRhJmHE8YuLzSD7jV0i/6rzJD9aPSGMi8i0nvIkzFRTyj/Q6CzugoPkSgUhbaagHfBi8p10pA/XhAt5ZFtIBVmOejQWebzMkgmstRA+Bnxb7Mhy+gvrsUG7xRqYJ9eDOUmDK8T9ECnKSCUrGQKOHhg1Ku0bODrOO7dOoyfcsKzds93X7gvtKtT1JbLcILFcDgSuJx3Sa+oi05cdzcgNxDMO9VpY1V/1NsAV8XZSMmWdX2y0UZ5mBtX59Gq+IyDvXJBhmXP8BuspqRcinESGdfiEMErpRl+mrljkAFljZVVB0/tDsE0opaHblukHMT9n6mfL0XU8E83P5pWM5tf6TscyQoODk4JzjwDGjyRWXIitxxRal3eAxpIT2SzGi3GhCNvrmatFKiR9MqzsAzSQgIynvpmhs9lxZlgI7E/lKHO8dIWCe/JYXgsdwZKXkR80WOzq6iHsUCo+3MD69STJave5kgeeB/RX2ti418V7MtSOBUSIHeC48gJ6M0mJUbjTqgVU0DLKw3L2wzjNnpIX+AhF5pYI2jYkN5JLaZeBj5+W3ZrCZc8BN+OuW+hzCiNbfw+Ex0wLoh9xT1/TNDY22Sde7jT8VZPwF6dqrpeAowABGbd2WOaI596fQ21MCDZkk1+pPWAQHiF9dxqCEu4aM0BWHABPJ1QrjMVuKhwzwH9FH9dQzPX/xBjkSfBcFQtP3uDEXd0+yu/MvgKuA5aN2Lqifa6rsNylP8sJe+wFbP4OtJhvY8+FY3D9mhVb+KMhSVpA9WdKcCc/64ONuZJxIW8tySmixgMqN1qS1+a0j8tKGRlKqqNY20UD1KMl+29OS71SeJwrn55KL0Gl9/yHvwJ6czYg/CPwLaPloe6An/7SfdgUN2l++ZxpEPR2ow9kAxWB8GBxcMyoBXkLnsDOEXoqFgohZ/xk+UZBxrJr+TN9+nrhxmuRMsao6iEvSnK9w5wI9anJx1SevqK7EpCvOQYAQ=="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":42096,"cache_read_input_tokens":14916,"cache_creation":{"ephemeral_5m_input_tokens":42096,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard"}},"requestId":"req_011CWCwN2DUyk688GcjAx1WS","type":"assistant","uuid":"5dbaa372-8079-4e9b-9a81-7b8bbaeb6475","timestamp":"2025-12-17T21:05:02.324Z"}
{"parentUuid":"5dbaa372-8079-4e9b-9a81-7b8bbaeb6475","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","message":{"model":"claude-opus-4-5-20251101","id":"msg_015LghcJyDU9r6MrsfjLMMXq","type":"message","role":"assistant","content":[{"type":"text","text":"Good question. Let me trace through how it works:\n\n**The rendering flow:**\n1. `escapeHtml(text)` converts `<` → `&lt;`, `>` → `&gt;`, `&` → `&amp;`\n2. Regex replacements convert markdown syntax into HTML tags (e.g., `**bold**` → `<strong>bold</strong>`)\n3. `DOMPurify.sanitize(html)` cleans the output\n\n**DOMPurify won't break markdown** because it allows standard HTML tags by default:\n- `<pre>`, `<code>` ✓\n- `<h1>`-`<h6>` ✓\n- `<strong>`, `<em>` ✓\n- `<a href=\"...\">` ✓ (but strips `javascript:` URLs)\n- `<blockquote>`, `<ul>`, `<ol>`, `<li>` ✓\n- `<p>`, `<br>` ✓\n\n**What DOMPurify blocks:**\n- `javascript:` and `data:` URLs in hrefs → stripped/removed\n- Injected event handlers like `onclick` → removed\n- Other dangerous attributes\n\nSo markdown like `**bold** and [link](https://example.com)` still renders correctly. Only malicious payloads like `[click](javascript:alert(1))` get neutered - the link either becomes plain text or has its href stripped.\n\nYou can test it by loading a JSONL with markdown content - it should render the same as before, just without the XSS holes."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":42096,"cache_read_input_tokens":14916,"cache_creation":{"ephemeral_5m_input_tokens":42096,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard"}},"requestId":"req_011CWCwN2DUyk688GcjAx1WS","type":"assistant","uuid":"3e85761f-9227-479b-b7cf-742748d106e3","timestamp":"2025-12-17T21:05:02.697Z"}
{"type":"file-history-snapshot","messageId":"6c3647db-989e-4f3b-8a7c-8055cf024670","snapshot":{"messageId":"6c3647db-989e-4f3b-8a7c-8055cf024670","trackedFileBackups":{"claude-code-timeline.html":{"backupFileName":"cc3a5887c9685f80@v2","version":2,"backupTime":"2025-12-17T21:04:42.045Z"}},"timestamp":"2025-12-17T21:07:01.600Z"},"isSnapshotUpdate":false}
{"parentUuid":"3e85761f-9227-479b-b7cf-742748d106e3","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","type":"user","message":{"role":"user","content":"Use this: <script src=\"https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js\" integrity=\"sha384-osZDKVu4ipZP703HmPOhWdyBajcFyjX2Psjk//TG1Rc0AdwEtuToaylrmcK3LdAl\" crossorigin=\"anonymous\"></script>"},"uuid":"6c3647db-989e-4f3b-8a7c-8055cf024670","timestamp":"2025-12-17T21:07:01.598Z","thinkingMetadata":{"level":"high","disabled":false,"triggers":[]}}
{"parentUuid":"6c3647db-989e-4f3b-8a7c-8055cf024670","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","message":{"model":"claude-opus-4-5-20251101","id":"msg_01JfJR4Wc7yTNfTZGhwumH7M","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to use a different integrity hash for the DOMPurify script tag. They're providing an sha384 hash instead of the sha512 hash I used. Let me update that.","signature":"EtICCkYIChgCKkAWx8WH36JYBwa2TSKmwe/eHYzGW7SAV/jO+bbadagoOdV4Vni3wHJl4lFYsbCduNKomZW6axHwUvcnzt33Tuk+EgxJrY+2CjYx9JsE7V8aDFd/HRrYZmFYZsa3eyIw6I0O+Li6gTCIWwPQkqnV594YPUTrARnnwx1XqnXonFh85byu+3w3Cb4ZdYahlfNQKrkBGtNF26axb2tn0tT9yuKpOKuXbHLA3ahGblFXg8CTCpqCJ8DCFeItH3cVPmNMuap86BMTnBSB9qYruwsBvbrCmeCM0L3i3DmohvsFbZmb9kPcvV0D0VG9JtHmlLNPtpArufg5soYwCl0mJWxjlUfJU1SB5HDRi8Cy+0M3aKIQXioo2spVJJ8O3l0/Ish98FUs+3HeKs2Tvt88D3nWIYSxGdpl0uPhXt36eE2aduRYbRdSpLT2/a/L5nsYAQ=="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":2384,"cache_read_input_tokens":55731,"cache_creation":{"ephemeral_5m_input_tokens":2384,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard"}},"requestId":"req_011CWCwYJWJ8gPCGS3G7GPy4","type":"assistant","uuid":"92425d55-66b1-4fbb-a59f-6ca706fcc470","timestamp":"2025-12-17T21:07:05.334Z"}
{"parentUuid":"92425d55-66b1-4fbb-a59f-6ca706fcc470","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","message":{"model":"claude-opus-4-5-20251101","id":"msg_01JfJR4Wc7yTNfTZGhwumH7M","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01S1Ch2nbz3yQLjqxUdY9fPV","name":"Edit","input":{"file_path":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","old_string":"<script src=\"https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js\" integrity=\"sha512-Pk20VxuOhSM2Yj7vqVyL9EtCnxlRH3BszG2kN8vM/zxJNMYyH9GA0PO5P3iHl91NY9QpWBdVGdFn5ECDE9GSMQ==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>","new_string":"<script src=\"https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js\" integrity=\"sha384-osZDKVu4ipZP703HmPOhWdyBajcFyjX2Psjk//TG1Rc0AdwEtuToaylrmcK3LdAl\" crossorigin=\"anonymous\">"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":2384,"cache_read_input_tokens":55731,"cache_creation":{"ephemeral_5m_input_tokens":2384,"ephemeral_1h_input_tokens":0},"output_tokens":398,"service_tier":"standard"}},"requestId":"req_011CWCwYJWJ8gPCGS3G7GPy4","type":"assistant","uuid":"dc3f4ba2-40b5-4ca6-8e05-2fdfeb4f7cae","timestamp":"2025-12-17T21:07:07.803Z"}
{"parentUuid":"dc3f4ba2-40b5-4ca6-8e05-2fdfeb4f7cae","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"memoized-nibbling-sunset","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01S1Ch2nbz3yQLjqxUdY9fPV","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 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\">\n 8→ <style>\n 9→ :root {\n 10→ color-scheme: light dark;\n 11→ --bg: #0b0c0f;"}]},"uuid":"108debe4-0cff-465b-b708-f37251186a93","timestamp":"2025-12-17T21:07:13.640Z","toolUseResult":{"filePath":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","oldString":"<script src=\"https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js\" integrity=\"sha512-Pk20VxuOhSM2Yj7vqVyL9EtCnxlRH3BszG2kN8vM/zxJNMYyH9GA0PO5P3iHl91NY9QpWBdVGdFn5ECDE9GSMQ==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>","newString":"<script src=\"https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js\" integrity=\"sha384-osZDKVu4ipZP703HmPOhWdyBajcFyjX2Psjk//TG1Rc0AdwEtuToaylrmcK3LdAl\" crossorigin=\"anonymous\">","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=\"sha512-Pk20VxuOhSM2Yj7vqVyL9EtCnxlRH3BszG2kN8vM/zxJNMYyH9GA0PO5P3iHl91NY9QpWBdVGdFn5ECDE9GSMQ==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></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 .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 function renderMarkdown(text) {\n if (typeof text !== 'string') return '';\n\n // Escape HTML first to prevent raw HTML injection\n let html = escapeHtml(text);\n\n // Code blocks (```...```)\n html = html.replace(/```(\\w*)\\n([\\s\\S]*?)```/g, (match, lang, code) => {\n return `<pre><code class=\"language-${lang}\">${code.trim()}</code></pre>`;\n });\n\n // Inline code (`...`)\n html = html.replace(/`([^`]+)`/g, '<code>$1</code>');\n\n // Headers\n html = html.replace(/^######\\s+(.+)$/gm, '<h6>$1</h6>');\n html = html.replace(/^#####\\s+(.+)$/gm, '<h5>$1</h5>');\n html = html.replace(/^####\\s+(.+)$/gm, '<h4>$1</h4>');\n html = html.replace(/^###\\s+(.+)$/gm, '<h3>$1</h3>');\n html = html.replace(/^##\\s+(.+)$/gm, '<h2>$1</h2>');\n html = html.replace(/^#\\s+(.+)$/gm, '<h1>$1</h1>');\n\n // Bold and italic\n html = html.replace(/\\*\\*\\*([^*]+)\\*\\*\\*/g, '<strong><em>$1</em></strong>');\n html = html.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');\n html = html.replace(/\\*([^*]+)\\*/g, '<em>$1</em>');\n html = html.replace(/___([^_]+)___/g, '<strong><em>$1</em></strong>');\n html = html.replace(/__([^_]+)__/g, '<strong>$1</strong>');\n html = html.replace(/_([^_]+)_/g, '<em>$1</em>');\n\n // Links\n html = html.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '<a href=\"$2\" target=\"_blank\" rel=\"noopener\">$1</a>');\n\n // Blockquotes\n html = html.replace(/^&gt;\\s+(.+)$/gm, '<blockquote>$1</blockquote>');\n\n // Unordered lists\n html = html.replace(/^[\\*\\-]\\s+(.+)$/gm, '<li>$1</li>');\n html = html.replace(/(<li>.*<\\/li>\\n?)+/g, '<ul>$&</ul>');\n\n // Ordered lists\n html = html.replace(/^\\d+\\.\\s+(.+)$/gm, '<li>$1</li>');\n\n // Line breaks - convert double newlines to paragraphs\n html = html.split(/\\n\\n+/).map(para => {\n para = para.trim();\n if (!para) return '';\n // Don't wrap if already wrapped in block element\n if (/^<(h[1-6]|ul|ol|li|pre|blockquote|div)/.test(para)) return para;\n return `<p>${para.replace(/\\n/g, '<br>')}</p>`;\n }).join('\\n');\n\n // Sanitize with DOMPurify to prevent XSS from crafted markdown\n // (e.g., javascript: URLs in links, attribute injection via quotes)\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 = 'result-block' + (item.is_error ? ' result-error' : '');\n block.textContent = resultText.length > 5000 ? resultText.slice(0, 5000) + '\\n... [truncated]' : resultText;\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":4,"oldLines":7,"newStart":4,"newLines":7,"lines":[" <meta charset=\"UTF-8\" />"," <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />"," <title>Claude Code Timeline Viewer</title>","- <script src=\"https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js\" integrity=\"sha512-Pk20VxuOhSM2Yj7vqVyL9EtCnxlRH3BszG2kN8vM/zxJNMYyH9GA0PO5P3iHl91NY9QpWBdVGdFn5ECDE9GSMQ==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>","+ <script src=\"https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js\" integrity=\"sha384-osZDKVu4ipZP703HmPOhWdyBajcFyjX2Psjk//TG1Rc0AdwEtuToaylrmcK3LdAl\" crossorigin=\"anonymous\">"," <style>"," :root {"," color-scheme: light dark;"]}],"userModified":false,"replaceAll":false}}
{"parentUuid":"108debe4-0cff-465b-b708-f37251186a93","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"memoized-nibbling-sunset","message":{"model":"claude-opus-4-5-20251101","id":"msg_0132372CnZZb2djGFr3JCBVm","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"I notice the script tag is now missing its closing `</script>` tag. The edit removed `referrerpolicy=\"no-referrer\"></script>` and replaced it with just `crossorigin=\"anonymous\">` without the closing tag. I need to fix that.","signature":"EogDCkYIChgCKkB6WvopAcCSi7IJ4y/wUkB31e0sXPiCqj/OUW00Vaf+ppkgacdQxIJa7INoJb9w4R+R1+odTohlFNh1rhxmIHZfEgzL69bINYq1baERlXQaDB9wrg/CJtrv917EpyIwpsW7aN5UrY9GFZiGTYl0jccirWsE81OYc5NpzYiKN1QvqjSAruwLQ6TAI2OkUAuCKu8BZf0RLSSrbCCl8ZQ1kTZtZV37dy00dVO7FlJ8Pes9uRHeNlfgZTRHXKH2liD7mlL/NjS5usD03S7sOqFHlkeFk9Uo0gauorTwlYlMbNHz37B+zrLrB7yH2+kQtAzfjDaj1pIrIvaQyVJUC6bu4q2om11VGrOC/giNDGMMOfkoNEP+oLfyydSi/wFXzTzr+YXqOMnPT5hIm6kt1rttikfKzWF4fhOua6Tk9Z2HfGr8gmL/aF53+oXf86Dc2CdHAaAJRUBDYWCWwkJpB0zKd/EqoSJ9+2ZT0QL/B9jayTDZGONkQWmRjEN41LSD4O3HaxcYAQ=="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":677,"cache_read_input_tokens":58115,"cache_creation":{"ephemeral_5m_input_tokens":677,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWCwZCY9Jcnd7EiddZxiq","type":"assistant","uuid":"f0470e84-766b-491a-8007-2359f7c80d17","timestamp":"2025-12-17T21:07:17.977Z"}
{"parentUuid":"f0470e84-766b-491a-8007-2359f7c80d17","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"memoized-nibbling-sunset","message":{"model":"claude-opus-4-5-20251101","id":"msg_0132372CnZZb2djGFr3JCBVm","type":"message","role":"assistant","content":[{"type":"text","text":"I need to fix the missing closing tag."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":677,"cache_read_input_tokens":58115,"cache_creation":{"ephemeral_5m_input_tokens":677,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWCwZCY9Jcnd7EiddZxiq","type":"assistant","uuid":"ea0049ab-9707-4454-9cb9-3486fabdbf56","timestamp":"2025-12-17T21:07:18.193Z"}
{"parentUuid":"ea0049ab-9707-4454-9cb9-3486fabdbf56","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"memoized-nibbling-sunset","message":{"model":"claude-opus-4-5-20251101","id":"msg_0132372CnZZb2djGFr3JCBVm","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_012tummjg3sMeWhG8e8ojhvm","name":"Edit","input":{"file_path":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","old_string":"<script src=\"https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js\" integrity=\"sha384-osZDKVu4ipZP703HmPOhWdyBajcFyjX2Psjk//TG1Rc0AdwEtuToaylrmcK3LdAl\" crossorigin=\"anonymous\">\n <style>","new_string":"<script src=\"https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js\" integrity=\"sha384-osZDKVu4ipZP703HmPOhWdyBajcFyjX2Psjk//TG1Rc0AdwEtuToaylrmcK3LdAl\" crossorigin=\"anonymous\"></script>\n <style>"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":677,"cache_read_input_tokens":58115,"cache_creation":{"ephemeral_5m_input_tokens":677,"ephemeral_1h_input_tokens":0},"output_tokens":391,"service_tier":"standard"}},"requestId":"req_011CWCwZCY9Jcnd7EiddZxiq","type":"assistant","uuid":"03a429b6-6136-4688-96e5-376a9fdb4c19","timestamp":"2025-12-17T21:07:20.493Z"}
{"parentUuid":"03a429b6-6136-4688-96e5-376a9fdb4c19","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"memoized-nibbling-sunset","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_012tummjg3sMeWhG8e8ojhvm","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 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→ <style>\n 9→ :root {\n 10→ color-scheme: light dark;\n 11→ --bg: #0b0c0f;\n 12→ --panel: #12141a;"}]},"uuid":"420513a7-f5e5-4361-b577-d0ff3cf29d1a","timestamp":"2025-12-17T21:07:20.534Z","toolUseResult":{"filePath":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","oldString":"<script src=\"https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js\" integrity=\"sha384-osZDKVu4ipZP703HmPOhWdyBajcFyjX2Psjk//TG1Rc0AdwEtuToaylrmcK3LdAl\" crossorigin=\"anonymous\">\n <style>","newString":"<script src=\"https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js\" integrity=\"sha384-osZDKVu4ipZP703HmPOhWdyBajcFyjX2Psjk//TG1Rc0AdwEtuToaylrmcK3LdAl\" crossorigin=\"anonymous\"></script>\n <style>","originalFile":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>Claude Code Timeline Viewer</title>\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js\" integrity=\"sha384-osZDKVu4ipZP703HmPOhWdyBajcFyjX2Psjk//TG1Rc0AdwEtuToaylrmcK3LdAl\" crossorigin=\"anonymous\">\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 .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 function renderMarkdown(text) {\n if (typeof text !== 'string') return '';\n\n // Escape HTML first to prevent raw HTML injection\n let html = escapeHtml(text);\n\n // Code blocks (```...```)\n html = html.replace(/```(\\w*)\\n([\\s\\S]*?)```/g, (match, lang, code) => {\n return `<pre><code class=\"language-${lang}\">${code.trim()}</code></pre>`;\n });\n\n // Inline code (`...`)\n html = html.replace(/`([^`]+)`/g, '<code>$1</code>');\n\n // Headers\n html = html.replace(/^######\\s+(.+)$/gm, '<h6>$1</h6>');\n html = html.replace(/^#####\\s+(.+)$/gm, '<h5>$1</h5>');\n html = html.replace(/^####\\s+(.+)$/gm, '<h4>$1</h4>');\n html = html.replace(/^###\\s+(.+)$/gm, '<h3>$1</h3>');\n html = html.replace(/^##\\s+(.+)$/gm, '<h2>$1</h2>');\n html = html.replace(/^#\\s+(.+)$/gm, '<h1>$1</h1>');\n\n // Bold and italic\n html = html.replace(/\\*\\*\\*([^*]+)\\*\\*\\*/g, '<strong><em>$1</em></strong>');\n html = html.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');\n html = html.replace(/\\*([^*]+)\\*/g, '<em>$1</em>');\n html = html.replace(/___([^_]+)___/g, '<strong><em>$1</em></strong>');\n html = html.replace(/__([^_]+)__/g, '<strong>$1</strong>');\n html = html.replace(/_([^_]+)_/g, '<em>$1</em>');\n\n // Links\n html = html.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '<a href=\"$2\" target=\"_blank\" rel=\"noopener\">$1</a>');\n\n // Blockquotes\n html = html.replace(/^&gt;\\s+(.+)$/gm, '<blockquote>$1</blockquote>');\n\n // Unordered lists\n html = html.replace(/^[\\*\\-]\\s+(.+)$/gm, '<li>$1</li>');\n html = html.replace(/(<li>.*<\\/li>\\n?)+/g, '<ul>$&</ul>');\n\n // Ordered lists\n html = html.replace(/^\\d+\\.\\s+(.+)$/gm, '<li>$1</li>');\n\n // Line breaks - convert double newlines to paragraphs\n html = html.split(/\\n\\n+/).map(para => {\n para = para.trim();\n if (!para) return '';\n // Don't wrap if already wrapped in block element\n if (/^<(h[1-6]|ul|ol|li|pre|blockquote|div)/.test(para)) return para;\n return `<p>${para.replace(/\\n/g, '<br>')}</p>`;\n }).join('\\n');\n\n // Sanitize with DOMPurify to prevent XSS from crafted markdown\n // (e.g., javascript: URLs in links, attribute injection via quotes)\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 = 'result-block' + (item.is_error ? ' result-error' : '');\n block.textContent = resultText.length > 5000 ? resultText.slice(0, 5000) + '\\n... [truncated]' : resultText;\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":4,"oldLines":7,"newStart":4,"newLines":7,"lines":[" <meta charset=\"UTF-8\" />"," <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />"," <title>Claude Code Timeline Viewer</title>","- <script src=\"https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js\" integrity=\"sha384-osZDKVu4ipZP703HmPOhWdyBajcFyjX2Psjk//TG1Rc0AdwEtuToaylrmcK3LdAl\" crossorigin=\"anonymous\">","+ <script src=\"https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js\" integrity=\"sha384-osZDKVu4ipZP703HmPOhWdyBajcFyjX2Psjk//TG1Rc0AdwEtuToaylrmcK3LdAl\" crossorigin=\"anonymous\"></script>"," <style>"," :root {"," color-scheme: light dark;"]}],"userModified":false,"replaceAll":false}}
{"parentUuid":"420513a7-f5e5-4361-b577-d0ff3cf29d1a","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"memoized-nibbling-sunset","message":{"model":"claude-opus-4-5-20251101","id":"msg_01RsYDCNbk98Tr6UJ8YiTGC4","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Good, now the script tag is properly closed. The fix is complete with the user's preferred SRI hash.","signature":"EowCCkYIChgCKkCOYdc9CqsInjh/kU/Fr33TlwrZNVCDv+lyiY5+KM4WbUTt/bkNHdI9Ac2sSgc0PR+mZzSfawyTvskKux9cyFijEgxolBoQcrUdvUnmMTIaDKn8SEVhTfUPUdgAISIwpHqIUtotA1QPX4KYBCA0T6AfZSRkkOdfn8zLQLElZRgM3rwemT+in6mghdC6mAVEKnTM5LQCjqCpdHFtNebxcewwTSfoMioJeTLvtBMnLAchGG0N2vkur9om5oNuKYlxcOeu48BChJpDtwtXuC+tOW+9xYJsheCD93RGjUFNqCvwpNr06KIKRvlYmQpihcpHTd7oFw1MsZ1Yr/82l98tHMDuN9ov7RgB"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":787,"cache_read_input_tokens":58792,"cache_creation":{"ephemeral_5m_input_tokens":787,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard"}},"requestId":"req_011CWCwZhM1JLQj8EQyhJhwK","type":"assistant","uuid":"74870b1a-f7f0-4200-9976-54e08f767114","timestamp":"2025-12-17T21:07:23.976Z"}
{"parentUuid":"74870b1a-f7f0-4200-9976-54e08f767114","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"memoized-nibbling-sunset","message":{"model":"claude-opus-4-5-20251101","id":"msg_01RsYDCNbk98Tr6UJ8YiTGC4","type":"message","role":"assistant","content":[{"type":"text","text":"Fixed. Updated to use the sha384 integrity hash."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":787,"cache_read_input_tokens":58792,"cache_creation":{"ephemeral_5m_input_tokens":787,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard"}},"requestId":"req_011CWCwZhM1JLQj8EQyhJhwK","type":"assistant","uuid":"6a50ad6f-7664-4198-985f-ef6970772cc1","timestamp":"2025-12-17T21:07:24.384Z"}
{"type":"file-history-snapshot","messageId":"6c68207e-29aa-4e84-99df-2d8c211ee88e","snapshot":{"messageId":"6c68207e-29aa-4e84-99df-2d8c211ee88e","trackedFileBackups":{"claude-code-timeline.html":{"backupFileName":"cc3a5887c9685f80@v3","version":3,"backupTime":"2025-12-17T21:08:36.377Z"}},"timestamp":"2025-12-17T21:08:36.367Z"},"isSnapshotUpdate":false}
{"parentUuid":"6a50ad6f-7664-4198-985f-ef6970772cc1","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","type":"user","message":{"role":"user","content":"\"message\": {\n \"role\": \"user\",\n \"content\": [\n {\n \"tool_use_id\": \"toolu_01417uhEnKMWrEad1QrrV8CE\",\n \"type\": \"tool_result\",\n \"content\": [\n {\n \"type\": \"text\",\n \"text\": \"Perfect! Now I have all the information needed. Let me compile a comprehensive report.\\n\\n## Search Results Summary\\n\\nI've successfully explored the llm-gemini codebase and fo Fix that so messages like that are also rendered as markdown"},"uuid":"6c68207e-29aa-4e84-99df-2d8c211ee88e","timestamp":"2025-12-17T21:08:36.364Z","thinkingMetadata":{"level":"high","disabled":false,"triggers":[]}}
{"parentUuid":"6c68207e-29aa-4e84-99df-2d8c211ee88e","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","message":{"model":"claude-opus-4-5-20251101","id":"msg_01NuLCnVS5tm1x26FnUb3GR4","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"The user is showing me a JSONL message that contains a tool_result with text content. They want this content to also be rendered as markdown. Let me look at the code to see how tool_result content is currently handled.\n\nLooking at the `renderPrettyView` function, I can see how tool_result is handled:\n\n```javascript\n// Tool result\nif (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 = 'result-block' + (item.is_error ? ' result-error' : '');\n block.textContent = resultText.length > 5000 ? resultText.slice(0, 5000) + '\\n... [truncated]' : resultText;\n section.appendChild(label);\n section.appendChild(block);\n fragment.appendChild(section);\n hasContent = true;\n }\n}\n```\n\nThe issue is that it uses `block.textContent = resultText` which doesn't render markdown. I should change it to use `block.innerHTML = renderMarkdown(resultText)` instead.\n\nI also need to update the className - currently it uses `result-block` which has monospace font. \n\nI'll preserve the existing `result-block` styling while enabling markdown rendering. This approach maintains the current visual structure but adds markdown support. The key is to use `innerHTML` with a markdown rendering function while keeping the existing error handling and truncation logic intact.","signature":"ErIRCkYIChgCKkBGcwA/cKK8nhbkbSZpVeZke+QBXGsnlRlxxRfQy70mdQj9e+NerkRvwssLtsZs48PQLgTcR1lRd6qlLUav6HzeEgzaH6qMAnJQRamnvg8aDC/lPl5na3mhrnZb/CIwr/HzWvjlyjeN92uCMXkReKsPhhOv/d2NAJveLyqegMNMKi+4pYaCVZoGpW1bBTUQKpkQVQDyciJ6S0xf0Pq8cXrzawYiv6LS0mHWvIAdBnClahnSjlLPdBGi3qofhXahoGqrzzi4jqoWJI844CYxuO3CFtlL1zTLgsLvDw7u0R/9GiGJMGWSg9Uod+/x5etd/JzapvaqC7/1ZfEu36JwryJNfF6TyG9NHJBMvfQht835/Bu/sgCklZWTWkjawNFs5hI5IaObJjRNFwoPo3b0PUzhSofuJ7gYtI8hcg1elS29GwIL/JtA+umfOe7WkUbspiC28C9PCyfimQiVJAbwtxXQd68ypStViN9yLJ+NyCid9w/2w0mpnSyESyVD6Vznwo6d/2Ynz0qE+DhwhWOIY0ZIzcYlG1nWRHi41H96ijhXNBEGQ4dDmutR4aPVgd5TNa6mdXu20AdY7MOP6g7ZFHEyApc/UWLHV/sPsQwg2aBsp8X4bJSwpRFzHqjnHWa6ErWf5BajhNozvds/VEAo2DULQR9TYHBIIJI2yWHybxHXCGjApGEC/EFG1E8O7IXvgDCzr5qQ1ohMJGDVvEoBJvJn4A1jX7PLZWRVn2QDCF8WHyeKWlOZOsjZaBPs5mdsktVYko2GQbbFnhSw9UQLzZ1+RyIGlp828+xYBa+AkE0wXeRCehuBHt3Tr1tgHx56QKxyLuT3DBbSSScaDaabk5Tu/BYEUnbQPLs1pBTkB48fWP/kM8M45NtOvqH81XhmNyY2CxhKSr84AOnthzWFZD3nvfbLuUQ3lChkpOAV6rm9q8q0wBxW6eGNcBwS6svHrzTx9A5EEJvxXkNDO4lfYTNhDM9rKAHVehTgM/osDztTYNgR41NYObAv2hyaV2ZiCLoHOR2S0mCMY1x6VuVl3y2l7LTLYHyfQuhmeTrygS6RbHYxivkCkcsM3jv/hMLExeSvJPElAR4ccW75VKD5OHsglaUjluRrgYTw0Jjz6jlc3vRELAq/0dHf7fLbk8n7V+ntPBeel+7CeIRCkgP//l6OH7DTnsXicLxlmlpLYKqJAfU/qPtqy0tWPkMdbltKSkE0aJn0eH04EwVladX8I37fHrCUI7Q8Qg20YCpii3teqqWT2h3Znv+7fI8ixrMMZ/JY8JDIsnekQVBBJtf3A17Gjx4A1hJiYCjRiO0xU2zJev7jF6GnICb39tSmSZCtOlJvIGCF7n8SUnvhT+o+vbudOJxNHZ1QAk83OiMIphQ/p1jc67WqaQUJ03lMbK2VGKxm5DYUtHxUYjFAI1SpfLMExkjSnWY2wDJtw0gvdyFvzyuU2r5kIih3kPayxqtSm2JW2mUdJcBgdLN8SObCQbO36WgqCDMW+O0UTg5laxPn6e485Pa498XOsBo6T0l4ev2YRIsf8QnjzErklm85aBNsobRRzIyLs91k4yXaTmUnPt85zyYi47ZiiHVc+xCJz2mPx+3fU8uJxufSFPNkXiEVMfKFP90QrUj4qDId3ghb/pcKBBkuH5YLCqL+p95XqnxwS54UOpepQPaTsxRPocgxLBZDFpOaJvQEivm6JmDA1Qrcf8+p2oZLcSwr7apUHTw4lSgxgi6d78PvUkG/FS8CDBCoucmRCuo3YcHv1O+i/zDXw+EmLu+lh7ZoxEGNgHT6Dr5/Ub7FFmzT2IjC5v+pqrZIOAt7UqeROUAYif2wsZEKTFn1BiAqF/luPb+PLQgXLBqU48r02ptbq6nMCZe21l1fzoW3M+mIPA/OP2QDRXohe9lIOD9Zbok1+I1mzu9s0/4y9UWtXJVu88fDVt1Eci0zJWsDjatd+i7MPVN+LCPHPuTxF/6+S16sCPr5ZI2v3wr3ny/CbN+GzC5kwPwX0AsFP8BDayGp8BJk1Feo2HEAg6qWGqeRsEJbrxRVh7+r5fyrOrm0eBu00+pl9myVfsg4qKNgFuXhW/sNa+axRQrjKQymObw18a1voxuCplzT5XbP6hIc32YZ8np+4auE+52nXFicB3HQneA0Q62IjoCGFtKtJO/Lq6nZGfC3HP1Up0Bnb+ZwmnRqtJdzrl0Np9IeY03SKqIRevzwX/wel4MAP/SYKM/vGVN+jcZ4Td5My6rqIhP/OuJmO9FzkXNRSXmRkaQNtpmQ1Fa4pv2ff34hqZxgDI/vLL7LHRBVfMo7ClocaY+e2apaMTgpIa933L+DluoxB7Ud44Rc9lsRKiSAp7c0CTi+0YWaPbRRLPtgpdp99u9FvGGYXyOjiPDg4jjDz3S46sSiz6kGpijktrUVBH8raWEc+t4matvaOXc44ld69MApA7Qg9L2umwh/zycF19qO82aELUUyjF4/ntu/2UHq9w7E1E86nwCqjSEFpu8FS3bbpxT/5B6l8Il1wwrqyQEPRz4Ro1UMsySMuNIXoJcQcGGZiLmB16GEzvCTP2JPobg/JA1hoH9R86BzdJ7i/RNsmc7QXZcQ/Mq6u9jWAekzXEhj0Y0isVLlb8fVZrhR+6BKX1XhCHqDeXDHjfExjJzvv1pCEuQXqVaZ27C4Lq5Y4LllOP9TS+V4LwNebP5JzG8MG3ccU9je8dWhRSrkitmgOc69zOhbqxsfhqdA2bPKuwuK5DsBAdhzToZ7Rb0vNdXQU+f3WT1CJ/MupPu8w8wD/2Pc4AQ0rxtLGMh7VXJnwlqKWwSEhl97Et5Oez+P+wAX2sAhQImkHDn9Tk/15fDf+9aOe1Oly/MdfVcKualeXKsAX8Ce8AAQcReeI6PHz1GcTeg0CmT0BF8rT0aFim4wGuFBXf11fXg7RXXDQbTvt9cqIgNTYQD82eak9qLGB/34xTSnGAE="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":44691,"cache_read_input_tokens":14916,"cache_creation":{"ephemeral_5m_input_tokens":44691,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard"}},"requestId":"req_011CWCwfJ8R1VVwRimtfggw1","type":"assistant","uuid":"2af581a6-9b1b-49b3-9ce7-1bbbb15edb5e","timestamp":"2025-12-17T21:08:52.516Z"}
{"parentUuid":"2af581a6-9b1b-49b3-9ce7-1bbbb15edb5e","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","message":{"model":"claude-opus-4-5-20251101","id":"msg_01NuLCnVS5tm1x26FnUb3GR4","type":"message","role":"assistant","content":[{"type":"text","text":"I'll update the tool_result rendering to use markdown."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":44691,"cache_read_input_tokens":14916,"cache_creation":{"ephemeral_5m_input_tokens":44691,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard"}},"requestId":"req_011CWCwfJ8R1VVwRimtfggw1","type":"assistant","uuid":"404548ab-5471-47bc-a48b-7ac776a4891b","timestamp":"2025-12-17T21:08:52.522Z"}
{"parentUuid":"404548ab-5471-47bc-a48b-7ac776a4891b","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","message":{"model":"claude-opus-4-5-20251101","id":"msg_01NuLCnVS5tm1x26FnUb3GR4","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01XqGd8eEB9uC6NmzkZeTcaV","name":"Read","input":{"file_path":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","offset":1895,"limit":40}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":44691,"cache_read_input_tokens":14916,"cache_creation":{"ephemeral_5m_input_tokens":44691,"ephemeral_1h_input_tokens":0},"output_tokens":696,"service_tier":"standard"}},"requestId":"req_011CWCwfJ8R1VVwRimtfggw1","type":"assistant","uuid":"0752b3b7-98b1-4a2c-a8f0-1bdd3548d8f5","timestamp":"2025-12-17T21:08:52.551Z"}
{"parentUuid":"0752b3b7-98b1-4a2c-a8f0-1bdd3548d8f5","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"wiggly-wishing-valley","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01XqGd8eEB9uC6NmzkZeTcaV","type":"tool_result","content":" 1895→ fragment.appendChild(section);\n 1896→ hasContent = true;\n 1897→ }\n 1898→\n 1899→ // Tool result\n 1900→ if (item.type === 'tool_result') {\n 1901→ const section = document.createElement('div');\n 1902→ section.className = 'pretty-section';\n 1903→ const label = document.createElement('div');\n 1904→ label.className = 'pretty-label';\n 1905→ label.textContent = item.is_error ? 'Tool Error' : 'Tool Result';\n 1906→\n 1907→ const resultContent = item.content;\n 1908→ let resultText = '';\n 1909→\n 1910→ if (typeof resultContent === 'string') {\n 1911→ resultText = resultContent;\n 1912→ } else if (Array.isArray(resultContent)) {\n 1913→ for (const rc of resultContent) {\n 1914→ if (rc?.type === 'text' && rc.text) {\n 1915→ resultText += rc.text;\n 1916→ }\n 1917→ }\n 1918→ }\n 1919→\n 1920→ if (resultText) {\n 1921→ const block = document.createElement('div');\n 1922→ block.className = 'result-block' + (item.is_error ? ' result-error' : '');\n 1923→ block.textContent = resultText.length > 5000 ? resultText.slice(0, 5000) + '\\n... [truncated]' : resultText;\n 1924→ section.appendChild(label);\n 1925→ section.appendChild(block);\n 1926→ fragment.appendChild(section);\n 1927→ hasContent = true;\n 1928→ }\n 1929→ }\n 1930→ }\n 1931→ }\n 1932→\n 1933→ if (hasContent) {\n 1934→ els.detailPretty.appendChild(fragment);\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":"7c5f63e6-0fa0-4d8a-823c-f4bca46ca329","timestamp":"2025-12-17T21:08:52.583Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","content":" 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 = 'result-block' + (item.is_error ? ' result-error' : '');\n block.textContent = resultText.length > 5000 ? resultText.slice(0, 5000) + '\\n... [truncated]' : resultText;\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);","numLines":40,"startLine":1895,"totalLines":2288}}}
{"parentUuid":"7c5f63e6-0fa0-4d8a-823c-f4bca46ca329","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"wiggly-wishing-valley","message":{"model":"claude-opus-4-5-20251101","id":"msg_01TGNHvm4VHkarhT7WJzNJSd","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"I see the issue. The tool_result rendering uses `block.textContent` which doesn't render markdown. I need to change it to use `block.innerHTML = renderMarkdown(...)` and also update the class to include `pretty-content` for proper markdown styling.","signature":"EqEDCkYIChgCKkBzpk8BeMOJB+zcPey86KIT1fu6vgDMH8gpUur2ao65N9AHu6Tpsa8Svf3t57JhPopFfqqy4VSVKsiCb05PU0PBEgz/Rnxn08M8fJwVMIgaDLrLIiqxPdn7orG1EyIwRWHSYJt+9DP2ydZCg0527qWXdySm0eSukSIbYMkFyxmQYZZur6hyTkZVVFynZslzKogCHcSnWTMycEI0gywOZBCojOG+MwSpTfLCtmjqRXT51PdKHHxFIgRTUQolHVp+emczRk+fAWeGFy10P4oSPW5eqTgVmmjSCl6p9oyuhQubomV8Ccc+E4+o7HGNSkrFngvm8qzHyHD6gLkKQVemlcx5/+Mqb7RGMqJKRY3BGteqtJCg0cGVxop3MQV91hFKgrw7HxPDpRV2XQNmUEwjbTDszFsfi8ctoCPNw8TZYH9KgyBN+t8niNdYg4fFyxTaFu3gc/BlBjC9BWVzJrGeizOEu2RvCah8ghQVah3SZkrW2i+u3H8jwhOYHvQD/4G7GNsdDEhMhsLMKr7L6GU8MAa2jDsy80bXLpfLGAE="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":1310,"cache_read_input_tokens":59607,"cache_creation":{"ephemeral_5m_input_tokens":1310,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard"}},"requestId":"req_011CWCwgVTpgKQKb9A7N4VBT","type":"assistant","uuid":"07f21414-a986-4cce-834d-5c0ca9197b41","timestamp":"2025-12-17T21:08:55.808Z"}
{"parentUuid":"07f21414-a986-4cce-834d-5c0ca9197b41","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"wiggly-wishing-valley","message":{"model":"claude-opus-4-5-20251101","id":"msg_01TGNHvm4VHkarhT7WJzNJSd","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01YPS4SsnjZK15aWETtoFD4G","name":"Edit","input":{"file_path":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","old_string":"if (resultText) {\n const block = document.createElement('div');\n block.className = 'result-block' + (item.is_error ? ' result-error' : '');\n block.textContent = resultText.length > 5000 ? resultText.slice(0, 5000) + '\\n... [truncated]' : resultText;\n section.appendChild(label);\n section.appendChild(block);\n fragment.appendChild(section);\n hasContent = true;\n }","new_string":"if (resultText) {\n const block = document.createElement('div');\n block.className = 'result-block 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 }"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":1310,"cache_read_input_tokens":59607,"cache_creation":{"ephemeral_5m_input_tokens":1310,"ephemeral_1h_input_tokens":0},"output_tokens":429,"service_tier":"standard"}},"requestId":"req_011CWCwgVTpgKQKb9A7N4VBT","type":"assistant","uuid":"34b9756a-c1fd-48ef-9cdc-d608c932d19d","timestamp":"2025-12-17T21:08:59.695Z"}
{"parentUuid":"34b9756a-c1fd-48ef-9cdc-d608c932d19d","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"wiggly-wishing-valley","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01YPS4SsnjZK15aWETtoFD4G","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 1916→ }\n 1917→ }\n 1918→ }\n 1919→\n 1920→ if (resultText) {\n 1921→ const block = document.createElement('div');\n 1922→ block.className = 'result-block pretty-content' + (item.is_error ? ' result-error' : '');\n 1923→ const displayText = resultText.length > 5000 ? resultText.slice(0, 5000) + '\\n... [truncated]' : resultText;\n 1924→ block.innerHTML = renderMarkdown(displayText);\n 1925→ section.appendChild(label);\n 1926→ section.appendChild(block);\n 1927→ fragment.appendChild(section);\n 1928→ hasContent = true;\n 1929→ }\n 1930→ }\n 1931→ }\n 1932→ }\n 1933→"}]},"uuid":"13648538-b41e-46a3-9e55-0d6883534764","timestamp":"2025-12-17T21:09:33.681Z","toolUseResult":{"filePath":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","oldString":"if (resultText) {\n const block = document.createElement('div');\n block.className = 'result-block' + (item.is_error ? ' result-error' : '');\n block.textContent = resultText.length > 5000 ? resultText.slice(0, 5000) + '\\n... [truncated]' : resultText;\n section.appendChild(label);\n section.appendChild(block);\n fragment.appendChild(section);\n hasContent = true;\n }","newString":"if (resultText) {\n const block = document.createElement('div');\n block.className = 'result-block 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 }","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 <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 .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 function renderMarkdown(text) {\n if (typeof text !== 'string') return '';\n\n // Escape HTML first to prevent raw HTML injection\n let html = escapeHtml(text);\n\n // Code blocks (```...```)\n html = html.replace(/```(\\w*)\\n([\\s\\S]*?)```/g, (match, lang, code) => {\n return `<pre><code class=\"language-${lang}\">${code.trim()}</code></pre>`;\n });\n\n // Inline code (`...`)\n html = html.replace(/`([^`]+)`/g, '<code>$1</code>');\n\n // Headers\n html = html.replace(/^######\\s+(.+)$/gm, '<h6>$1</h6>');\n html = html.replace(/^#####\\s+(.+)$/gm, '<h5>$1</h5>');\n html = html.replace(/^####\\s+(.+)$/gm, '<h4>$1</h4>');\n html = html.replace(/^###\\s+(.+)$/gm, '<h3>$1</h3>');\n html = html.replace(/^##\\s+(.+)$/gm, '<h2>$1</h2>');\n html = html.replace(/^#\\s+(.+)$/gm, '<h1>$1</h1>');\n\n // Bold and italic\n html = html.replace(/\\*\\*\\*([^*]+)\\*\\*\\*/g, '<strong><em>$1</em></strong>');\n html = html.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');\n html = html.replace(/\\*([^*]+)\\*/g, '<em>$1</em>');\n html = html.replace(/___([^_]+)___/g, '<strong><em>$1</em></strong>');\n html = html.replace(/__([^_]+)__/g, '<strong>$1</strong>');\n html = html.replace(/_([^_]+)_/g, '<em>$1</em>');\n\n // Links\n html = html.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '<a href=\"$2\" target=\"_blank\" rel=\"noopener\">$1</a>');\n\n // Blockquotes\n html = html.replace(/^&gt;\\s+(.+)$/gm, '<blockquote>$1</blockquote>');\n\n // Unordered lists\n html = html.replace(/^[\\*\\-]\\s+(.+)$/gm, '<li>$1</li>');\n html = html.replace(/(<li>.*<\\/li>\\n?)+/g, '<ul>$&</ul>');\n\n // Ordered lists\n html = html.replace(/^\\d+\\.\\s+(.+)$/gm, '<li>$1</li>');\n\n // Line breaks - convert double newlines to paragraphs\n html = html.split(/\\n\\n+/).map(para => {\n para = para.trim();\n if (!para) return '';\n // Don't wrap if already wrapped in block element\n if (/^<(h[1-6]|ul|ol|li|pre|blockquote|div)/.test(para)) return para;\n return `<p>${para.replace(/\\n/g, '<br>')}</p>`;\n }).join('\\n');\n\n // Sanitize with DOMPurify to prevent XSS from crafted markdown\n // (e.g., javascript: URLs in links, attribute injection via quotes)\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 = 'result-block' + (item.is_error ? ' result-error' : '');\n block.textContent = resultText.length > 5000 ? resultText.slice(0, 5000) + '\\n... [truncated]' : resultText;\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":1919,"oldLines":8,"newStart":1919,"newLines":9,"lines":[" "," if (resultText) {"," const block = document.createElement('div');","- block.className = 'result-block' + (item.is_error ? ' result-error' : '');","- block.textContent = resultText.length > 5000 ? resultText.slice(0, 5000) + '\\n... [truncated]' : resultText;","+ block.className = 'result-block pretty-content' + (item.is_error ? ' result-error' : '');","+ const displayText = resultText.length > 5000 ? resultText.slice(0, 5000) + '\\n... [truncated]' : resultText;","+ block.innerHTML = renderMarkdown(displayText);"," section.appendChild(label);"," section.appendChild(block);"," fragment.appendChild(section);"]}],"userModified":false,"replaceAll":false}}
{"parentUuid":"13648538-b41e-46a3-9e55-0d6883534764","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"wiggly-wishing-valley","message":{"model":"claude-opus-4-5-20251101","id":"msg_01VgUtWpNn5XE3oxPu88DBwD","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Good, now tool_result content will be rendered as markdown. The `pretty-content` class provides the proper markdown styling (headings, lists, code blocks, etc.), and `renderMarkdown` will handle the conversion and sanitization via DOMPurify.","signature":"EpoDCkYIChgCKkBlgj2AYiyitXEm+WZtWtskDl95W9/SUsT8G6ufNfXIDJ9Dbkwj4lwhQt8czS0y05GVFNNuW4/tvg7HO7L+B89aEgy5r5TN+ageBeQyczYaDK2T610VI3kFXXBtuyIwPIQlDuUQfrfxeRPycJ1oTK4BtzHGe/m/U6X6h4V3/r1kYcl85Iq6tAKS6m5WAIawKoECs0SmI0gLYhW3jOWwaABMKvakuL4K8tetzEClFe03msHjx0U5qrrQRPGDWyvKHUs3vkIKPmu8un6quJT1Ujh9UrreWJHjYS5VQTyFNofGaR5LyPlX4sZg1XwuXCmEbYHmkW61rdmEvElLHlDnw9LfcQhv8EmRqbNc7whaZsZO3BYNNstesZQzmi8YuDIJEMkTxD+fvVlHROxJphp2ZG0CRp6OXLdF16IHfuJmX1/AfHEmzp31bwC2EX7o2TZ7MqlKS/nYoiOncNrSgLUHSx0vei/Od/GNRT4uUCBuS/r+hOBEpwR0qiXjkcZ92x3wyzj4bM/row/lGVlZUdj2Mxz6i/oYAQ=="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":825,"cache_read_input_tokens":60917,"cache_creation":{"ephemeral_5m_input_tokens":825,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWCwjYNXZ6mmCRngMMUtX","type":"assistant","uuid":"23a54064-c59a-4355-9480-d0630a8db7de","timestamp":"2025-12-17T21:09:37.694Z"}
{"parentUuid":"23a54064-c59a-4355-9480-d0630a8db7de","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"wiggly-wishing-valley","message":{"model":"claude-opus-4-5-20251101","id":"msg_01VgUtWpNn5XE3oxPu88DBwD","type":"message","role":"assistant","content":[{"type":"text","text":"Done. Tool result content now renders as markdown. The change:\n- Added `pretty-content` class for proper markdown styling (headings, lists, code, etc.)\n- Uses `renderMarkdown()` which includes the DOMPurify sanitization\n\nSo messages like `## Search Results Summary` in tool results will now display as proper formatted headings."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":825,"cache_read_input_tokens":60917,"cache_creation":{"ephemeral_5m_input_tokens":825,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWCwjYNXZ6mmCRngMMUtX","type":"assistant","uuid":"dca44c31-faff-47d8-a7cc-0e2ff2b726ce","timestamp":"2025-12-17T21:09:39.338Z"}
{"type":"file-history-snapshot","messageId":"112e938b-a168-4b3b-ac36-b0f67bf8bbb0","snapshot":{"messageId":"112e938b-a168-4b3b-ac36-b0f67bf8bbb0","trackedFileBackups":{"claude-code-timeline.html":{"backupFileName":"cc3a5887c9685f80@v4","version":4,"backupTime":"2025-12-17T21:10:44.738Z"}},"timestamp":"2025-12-17T21:10:44.731Z"},"isSnapshotUpdate":false}
{"parentUuid":"dca44c31-faff-47d8-a7cc-0e2ff2b726ce","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"wiggly-wishing-valley","type":"user","message":{"role":"user","content":"In that particular case even though it is a tool_result it should not use courier font, it should look like other rendered markdown"},"uuid":"112e938b-a168-4b3b-ac36-b0f67bf8bbb0","timestamp":"2025-12-17T21:10:44.726Z","thinkingMetadata":{"level":"high","disabled":false,"triggers":[]}}
{"parentUuid":"112e938b-a168-4b3b-ac36-b0f67bf8bbb0","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"wiggly-wishing-valley","message":{"model":"claude-opus-4-5-20251101","id":"msg_01JEvrDeceCdfkLXDJK6J44o","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"The user is saying that the `result-block` class applies a monospace/courier font which doesn't look right for markdown content. They want it to use the regular sans-serif font like other markdown content.\n\nLet me look at the CSS for `result-block`:\n\n```css\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); /* <-- This is the problem */\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\nThe issue is `font-family: var(--mono)` which forces monospace font. Since I'm now adding `pretty-content` class as well, I need to make sure that when both classes are present, the markdown styling (which uses the regular sans-serif font) takes precedence.\n\nI could either:\n1. Remove `result-block` class entirely and just use `pretty-content`\n2. Override the font-family when both classes are present\n3. Create a modifier class that removes the mono font\n\nOption 1 seems cleanest - just use `pretty-content` with perhaps some additional styling for the container look (background, border, etc.). But looking at the existing `pretty-content` styles, they don't have the container styling that `result-block` has.\n\nActually, looking more carefully, I think the best approach is to remove `result-block` and use just `pretty-content`, but wrap it in a container div that has the background/border styling without the monospace font.\n\nOr even simpler - I can just not use `result-block` at all and use `pretty-content` directly. The styling from `pretty-content` should be sufficient for rendered markdown.\n\n\nI'll craft a straightforward solution by directly applying `pretty-content` and creating a flexible approach for error handling. I'll design a clean method to distinguish between normal content and error states without relying on the previous monospace-heavy class structure.","signature":"EqMRCkYIChgCKkBvmL5uI7UgwWo3PTlVviXv9S3cGcuq2xfXu8oKrT8G6Myux3cnLlsvyxabJXKcfCUpZtT4oKD+CkVKq+hxyRkOEgwmpSD/icopQ1UrOysaDFAnp7SKPr4CNFTELiIwvuajm2xb4ihshaUc9yPi7+jHssF/UoeK6u0s/GSWJovHrZycXYlBDyQ+v4tqG5ruKooQCR7zG4h9KpWmRATiLW3G4BGLf8kcZt1FgkePPkZNMzUGMDVwt2/u4DNla9Y129OZTxKSTaXpyIYS+0hvYpXXb9R/6kqTUJFn4QKbwOq27r5+IJO1qJCG3MfrHTe33U5wYtZv/+wWV05on0szEqZ164VwW7I4YshfOeuRX9o71ehuUv6cewOBTv2zaMuQdEQZwwGj0RnJKsA5DveHcyp19CEv0qhXZCyjQEvcoP+IUM7m/wEAx9P7Pbjissg8ydQBRL4omDD75KbV3Hch7GGM6qBXfWt1ribobc6d4uantYn5vZrxrmevT5XONeUGJ1Vxk+Qb3lR1/akfIHxO3yPd1NIyRtuZ6uHVBG0xorC7v0Ej9phcArqmaK0sJPRj6iGi5HZSEzn6ob1FFwnIb+CcP+86gQi2KULxSs8Ej8QYYMc+ojuGUxC4BtpwUY0ediwe47Rm/4cTWPbo+v+r8cN53euLvozj+1lKZ0Nqh/lKlf8H0cXN0hkPMK8B4XPNcMGcGJ5m0TOuFhTfOAzLf7a7K6KBmZg1DUcXApZVqqWD3fh9jq+nYSoZ8i1xBLRl6DnHy3nK0gTp6dEAWpR/15bTmqKZokoFTzGLq8PWbIPtC8a8CqglnfIEd3pCmmwM5FAGfOm96KCw4jzB4sk9QW9fF4GI7UBlCLetM6xKyJ5UfCsN9WjyI6bHvgaEkQfQKFn30y0EbZSDK1wbSO6NVrTt+44QnDiuk9sBrokQAj/pvIco4+Wl4KYy9aBM/cfJKSzKEXYwV4Ta3X0iOIW7t0FZpt3Nnn5S3+w4IFvJeLOxvwatJrW6NfNhQ08EqyTNLfASNKpQojwViHxAkCiWqSnpaoUCD/ZLKOq4CcdpAtEtJkIuQmpiu9KKYkWvBiZdcNZXlAFLdzUVLMVsfWoWnXH+VFja3lv9q7Q8F5ZGpIrf/W1PJBqhmxr0FRTF6iEBJWF7cYbA1LL1kd3C8KpwleBjJYZI22WAsb7JxkTcj1L4F96iKCZr7y/jgm+E7ff7dnCDKsgujh/5pgVPKp2Zjn1aU5bvi03e5uOXDRG7CqXJGhYOsZ6RPe1rAHiafjcFsNEKt6Uo0bWCdkvdNcSAiec3sbxXDLwRnMfP9t+n4WFZJ37V+WownV+GjgxH3TvMl6WYP43OzNBb/ccc8RCOUIAW+nrQ7GC8aK/S+JvVoPxEnhhPLsAydMpLmOvvyV0CX/QGc/ydGoMlln0uDsE/ea2XZK1BxXmllyR/6R7Af9z1/RHQYOTEuGeV9dUfLrpqapZlxjN1mjU+J0tjGEXKCZSCu/KVDIVCCkXPvQluS7tHc30cncs9gyvGy6OYYUTTabuGjZeDW/wP3TUYNOH+FO/KuDv3v4a537oNdYW4k1m/FJ/aK83e5DLw0Q61HO6Ee18W3G3ak7ACQvWphjGsn6tOHmaGZ5vuYHyL0KEgsKg7ZfluGU1lsHcch0CTwROoqyqWaqLhE4/e8oRz7F5BC9wCZ6RHim0pGVMFhUZhs2SUjt9gwn6dNH1ywQBHM1JR2+kJR1zbvnjzZj4vBsy+JcMd4TwnAVKKKwX95aNOv5OmmIq1DnG/w8ogA2BFdGa+7a55XfslVH5O3iyO8zx3IEd/DpY76PbnqlMWAwVGtY5/3Ae3J0VqCo9S9WI5bFwSYEI7efkYYrKZz/gwzFUfnXZ/zLaxfLis/YICc7NCHWLvLrEJ7BKQFiw/h2pSv7zXwJbqlAI+iurOtvzdUmZ3OZkNMb5uSC9vDb4KJB1wuxuvm6v4ecSph/Xywl2yvyNnfgdRXadHGPj7yOqkCZp/RniZNboZNe2QgCErJy0QgZ+9m+ZWtno+POxJgCKTRq0sAqkmKDRkorPhp1kCxyiXo6TYcDn4VVXAastT3h0TC02orQe/Es0OjTdyRZ08PktG5sP5TS18k75rsNdoPNZG7jjL8T7PpGLsB1OPi0RqIKe0PSIf6qdI3xNVqkGVGrz3RY9hyYVVsDGcD/DXAbh0SmSZxQ96d6nhKiDIlzjxNyOmb+qoN5aUC6AwmiO4i/otCk88hXt2sG/nKv24fmJ/uat4C9TBIrki4PuN/IJ7sk08hUJT4EP8vcPjFHTSxOH4YOwK7zFcvn7r9SS28sEOs0z/dzBSEF1xJXid8Hyxgpy2BcLAVWPyS/CXgoKju6doii84vdG4r+0G/dVxWdwEC/2WC9ICQYwvsRQ0Pv5LRo8h76bIZDu0KSrKEI1L11qpUaSn5UehykjkXonv1MYu+TMqZHtIuN3Qe9rmRoXMWUgCsNTaRwaUGP3+DdFQK3qndSTv1QWKazd5y5fJ0xeMHeidWu9+RSCy9CzyHtPfIink2LEHRBrqTZko7/xBonTuhp9PwjQp5dMvVbkJDZURKh+GE/meMVxj05qjvesAK5jDXofV8yfqedOJCUl8ujGe60leANjzvYV/PBj0qU8tc5bbY+kCIDcAHQ2s01jfvFovQ6o2Ke3AITTMs+QYYlsveHzrZO9O7omboNGgaE+yzdj6RtSFaToiyHnsvEQu94ZFTbdCSc6MG3lAmlhSnQjTWdHR7o2hNhcLjALGDphTjnH4Y36pk11NnVrTZno5i0aQl3OhdDPXJb/COqf/axSB316553FiaAlxQSOKuVGrCoAiivDp/eY3xxBEar9BThb3+ZTb5P6cXwQzr0eVFDzyNCpV/c01y7dFROzOAUetXbrM82fcSDiScEfMaVwXQGuhisaA5z4btVwfYzXjGAE="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":29,"cache_read_input_tokens":61891,"cache_creation":{"ephemeral_5m_input_tokens":29,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard"}},"requestId":"req_011CWCwpm6PmWCkYgcQ5piu6","type":"assistant","uuid":"69724044-1738-483a-bbf7-1b672d6e009f","timestamp":"2025-12-17T21:11:00.460Z"}
{"parentUuid":"69724044-1738-483a-bbf7-1b672d6e009f","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"wiggly-wishing-valley","message":{"model":"claude-opus-4-5-20251101","id":"msg_01JEvrDeceCdfkLXDJK6J44o","type":"message","role":"assistant","content":[{"type":"text","text":"You're right. The `result-block` class forces monospace font. I'll remove it so the markdown renders with the normal sans-serif font."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":29,"cache_read_input_tokens":61891,"cache_creation":{"ephemeral_5m_input_tokens":29,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard"}},"requestId":"req_011CWCwpm6PmWCkYgcQ5piu6","type":"assistant","uuid":"292e69f9-cab0-47b9-8893-630853af906d","timestamp":"2025-12-17T21:11:00.477Z"}
{"parentUuid":"292e69f9-cab0-47b9-8893-630853af906d","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"wiggly-wishing-valley","message":{"model":"claude-opus-4-5-20251101","id":"msg_01JEvrDeceCdfkLXDJK6J44o","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01SKKJ4qeyM2zLjNfJTSJPjj","name":"Edit","input":{"file_path":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","old_string":"if (resultText) {\n const block = document.createElement('div');\n block.className = 'result-block 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 }","new_string":"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 }"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":29,"cache_read_input_tokens":61891,"cache_creation":{"ephemeral_5m_input_tokens":29,"ephemeral_1h_input_tokens":0},"output_tokens":967,"service_tier":"standard"}},"requestId":"req_011CWCwpm6PmWCkYgcQ5piu6","type":"assistant","uuid":"a5f61a1f-354b-4275-ab70-98453bc65282","timestamp":"2025-12-17T21:11:02.720Z"}
{"parentUuid":"a5f61a1f-354b-4275-ab70-98453bc65282","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"wiggly-wishing-valley","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01SKKJ4qeyM2zLjNfJTSJPjj","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 1916→ }\n 1917→ }\n 1918→ }\n 1919→\n 1920→ if (resultText) {\n 1921→ const block = document.createElement('div');\n 1922→ block.className = 'pretty-content' + (item.is_error ? ' result-error' : '');\n 1923→ const displayText = resultText.length > 5000 ? resultText.slice(0, 5000) + '\\n... [truncated]' : resultText;\n 1924→ block.innerHTML = renderMarkdown(displayText);\n 1925→ section.appendChild(label);\n 1926→ section.appendChild(block);\n 1927→ fragment.appendChild(section);\n 1928→ hasContent = true;\n 1929→ }\n 1930→ }\n 1931→ }\n 1932→ }\n 1933→"}]},"uuid":"b3d3ec05-c96e-4a39-ae81-5c27468222bb","timestamp":"2025-12-17T21:11:02.769Z","toolUseResult":{"filePath":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","oldString":"if (resultText) {\n const block = document.createElement('div');\n block.className = 'result-block 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 }","newString":"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 }","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 <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 .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 function renderMarkdown(text) {\n if (typeof text !== 'string') return '';\n\n // Escape HTML first to prevent raw HTML injection\n let html = escapeHtml(text);\n\n // Code blocks (```...```)\n html = html.replace(/```(\\w*)\\n([\\s\\S]*?)```/g, (match, lang, code) => {\n return `<pre><code class=\"language-${lang}\">${code.trim()}</code></pre>`;\n });\n\n // Inline code (`...`)\n html = html.replace(/`([^`]+)`/g, '<code>$1</code>');\n\n // Headers\n html = html.replace(/^######\\s+(.+)$/gm, '<h6>$1</h6>');\n html = html.replace(/^#####\\s+(.+)$/gm, '<h5>$1</h5>');\n html = html.replace(/^####\\s+(.+)$/gm, '<h4>$1</h4>');\n html = html.replace(/^###\\s+(.+)$/gm, '<h3>$1</h3>');\n html = html.replace(/^##\\s+(.+)$/gm, '<h2>$1</h2>');\n html = html.replace(/^#\\s+(.+)$/gm, '<h1>$1</h1>');\n\n // Bold and italic\n html = html.replace(/\\*\\*\\*([^*]+)\\*\\*\\*/g, '<strong><em>$1</em></strong>');\n html = html.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');\n html = html.replace(/\\*([^*]+)\\*/g, '<em>$1</em>');\n html = html.replace(/___([^_]+)___/g, '<strong><em>$1</em></strong>');\n html = html.replace(/__([^_]+)__/g, '<strong>$1</strong>');\n html = html.replace(/_([^_]+)_/g, '<em>$1</em>');\n\n // Links\n html = html.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '<a href=\"$2\" target=\"_blank\" rel=\"noopener\">$1</a>');\n\n // Blockquotes\n html = html.replace(/^&gt;\\s+(.+)$/gm, '<blockquote>$1</blockquote>');\n\n // Unordered lists\n html = html.replace(/^[\\*\\-]\\s+(.+)$/gm, '<li>$1</li>');\n html = html.replace(/(<li>.*<\\/li>\\n?)+/g, '<ul>$&</ul>');\n\n // Ordered lists\n html = html.replace(/^\\d+\\.\\s+(.+)$/gm, '<li>$1</li>');\n\n // Line breaks - convert double newlines to paragraphs\n html = html.split(/\\n\\n+/).map(para => {\n para = para.trim();\n if (!para) return '';\n // Don't wrap if already wrapped in block element\n if (/^<(h[1-6]|ul|ol|li|pre|blockquote|div)/.test(para)) return para;\n return `<p>${para.replace(/\\n/g, '<br>')}</p>`;\n }).join('\\n');\n\n // Sanitize with DOMPurify to prevent XSS from crafted markdown\n // (e.g., javascript: URLs in links, attribute injection via quotes)\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 = 'result-block 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":1919,"oldLines":7,"newStart":1919,"newLines":7,"lines":[" "," if (resultText) {"," const block = document.createElement('div');","- block.className = 'result-block pretty-content' + (item.is_error ? ' result-error' : '');","+ block.className = 'pretty-content' + (item.is_error ? ' result-error' : '');"," const displayText = resultText.length > 5000 ? resultText.slice(0, 5000) + '\\n... [truncated]' : resultText;"," block.innerHTML = renderMarkdown(displayText);"," section.appendChild(label);"]}],"userModified":false,"replaceAll":false}}
{"parentUuid":"b3d3ec05-c96e-4a39-ae81-5c27468222bb","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"wiggly-wishing-valley","message":{"model":"claude-opus-4-5-20251101","id":"msg_01CLHz4JZJWB2ZrT8VHQSsxa","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Good. Now tool_result content will use `pretty-content` class without the `result-block` class, so it will use the regular sans-serif font and look like other rendered markdown. The error styling (red border/background) is still preserved via `result-error` class.","signature":"ErEDCkYIChgCKkAePwQF6bnu/m0E5Y0zfO95q2wBZwdhiHAJZklCLWsaQmEM5sIim9dzN7X3Be62nFG3ifmhKfOIi2n72/EJKpwIEgwDS1RPYYSIucLuf/caDI9nQ+0seXqZI9m5myIw1OAKYNLE16SYYmAKo3zJL5v706qWJSwCaTd/HUrYwHcFU2WvKyRYsDb8rIM92c++KpgCA3FNTVqVZ6iRV9DMzYUGzWHgBAhr6wu5yvv9qa3/V9oDxZ61z4GSRNx9kUbsogWoFvx3E+AJ5E/Sv7FznqJlp+azLLiSLBz1yviaXMf0fDJuRAt1oOd6NN7wgTN7h91NHUHUJo6bLDbzwCSME0Faar1BHkntudyA6uepsqs9MDA5EEm0j8+3dh99zY7SsR2FQGIBsFK6frw55G6S2F6h3dAlRVh8EhHpoWdC3g0oMb3651shFhRULXF8aVr7asH4rJ3jUU70CrCFKXVEZMyd8iKv8U+2obhyL+E5hjDP1LABd0jyDf8j64ffxqOPTtOUZUmaAVW3S9F/KK6QboS0uL3cUUm8Xi0A1Fp5ZQEi71AKw7x+ZidNIBgB"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":1351,"cache_read_input_tokens":61920,"cache_creation":{"ephemeral_5m_input_tokens":1351,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWCwr69tw6XNXPvb7ynLW","type":"assistant","uuid":"219dc67d-77d3-48d3-a32d-49a3604e7699","timestamp":"2025-12-17T21:11:07.536Z"}
{"parentUuid":"219dc67d-77d3-48d3-a32d-49a3604e7699","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"wiggly-wishing-valley","message":{"model":"claude-opus-4-5-20251101","id":"msg_01CLHz4JZJWB2ZrT8VHQSsxa","type":"message","role":"assistant","content":[{"type":"text","text":"Done. Tool results now use just `pretty-content` class, so they'll render with the regular sans-serif font like other markdown content. Error results still get the red styling via `result-error`."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":1351,"cache_read_input_tokens":61920,"cache_creation":{"ephemeral_5m_input_tokens":1351,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWCwr69tw6XNXPvb7ynLW","type":"assistant","uuid":"5892351c-e57a-4e85-acad-5f99442ca260","timestamp":"2025-12-17T21:11:08.737Z"}
{"type":"file-history-snapshot","messageId":"4a79b58b-ec14-4e24-bb3e-ddd0ab305302","snapshot":{"messageId":"4a79b58b-ec14-4e24-bb3e-ddd0ab305302","trackedFileBackups":{"claude-code-timeline.html":{"backupFileName":"cc3a5887c9685f80@v5","version":5,"backupTime":"2025-12-17T21:11:19.107Z"}},"timestamp":"2025-12-17T21:11:19.100Z"},"isSnapshotUpdate":false}
{"parentUuid":"5892351c-e57a-4e85-acad-5f99442ca260","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"wiggly-wishing-valley","type":"user","message":{"role":"user","content":"Which markdown render library are we using?"},"uuid":"4a79b58b-ec14-4e24-bb3e-ddd0ab305302","timestamp":"2025-12-17T21:11:19.095Z","thinkingMetadata":{"level":"high","disabled":false,"triggers":[]}}
{"parentUuid":"4a79b58b-ec14-4e24-bb3e-ddd0ab305302","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"wiggly-wishing-valley","message":{"model":"claude-opus-4-5-20251101","id":"msg_01BA9J2ShPnsjoncVRFqy7Z3","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"The user is asking which markdown rendering library is being used. Looking at the code, there's no external markdown library - the `renderMarkdown` function is a custom implementation using regex replacements. Let me explain this.","signature":"Eo8DCkYIChgCKkB1H3zf+c1a2DMgVuu/CHKuheohrifTtTkmBQ0JWono1T5wF9BUa3OYpmpdZ5nb/BpfpXea4FjOfID1a3SJf0FoEgwQFItr9OsBDkqN46IaDEOXJBbPUJgwdnBgNyIwVoGQ/PRLtjgxQg+7OQN2Abuxo398w0VrLt6cec/uG988bpAR3bkj3PN0bXJjI7drKvYB4kI0FIF/fitPzYKVfpuMj8snRRwR5Z4qgHowMrSYF16l5ZyshoorrH3zSU4uBeHtSVZOXIUI8cZvuu5C87RcgdrxTJLXdKJ+m+mbiJBbyVOjy5iv4wY8ZNoE64NfBdVlEz+ZxCg2gDl+yT/WH2K1AlprzG3eqdLBg5bgBQcFW7/U2WAPcs7lN05H1xiv54+U6wUZObkof9CVz0ashj0BN3dErr+JxzHJDCE3kU5NjmW6vGTm5p2fhLwIvW/mW+BPZuei8BAlTIMsdqrAj3aks1ANdITNTVsRxpL1SgL2uBbLY4wThlnJLR+QLT2m4cu7aEocgNQ/GAE="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":11,"cache_read_input_tokens":63391,"cache_creation":{"ephemeral_5m_input_tokens":11,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard"}},"requestId":"req_011CWCwsHwqdAEEjjGhDMTqM","type":"assistant","uuid":"dd7d9485-779a-4af9-9d24-76634693e3d8","timestamp":"2025-12-17T21:11:21.872Z"}
{"parentUuid":"dd7d9485-779a-4af9-9d24-76634693e3d8","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"wiggly-wishing-valley","message":{"model":"claude-opus-4-5-20251101","id":"msg_01BA9J2ShPnsjoncVRFqy7Z3","type":"message","role":"assistant","content":[{"type":"text","text":"None - it's a hand-rolled implementation using regex. The `renderMarkdown` function at line 1644 does basic pattern matching:\n\n```javascript\nfunction renderMarkdown(text) {\n // Escape HTML first\n let html = escapeHtml(text);\n\n // Code blocks (```...```)\n // Inline code (`...`)\n // Headers (# through ######)\n // Bold/italic (**, *, __, _)\n // Links ([text](url))\n // Blockquotes (>)\n // Unordered lists (- or *)\n // Ordered lists (1.)\n // Paragraphs (double newlines)\n\n return DOMPurify.sanitize(html);\n}\n```\n\nIt's lightweight but limited - no support for tables, nested lists, footnotes, etc. If you want more complete markdown support, you could swap in a library like:\n\n- **marked** (~40KB) - fast, popular\n- **markdown-it** (~100KB) - extensible, CommonMark compliant \n- **micromark** (~14KB) - tiny, CommonMark compliant\n\nWant me to replace the hand-rolled version with one of these?"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":11,"cache_read_input_tokens":63391,"cache_creation":{"ephemeral_5m_input_tokens":11,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard"}},"requestId":"req_011CWCwsHwqdAEEjjGhDMTqM","type":"assistant","uuid":"fb63469a-6e57-428d-a275-a5e4a992ed67","timestamp":"2025-12-17T21:11:27.470Z"}
{"type":"file-history-snapshot","messageId":"0cf6fc83-5935-4eb7-897f-2d189e1c5bf0","snapshot":{"messageId":"0cf6fc83-5935-4eb7-897f-2d189e1c5bf0","trackedFileBackups":{"claude-code-timeline.html":{"backupFileName":"cc3a5887c9685f80@v5","version":5,"backupTime":"2025-12-17T21:11:19.107Z"}},"timestamp":"2025-12-17T21:14:20.003Z"},"isSnapshotUpdate":false}
{"parentUuid":"fb63469a-6e57-428d-a275-a5e4a992ed67","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"wiggly-wishing-valley","type":"user","message":{"role":"user","content":"We need to use a library that can handle tables, can markdown-it do it?"},"uuid":"0cf6fc83-5935-4eb7-897f-2d189e1c5bf0","timestamp":"2025-12-17T21:14:19.996Z","thinkingMetadata":{"level":"high","disabled":false,"triggers":[]}}
{"parentUuid":"0cf6fc83-5935-4eb7-897f-2d189e1c5bf0","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"wiggly-wishing-valley","message":{"model":"claude-opus-4-5-20251101","id":"msg_01XtT4ak8p9R479K2A1QefES","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"The user wants to know if markdown-it can handle tables. Yes, markdown-it does support tables - it's actually built into the core library when you enable it (it's part of GFM - GitHub Flavored Markdown). You can enable it by using the preset or enabling specific features.\n\nLet me confirm and then offer to implement it.","signature":"EukDCkYIChgCKkDsJCvUX0fDVZWphnU0JKDaEqFqrgF7GkZmGznBfrSH//mKh0GarYFrPYkdXvOEyRvhSdoctIHJjhN3nVRLfb9sEgxC8hIALPGT7DSzUdIaDPpNt5YlA4dzzUm3YiIwdkTyNF/VEt7MgbkkYltLYc9rrZ6+OYZBgcLeZ6EDpEpqm4ahtzC5zlftpMHOyjbLKtACTbetUMInXCCEEljgeNUClX8KVjG4nONYc0rcy9tMTbyVf4gcTcF9jfonfDpkCcB8e0AygdnAYxckMeBPoywX+0In5os+t9mD05PfRmSyj8luVdgl34BJ3vyvkLbm+sKjiSwwNbdmRkrrG0ysWAm/9sKO3Ajh2Fgoow87hqAKU58ZbZNC0+Sp+OXbulnOFbgjZdb3GcX+l+fj/3ycZJFQudBmpg177SLLVVNwsTK97FJxJweepwOxT8zxS7PgP4WgpxW2B8eUFsC4TmLk91miOp+YmUrRUzvykn3pVexIkwRHJb4GulcjQWP/vmYB5yRC6TXkMA4Ff4rhYEYQ30A6t5erxzZ2M/0otf4szqDFrXpI55NET5TYsRnM1rURDtBPp+GGHS3RCRRl0ye0OMBwqGZabZliyMpgmOwKL5GQYbg66rGanUr+M/cISdxtEv/bGAE="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":21,"cache_read_input_tokens":63742,"cache_creation":{"ephemeral_5m_input_tokens":21,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard"}},"requestId":"req_011CWCx6dUSurimukTryiFtA","type":"assistant","uuid":"3b321731-37ae-4406-9f97-564c049c5e00","timestamp":"2025-12-17T21:14:24.987Z"}
{"parentUuid":"3b321731-37ae-4406-9f97-564c049c5e00","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"wiggly-wishing-valley","message":{"model":"claude-opus-4-5-20251101","id":"msg_01XtT4ak8p9R479K2A1QefES","type":"message","role":"assistant","content":[{"type":"text","text":"Yes, markdown-it supports tables out of the box. You just need to enable them when initializing:\n\n```javascript\nconst md = markdownit({\n html: false, // Disable raw HTML (we want DOMPurify to handle safety)\n linkify: true, // Auto-convert URLs to links\n typographer: true // Smart quotes, dashes, etc.\n}).enable('table'); // Enable GFM tables\n```\n\nTables work with the standard GFM syntax:\n\n```markdown\n| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |\n```\n\nWant me to replace the hand-rolled regex with markdown-it?"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":21,"cache_read_input_tokens":63742,"cache_creation":{"ephemeral_5m_input_tokens":21,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard"}},"requestId":"req_011CWCx6dUSurimukTryiFtA","type":"assistant","uuid":"0871c223-0771-4454-981e-253ebedd8ca0","timestamp":"2025-12-17T21:14:28.213Z"}
{"type":"file-history-snapshot","messageId":"fe303334-fd21-4f63-a2bd-e8618891a887","snapshot":{"messageId":"fe303334-fd21-4f63-a2bd-e8618891a887","trackedFileBackups":{"claude-code-timeline.html":{"backupFileName":"cc3a5887c9685f80@v5","version":5,"backupTime":"2025-12-17T21:11:19.107Z"}},"timestamp":"2025-12-17T21:17:00.810Z"},"isSnapshotUpdate":false}
{"parentUuid":"0871c223-0771-4454-981e-253ebedd8ca0","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"wiggly-wishing-valley","type":"user","message":{"role":"user","content":"do it - use <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>"},"uuid":"fe303334-fd21-4f63-a2bd-e8618891a887","timestamp":"2025-12-17T21:17:00.804Z","thinkingMetadata":{"level":"high","disabled":false,"triggers":[]}}
{"parentUuid":"fe303334-fd21-4f63-a2bd-e8618891a887","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"wiggly-wishing-valley","message":{"model":"claude-opus-4-5-20251101","id":"msg_015p8QYXzRSTb7dqkzsEFGDt","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to:\n1. Add the markdown-it library via CDN\n2. Replace the hand-rolled renderMarkdown function with markdown-it\n\nLet me do this step by step.","signature":"EsQCCkYIChgCKkCO4dK66+tZ+bsRminzp3SUcdUwOUsynI2M/a6nhVqd+EOEhYZ69dKRbT778TAHRJYSd9jYT71W4D99zgdH8g1OEgwveCWiMP5NVqzQ/RwaDHXLehS4Lfc19rYpnCIwLUKW7tbHfYwqmjuuG7svLRSGBWLPK9P3J4mN0dbTFWoYcZ9x6a6F/g2a+ISLgoItKqsBRhHuHiFb1tfaKR8CdXmsd1Wc/T1REXPsL/Hdx3jch/K/DLhtEnPxU7MoK2sq1cWs+L4prK/BjH7LVxvvhPw3kVUUODjSXVAB+uEwZja3FAzQr7smDJ71En9H8O2Woe7PW4J9SVDhcnQ01nu5GMi64BPR3k1RptN6iql8b4K3+BfSUQzOHnGoa0gS9i7e1yiXQ0pgVqoIBpN+1Gp7X2jDr6w/BnoV/jf1IRVfGAE="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":217,"cache_read_input_tokens":64019,"cache_creation":{"ephemeral_5m_input_tokens":217,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard"}},"requestId":"req_011CWCxJVFRnT84RMgFWTzXg","type":"assistant","uuid":"89a210a7-e48b-4d2f-8920-c4844a6c3de2","timestamp":"2025-12-17T21:17:04.508Z"}
{"parentUuid":"89a210a7-e48b-4d2f-8920-c4844a6c3de2","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/tools","sessionId":"43daf8c0-2ba6-4328-9f98-582b7b4b4cdd","version":"2.0.71","gitBranch":"main","slug":"wiggly-wishing-valley","message":{"model":"claude-opus-4-5-20251101","id":"msg_015p8QYXzRSTb7dqkzsEFGDt","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01GZjFCciASFZ2sJu9fmBgkB","name":"Edit","input":{"file_path":"/Users/simon/Dropbox/dev/tools/claude-code-timeline.html","old_string":"<script src=\"https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js\" integrity=\"sha384-osZDKVu4ipZP703HmPOhWdyBajcFyjX2Psjk//TG1Rc0AdwEtuToaylrmcK3LdAl\" crossorigin=\"anonymous\"></script>","new_string":"<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>"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":217,"cache_read_input_tokens":64019,"cache_creation":{"ephemeral_5m_input_tokens":217,"ephemeral_1h_input_tokens":0},"output_tokens":468,"service_tier":"standard"}},"requestId":"req_011CWCxJVFRnT84RMgFWTzXg","type":"assistant","uuid":"3713098f-84f2-4c12-853c-491ff7a74095","timestamp":"2025-12-17T21:17:12.460Z"}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment