<!doctype html>
<!--
  Trace Viewer Template - Renders all parts on the right panel.

  Supported ProcessorPart types:
  - Text parts (text/plain, text/*)
  - Image parts (image/png, image/jpeg, image/webp, etc.)
  - Audio parts (audio/wav, audio/mp3, audio/*, etc.)
  - Function calls (name, ID, arguments)
  - Function responses (name, ID, response content, with embedded media)
  - Executable code (language, code block)
  - Code execution results (outcome status, output)
  - File data (URI links, display name, mime type)
  - Thought parts (thought indicator badge)
  - Unknown/custom parts (rendered as JSON fallback)
-->
<html>
  <head>
    <meta charset="UTF-8">
    <title>Trace Viewer</title>
    <link
      href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&display=swap"
      rel="stylesheet"
    />
    <style>
      body {{
        font-family: "SF Mono", "Consolas", "Monaco", monospace;
        margin: 0;
        display: flex;
        height: 100vh;
        color: #1a1a1a;
        font-size: 12px;
        background: #fff;
        overflow: hidden;
      }}
      #left-panel {{
        width: 25%;
        min-width: 180px;
        overflow-x: hidden;
        overflow-y: auto;
        border-right: none;
        padding: 6px;
        box-sizing: border-box;
        background-color: #fafafa;
        flex-shrink: 0;
      }}
      #resizer {{
        width: 6px;
        background: #e0e0e0;
        cursor: col-resize;
        flex-shrink: 0;
        transition: background 0.15s;
      }}
      #resizer:hover, #resizer.dragging {{
        background: #0066cc;
      }}
      #right-panel {{
        flex: 1;
        min-width: 200px;
        overflow: auto;
        padding: 12px;
        box-sizing: border-box;
        background: #fff;
      }}
      h3 {{
        font-weight: 600;
        font-size: 13px;
        color: #000;
        margin: 0;
        padding: 4px 0;
        border-bottom: 1px solid #000;
        position: sticky;
        top: 0;
        background: #fafafa;
        z-index: 10;
      }}
      .trace-subtitle {{
        display: flex;
        justify-content: space-between;
        align-items: center;
        font-size: 11px;
        color: #555;
        margin: 0;
        padding: 4px 0;
        position: sticky;
        top: 24px;
        background: #fafafa;
        z-index: 10;
      }}
      .trace-toolbar {{
        display: flex;
        justify-content: space-between;
        align-items: center;
        padding: 4px 0;
        margin-bottom: 6px;
        border-bottom: 1px solid #ddd;
        gap: 8px;
        position: sticky;
        top: 48px;
        background: #fafafa;
        z-index: 10;
      }}
      .trace-controls {{
        display: flex;
        gap: 4px;
        align-items: center;
      }}
      .trace-controls button {{
        font-size: 10px;
        padding: 2px 6px;
        border: 1px solid #999;
        border-radius: 0;
        background: #fff;
        cursor: pointer;
        color: #333;
        font-family: inherit;
      }}
      .trace-controls button:hover {{
        background-color: #f0f0f0;
      }}
      .trace-stats {{
        font-size: 10px;
        color: #666;
        display: flex;
        gap: 8px;
      }}
      .trace-stats span {{
        padding: 1px 4px;
        background: #f0f0f0;
        border: 1px solid #ddd;
      }}
      ul {{
        list-style-type: none;
        padding-left: 0;
        margin: 0;
        display: flex;
        flex-direction: column;
      }}
      li {{
        padding: 1px 0;
        display: flex;
        flex-direction: column;
        width: 100%;
      }}
      li:last-child {{
        border-bottom: none;
      }}
      .event-item {{
        padding: 2px 4px;
        cursor: pointer;
        border-radius: 0;
        display: flex;
        flex-direction: column;
        width: 100%;
        box-sizing: border-box;
        min-width: 0;
        word-wrap: break-word;
        overflow-wrap: break-word;
      }}
      .event-item:hover {{
        background: #f0f0f0;
      }}
      .event-item-in {{
        align-self: flex-start;
        background-color: #e8f4ff;
        border-left: 4px solid #0066cc;
        width: 94%;
      }}
      .event-item-out {{
        align-self: flex-end;
        background-color: #e8ffe8;
        border-right: 4px solid #228b22;
        border-left: none;
        width: 94%;
      }}
      .event-item-out .event-header {{
        flex-direction: row-reverse;
      }}
      .event-item-out .event-content {{
        text-align: left;
      }}
      .event-item-out .event-footer {{
        flex-direction: row-reverse;
      }}
      .event-item-out .event-footer .metadata-btn {{
        margin-left: auto;
      }}
      .event-item-sub {{
        align-self: flex-start;
        background-color: #fff8f0;
        border-left: 3px solid #cc6600;
      }}
      .selected > .event-item {{
        background: #e0e0e0;
      }}
      .focused > .event-item {{
        background: #e0e0e0;
      }}
      .collapsible > .event-item::before {{
        content: none;
      }}
      .collapsed > .sub-trace-container {{
        display: none;
      }}
      img {{
        max-width: 100%;
        height: auto;
        display: block;
        margin-bottom: 8px;
        border: 1px solid #ccc;
        border-radius: 0;
      }}
      pre {{
        white-space: pre-wrap;
        margin: 0 0 8px 0;
        background-color: #f5f5f5;
        padding: 6px;
        border-radius: 0;
        border: 1px solid #ddd;
        font-size: 11px;
        font-family: inherit;
      }}
      .subtrace-container {{
        display: flex;
        flex-direction: column;
        gap: 4px;
      }}
      .audio-controls-header {{
        display: flex;
        justify-content: flex-start;
        align-items: center;
        gap: 8px;
        padding: 4px 6px;
        background: #f0f0f0;
        border-radius: 0;
        margin-bottom: 8px;
        border: 1px solid #ccc;
      }}
      .audio-play-btn {{
        display: inline-flex;
        align-items: center;
        gap: 4px;
        padding: 3px 8px;
        border: 1px solid #999;
        border-radius: 0;
        cursor: pointer;
        font-size: 11px;
        font-weight: 400;
        font-family: inherit;
        background: #fff;
        color: #333;
      }}
      .audio-play-btn:disabled {{
        opacity: 0.5;
        cursor: not-allowed;
      }}
      .audio-play-btn-input {{
        border-color: #0066cc;
        color: #0066cc;
      }}
      .audio-play-btn-input:hover:not(:disabled) {{
        background-color: #e6f0ff;
      }}
      .audio-play-btn-output {{
        border-color: #228b22;
        color: #228b22;
      }}
      .audio-play-btn-output:hover:not(:disabled) {{
        background-color: #e6f5e6;
      }}
      .audio-play-btn.playing {{
        background-color: #fff3e0;
        border-color: #cc6600;
        color: #cc6600;
      }}
      .content-block {{
        width: 96%;
        padding: 3px 6px;
        border-radius: 0;
        position: relative;
        box-sizing: border-box;
      }}
      .content-block-input {{
        align-self: flex-start;
        background-color: #e8f4ff;
        border-left: 4px solid #0066cc;
        width: 94%;
      }}
      .content-block-output {{
        align-self: flex-end;
        background-color: #e8ffe8;
        border-right: 4px solid #228b22;
        border-left: none;
        width: 94%;
      }}
      .content-block-output .block-header {{
        flex-direction: row-reverse;
      }}
      .content-block-output .block-time {{
        margin-left: 0;
        margin-right: auto;
      }}
      .content-block-output .block-content {{
        text-align: left;
      }}
      .content-block-output .block-content pre {{
        text-align: left;
      }}
      .content-block-output .block-content img {{
        margin-left: 0;
      }}
      .content-block.audio-playing, .event-block.audio-playing {{
        background-color: #fff8f0;
        border-color: #cc6600;
      }}
      .content-block.event-highlighted {{
        background: #ffffcc;
        border-color: #999;
      }}
      .block-header {{
        display: flex;
        gap: 6px;
        align-items: center;
        margin-bottom: 1px;
        font-size: 9px;
        color: #666;
      }}
      .block-meta {{
        font-weight: 400;
      }}
      .block-content {{
        font-size: 10px;
        line-height: 1.3;
      }}
      .block-content pre {{
        margin: 0;
        background: transparent;
        border: none;
        padding: 0;
      }}
      .block-content img {{
        max-width: 200px;
        max-height: 120px;
        object-fit: contain;
        border-radius: 0;
        margin: 1px 0;
      }}
      .block-time {{
        font-size: 10px;
        color: #888;
        margin-left: auto;
      }}
      .audio-indicator {{
        display: flex;
        align-items: center;
        gap: 4px;
        padding: 2px 4px;
        background: #f0f0f0;
        border-radius: 0;
        font-size: 11px;
        color: #555;
        border: 1px solid #ddd;
      }}
      .audio-indicator-icon {{
        font-size: 12px;
      }}
      .event-type {{
        font-weight: 600;
        font-size: 9px;
        padding: 2px 5px;
        border-radius: 0;
        white-space: nowrap;
        text-align: center;
        display: inline-block;
        font-family: inherit;
        text-transform: uppercase;
        letter-spacing: 0.5px;
      }}
      .event-type-in {{
        background-color: #0066cc;
        color: #fff;
      }}
      .event-type-out {{
        background-color: #228b22;
        color: #fff;
      }}
      .event-type-sub {{
        background-color: #cc6600;
        color: #fff;
      }}
      .event-details {{
        flex-grow: 1;
        overflow: hidden;
        font-size: 11px;
        display: flex;
        flex-direction: column;
        min-width: 0;
        word-wrap: break-word;
        overflow-wrap: break-word;
      }}
      .event-header {{
        display: flex;
        align-items: center;
        gap: 4px;
        flex-wrap: wrap;
        min-width: 0;
        word-wrap: break-word;
        overflow-wrap: break-word;
      }}
      .event-header > span {{
        word-wrap: break-word;
        overflow-wrap: break-word;
      }}
      .event-content {{
        margin: 2px 0;
      }}
      .event-content pre {{
        margin: 0;
        background: transparent;
        border: none;
        padding: 0;
        white-space: pre-wrap;
      }}
      .event-content img.thumb {{
        max-width: 60px;
        max-height: 45px;
        object-fit: contain;
        border-radius: 0;
      }}
      .event-footer {{
        display: flex;
        justify-content: space-between;
        align-items: center;
        font-size: 10px;
        color: #888;
        margin-top: 2px;
      }}
      .event-meta {{
        display: flex;
        gap: 6px;
        flex-wrap: wrap;
      }}
      .no-metadata {{
        color: #aaa;
        font-style: normal;
      }}
      .metadata-btn {{
        font-size: 10px;
        padding: 1px 4px;
        border: 1px solid #999;
        border-radius: 0;
        background: #fff;
        cursor: pointer;
        color: #555;
        font-family: inherit;
      }}
      .metadata-btn:hover {{
        background-color: #f0f0f0;
      }}
      .metadata-content {{
        display: none;
        margin-top: 4px;
        padding: 4px;
        background: #f5f5f5;
        border-radius: 0;
        font-size: 10px;
        border: 1px solid #ddd;
      }}
      .metadata-content.expanded {{
        display: block;
      }}
      .metadata-content pre {{
        margin: 0;
        white-space: pre-wrap;
        font-size: 10px;
      }}
      .event-time {{
        color: #666;
        white-space: nowrap;
        flex-shrink: 0;
      }}
      .collapse-btn {{
        font-size: 11px;
        width: 16px;
        height: 16px;
        padding: 0;
        border: 1px solid #999;
        border-radius: 0;
        background: #fff;
        cursor: pointer;
        color: #333;
        display: flex;
        align-items: center;
        justify-content: center;
        flex-shrink: 0;
        font-family: inherit;
      }}
      .collapse-btn:hover {{
        background-color: #f0f0f0;
      }}
      .thought-badge {{
        display: inline-block;
        padding: 2px 6px;
        background-color: #f0e6ff;
        border: 1px solid #9966cc;
        border-radius: 0;
        color: #663399;
        font-size: 10px;
        font-weight: 500;
        margin: 4px 0;
      }}
      .code-lang {{
        color: #666;
        font-size: 10px;
        font-weight: normal;
      }}
      .code-block {{
        background-color: #1e1e1e;
        color: #d4d4d4;
        padding: 8px;
        border-radius: 0;
        overflow-x: auto;
        font-family: inherit;
        font-size: 11px;
        white-space: pre-wrap;
        word-break: break-word;
      }}
      .code-output {{
        background-color: #f5f5f5;
        border: 1px solid #ddd;
        padding: 8px;
        font-family: inherit;
        font-size: 11px;
        white-space: pre-wrap;
        word-break: break-word;
      }}
      .outcome-success {{
        color: #228b22;
        font-weight: 500;
      }}
      .outcome-error {{
        color: #cc3300;
        font-weight: 500;
      }}
      .file-info {{
        padding: 4px 8px;
        background: #f5f5f5;
        border: 1px solid #ddd;
        display: inline-block;
        margin: 4px 0;
      }}
      .file-info a {{
        color: #0066cc;
        text-decoration: none;
      }}
      .file-info a:hover {{
        text-decoration: underline;
      }}
      .file-mime {{
        color: #888;
        font-size: 10px;
      }}
      .media-indicator {{
        padding: 4px 8px;
        background: #f0f0f0;
        border: 1px solid #ddd;
        margin: 2px 0;
        font-size: 11px;
      }}
      .function-call-badge {{
        display: inline-block;
        padding: 1px 4px;
        background-color: #fff0e6;
        border: 1px solid #cc6600;
        color: #cc6600;
        font-size: 9px;
        font-weight: 500;
        text-transform: uppercase;
      }}
      .function-response-badge {{
        display: inline-block;
        padding: 1px 4px;
        background-color: #e6fff0;
        border: 1px solid #228b22;
        color: #228b22;
        font-size: 9px;
        font-weight: 500;
        text-transform: uppercase;
      }}
      .event-type-badge {{
        display: inline-block;
        padding: 2px 6px;
        border-radius: 3px;
        font-size: 10px;
        font-weight: 600;
        margin-right: 4px;
      }}
      .badge-function-call {{
        background-color: #fff3e0;
        color: #e65100;
        border: 1px solid #ffb74d;
      }}
      .badge-function-response {{
        background-color: #e3f2fd;
        color: #1565c0;
        border: 1px solid #64b5f6;
      }}
      .badge-code {{
        background-color: #f3e5f5;
        color: #7b1fa2;
        border: 1px solid #ba68c8;
      }}
      .badge-code-ok {{
        background-color: #e8f5e9;
        color: #2e7d32;
        border: 1px solid #81c784;
      }}
      .badge-code-error {{
        background-color: #ffebee;
        color: #c62828;
        border: 1px solid #ef9a9a;
      }}
      .badge-error {{
        background-color: #ffebee;
        color: #c62828;
        border: 1px solid #ef9a9a;
      }}
      .badge-cancelled {{
        background-color: #fff3e0;
        color: #e65100;
        border: 1px solid #ffb74d;
      }}
      .error-event {{
        background-color: #ffebee;
        border-left: 4px solid #c62828;
        border-right: none;
      }}
      .error-event .event-block-header {{
        background-color: transparent;
        border: none;
      }}
      .trace-status-badge {{
        display: inline-block;
        padding: 2px 6px;
        border-radius: 3px;
        font-size: 9px;
        font-weight: 600;
        text-transform: uppercase;
        letter-spacing: 0.3px;
        margin-left: 8px;
      }}
      .trace-status-error {{
        background-color: #ffebee;
        color: #c62828;
        border: 1px solid #ef9a9a;
      }}
      .trace-status-cancelled {{
        background-color: #fff3e0;
        color: #e65100;
        border: 1px solid #ffb74d;
      }}
      .function-block {{
        margin: 8px 0;
        padding: 8px;
        background: #f9f9f9;
        border: 1px solid #e0e0e0;
        border-radius: 0;
      }}
      .function-block:first-child {{
        margin-top: 0;
      }}
      .event-block {{
        margin-bottom: 4px;
        overflow: hidden;
      }}
      .event-block-header {{
        display: flex;
        align-items: center;
        gap: 6px;
        padding: 4px 8px;
        cursor: pointer;
        background: #f5f5f5;
        border: 1px solid #ddd;
      }}
      .event-block-header:hover {{
        background: #eee;
      }}
      .event-block.content-block-input {{
        background-color: #e8f4ff;
        border-left: 4px solid #0066cc;
      }}
      .event-block.content-block-input .event-block-header {{
        background-color: transparent;
        border: none;
      }}
      .event-block.content-block-output {{
        background-color: #e8ffe8;
        border-right: 4px solid #228b22;
      }}
      .event-block.content-block-output .event-block-header {{
        background-color: transparent;
        border: none;
      }}
      .event-block-title {{
        font-weight: 500;
        font-size: 11px;
      }}
      .event-block-meta {{
        font-size: 10px;
        color: #666;
        display: flex;
        gap: 8px;
        margin-left: auto;
        flex-wrap: wrap;
        align-items: center;
      }}
      .event-metadata-badge {{
        background: #fffde7;
        border: 1px solid #ffc107;
        color: #6d4c00;
        padding: 1px 4px;
        border-radius: 3px;
        font-size: 9px;
        max-width: 300px;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
        cursor: help;
      }}
      .metadata-only-indicator {{
        padding: 4px;
        font-style: italic;
        color: #666;
      }}
      .event-block-body {{
        padding: 8px;
        border-top: 1px solid rgba(0,0,0,0.1);
        overflow-x: hidden;
        overflow-wrap: anywhere;
        word-wrap: break-word;
      }}
      .event-block-body pre {{
        overflow-x: auto;
        max-width: 100%;
      }}
      .event-block.expanded .event-block-body {{
        display: block;
      }}
      .sub-event {{
        padding: 6px 8px;
        margin-bottom: 4px;
        background: #fafafa;
        border: 1px solid #eee;
        font-size: 11px;
      }}
      .sub-event:last-child {{
        margin-bottom: 0;
      }}
      .sub-event-header {{
        display: flex;
        justify-content: space-between;
        align-items: center;
        gap: 8px;
        margin-bottom: 4px;
        font-size: 10px;
        color: #666;
      }}
      .sub-event-meta {{
        display: flex;
        gap: 6px;
        flex-wrap: wrap;
      }}
      .sub-event-time {{
        color: #888;
        white-space: nowrap;
      }}
      .sub-event-content {{
        font-size: 11px;
      }}
      .sub-event-content pre {{
        margin: 0;
        background: transparent;
        border: none;
        padding: 0;
        white-space: pre-wrap;
        word-break: break-word;
        font-size: 11px;
      }}
      .sub-event-metadata {{
        margin-top: 4px;
        padding: 6px 8px;
        background: #fff8e1;
        border: 1px solid #ffcc80;
        border-radius: 3px;
        font-size: 10px;
        overflow: hidden;
        word-break: break-word;
      }}
      .sub-event-metadata pre {{
        margin: 0;
        white-space: pre-wrap;
        font-size: 10px;
        overflow: hidden;
        word-break: break-word;
      }}
      .sub-event-metadata details {{
        margin: 0;
      }}
      .sub-event-metadata summary {{
        cursor: pointer;
        color: #666;
        font-size: 9px;
      }}
      .expand-triangle {{
        display: inline-block;
        transition: transform 0.2s ease;
        margin-right: 4px;
      }}
      details[open] > summary .expand-triangle {{
        transform: rotate(90deg);
      }}
      details > summary {{
        list-style: none;
      }}
      details > summary::-webkit-details-marker {{
        display: none;
      }}
      .substream-badge {{
        display: inline-block;
        padding: 3px 8px;
        border-radius: 3px;
        font-size: 10px;
        font-weight: 500;
        margin-left: 10px;
        margin-right: 6px;
      }}
      .substream-badge-input {{
        background-color: #e8f4ff;
        border: 1px solid #0066cc;
        color: #0066cc;
      }}
      .substream-badge-output {{
        background-color: #e8ffe8;
        border: 1px solid #228b22;
        color: #228b22;
      }}
      .block-footer {{
        display: flex;
        justify-content: flex-end;
        align-items: center;
        margin-top: 8px;
        margin-right: 4px;
        gap: 8px;
      }}
      .sub-event-footer {{
        display: flex;
        justify-content: flex-start;
        align-items: center;
        margin-top: 4px;
        padding-top: 4px;
        gap: 8px;
      }}
      .role-badge {{
        display: inline-block;
        padding: 2px 6px;
        border-radius: 3px;
        font-size: 9px;
        font-weight: 600;
        text-transform: uppercase;
        letter-spacing: 0.3px;
      }}
      .role-user {{
        background-color: #e3f2fd;
        color: #1565c0;
        border: 1px solid #90caf9;
      }}
      .role-model {{
        background-color: #e8f5e9;
        color: #2e7d32;
        border: 1px solid #a5d6a7;
      }}
      .role-system {{
        background-color: #f5f5f5;
        color: #616161;
        border: 1px solid #bdbdbd;
      }}
      .role-function-response {{
        background-color: #e3f2fd;
        color: #1565c0;
        border: 1px solid #64b5f6;
      }}
      .role-unknown {{
        background-color: #fff8e1;
        color: #f57c00;
        border: 1px solid #ffcc80;
      }}
      .block-header-left {{
        display: flex;
        align-items: center;
        gap: 6px;
      }}
      .sub-event-role {{
        margin-left: auto;
      }}
    </style>
  </head>
  <body>
    <div id="left-panel" tabindex="0"></div>
    <div id="resizer"></div>
    <div id="right-panel"></div>
    <div id="parts-cache" style="display: none;"></div>
    <script>
      const rawData = {trace_json};
      const partsStore = rawData.parts_store;
      const traceData = rawData.trace;

      const eventsById = {{}};
      const eventToParentSubtrace = {{}};
      let currentSubtraceBlocks = [];
      let currentSubtraceStartTime = null;
      let rootTraceStartTime = null;
      let focusedLi = null;

      // Cache for rendered part content (keyed by part_ref)
      const partsCache = {{}};
      const partsCacheContainer = document.getElementById('parts-cache');

      function getCachedPartContent(partRef, renderType) {{
          // renderType: 'full' (right panel), 'thumb' (left panel thumbnail), 'audio' (audio element)
          const cacheKey = `${{partRef}}_${{renderType}}`;
          if (partsCache[cacheKey]) {{
              return partsCache[cacheKey].cloneNode(true);
          }}

          const partDict = partsStore[partRef];
          if (!partDict) return null;

          let mimetype = partDict.mimetype || '';
          let data = partDict.data || '';
          const part = partDict.part || {{}};

          if (part.inline_data && part.inline_data.mime_type && part.inline_data.data) {{
              mimetype = part.inline_data.mime_type;
              data = part.inline_data.data;
          }}

          let element = null;

          if (mimetype.startsWith('image/')) {{
              const img = document.createElement('img');
              img.src = `data:${{mimetype}};base64,${{data}}`;
              if (renderType === 'thumb') {{
                  img.className = 'thumb';
              }}
              element = img;
          }} else if (mimetype.startsWith('audio/')) {{
              const audio = document.createElement('audio');
              audio.controls = true;
              audio.src = getAudioDataUrl(mimetype, data);
              element = audio;
          }}

          if (element) {{
              partsCache[cacheKey] = element;
              partsCacheContainer.appendChild(element.cloneNode(true));
              return element.cloneNode(true);
          }}
          return null;
      }}

      function getPartDict(event) {{
        return event.part_hash !== undefined ? partsStore[event.part_hash] : null;
      }}

      function bytesToBase64(rawBytes) {{
        let binary = '';
        const len = rawBytes.byteLength;

        for (let i = 0; i < len; i++) {{
            binary += String.fromCharCode(rawBytes[i]);
        }}

        return btoa(binary);
      }}

      // Convert audio/l16 (raw 16-bit PCM) to WAV format data URL
      // audio/l16 is typically 24000Hz, mono, 16-bit signed little-endian
      function l16ToWavDataUrl(base64Data, sampleRate = 24000, numChannels = 1) {{
          const binaryString = atob(base64Data);
          const len = binaryString.length;
          const pcmData = new Uint8Array(len);
          for (let i = 0; i < len; i++) {{
              pcmData[i] = binaryString.charCodeAt(i);
          }}

          const bitsPerSample = 16;
          const byteRate = sampleRate * numChannels * (bitsPerSample / 8);
          const blockAlign = numChannels * (bitsPerSample / 8);
          const dataSize = pcmData.length;
          const headerSize = 44;
          const fileSize = headerSize + dataSize;

          const buffer = new ArrayBuffer(fileSize);
          const view = new DataView(buffer);

          // RIFF header
          view.setUint8(0, 'R'.charCodeAt(0));
          view.setUint8(1, 'I'.charCodeAt(0));
          view.setUint8(2, 'F'.charCodeAt(0));
          view.setUint8(3, 'F'.charCodeAt(0));
          view.setUint32(4, fileSize - 8, true);
          view.setUint8(8, 'W'.charCodeAt(0));
          view.setUint8(9, 'A'.charCodeAt(0));
          view.setUint8(10, 'V'.charCodeAt(0));
          view.setUint8(11, 'E'.charCodeAt(0));

          // fmt subchunk
          view.setUint8(12, 'f'.charCodeAt(0));
          view.setUint8(13, 'm'.charCodeAt(0));
          view.setUint8(14, 't'.charCodeAt(0));
          view.setUint8(15, ' '.charCodeAt(0));
          view.setUint32(16, 16, true); // subchunk1 size
          view.setUint16(20, 1, true); // audio format (PCM)
          view.setUint16(22, numChannels, true);
          view.setUint32(24, sampleRate, true);
          view.setUint32(28, byteRate, true);
          view.setUint16(32, blockAlign, true);
          view.setUint16(34, bitsPerSample, true);

          // data subchunk
          view.setUint8(36, 'd'.charCodeAt(0));
          view.setUint8(37, 'a'.charCodeAt(0));
          view.setUint8(38, 't'.charCodeAt(0));
          view.setUint8(39, 'a'.charCodeAt(0));
          view.setUint32(40, dataSize, true);

          // Copy PCM data
          const wavBytes = new Uint8Array(buffer);
          wavBytes.set(pcmData, headerSize);

          return `data:audio/wav;base64,${{bytesToBase64(wavBytes)}}`;
      }}

      // Get audio data URL, converting l16 to wav if needed
      function getAudioDataUrl(mimetype, data) {{
          if (mimetype === 'audio/l16' || mimetype.startsWith('audio/l16;')) {{
              // Parse sample rate from mime type if present (e.g., "audio/l16;rate=24000")
              let sampleRate = 24000;
              const rateMatch = mimetype.match(/rate=(\d+)/);
              if (rateMatch) {{
                  sampleRate = parseInt(rateMatch[1], 10);
              }}
              return l16ToWavDataUrl(data, sampleRate);
          }}
          return `data:${{mimetype}};base64,${{data}}`;
      }}

      // Combine multiple base64-encoded audio chunks into a single audio data URL
      // For L16, we need to decode each chunk, combine the binary, then create one WAV
      function combineAudioChunks(chunks, mimetype) {{
          if (!chunks || chunks.length === 0) return null;
          
          if (mimetype === 'audio/l16' || mimetype.startsWith('audio/l16;')) {{
              // For L16: decode all chunks, combine binary, then create WAV
              let totalLength = 0;
              const binaryChunks = chunks.map(base64Data => {{
                  const binaryString = atob(base64Data);
                  const len = binaryString.length;
                  const bytes = new Uint8Array(len);
                  for (let i = 0; i < len; i++) {{
                      bytes[i] = binaryString.charCodeAt(i);
                  }}
                  totalLength += len;
                  return bytes;
              }});
              
              // Combine all chunks into one Uint8Array
              const combinedPcm = new Uint8Array(totalLength);
              let offset = 0;
              binaryChunks.forEach(chunk => {{
                  combinedPcm.set(chunk, offset);
                  offset += chunk.length;
              }});
              
              // Parse sample rate from mime type
              let sampleRate = 24000;
              const rateMatch = mimetype.match(/rate=(\d+)/);
              if (rateMatch) {{
                  sampleRate = parseInt(rateMatch[1], 10);
              }}
              
              // Create WAV from combined PCM data
              const numChannels = 1;
              const bitsPerSample = 16;
              const byteRate = sampleRate * numChannels * (bitsPerSample / 8);
              const blockAlign = numChannels * (bitsPerSample / 8);
              const dataSize = combinedPcm.length;
              const headerSize = 44;
              const fileSize = headerSize + dataSize;

              const buffer = new ArrayBuffer(fileSize);
              const view = new DataView(buffer);

              // RIFF header
              view.setUint8(0, 'R'.charCodeAt(0));
              view.setUint8(1, 'I'.charCodeAt(0));
              view.setUint8(2, 'F'.charCodeAt(0));
              view.setUint8(3, 'F'.charCodeAt(0));
              view.setUint32(4, fileSize - 8, true);
              view.setUint8(8, 'W'.charCodeAt(0));
              view.setUint8(9, 'A'.charCodeAt(0));
              view.setUint8(10, 'V'.charCodeAt(0));
              view.setUint8(11, 'E'.charCodeAt(0));

              // fmt subchunk
              view.setUint8(12, 'f'.charCodeAt(0));
              view.setUint8(13, 'm'.charCodeAt(0));
              view.setUint8(14, 't'.charCodeAt(0));
              view.setUint8(15, ' '.charCodeAt(0));
              view.setUint32(16, 16, true);
              view.setUint16(20, 1, true);
              view.setUint16(22, numChannels, true);
              view.setUint32(24, sampleRate, true);
              view.setUint32(28, byteRate, true);
              view.setUint16(32, blockAlign, true);
              view.setUint16(34, bitsPerSample, true);

              // data subchunk
              view.setUint8(36, 'd'.charCodeAt(0));
              view.setUint8(37, 'a'.charCodeAt(0));
              view.setUint8(38, 't'.charCodeAt(0));
              view.setUint8(39, 'a'.charCodeAt(0));
              view.setUint32(40, dataSize, true);

              // Copy PCM data
              const wavBytes = new Uint8Array(buffer);
              wavBytes.set(combinedPcm, headerSize);

              return `data:audio/wav;base64,${{bytesToBase64(wavBytes)}}`;
          }} else {{
              // For other formats, just concatenate base64 (works for WAV since it's raw concatenation of audio data)
              const combinedData = chunks.join('');
              return `data:${{mimetype}};base64,${{combinedData}}`;
          }}
      }}

      function getTimeDeltaMs(start, current) {{
          const deltaMs = new Date(current).getTime() - new Date(start).getTime();
          return deltaMs;
      }}

      function getData(partDict) {{
          if (!partDict) return null;
          const part = partDict.part || {{}};
          if(part.inline_data && part.inline_data.mime_type && part.inline_data.data) {{
              return part.inline_data.data;
          }}
          return partDict.data || null;
      }}

      function formatDuration(deltaMs) {{
          if (deltaMs < 1000) {{
              return `${{deltaMs}}ms`;
          }}
          if (deltaMs < 60000) {{
              return `${{(deltaMs / 1000).toFixed(1)}}s`;
          }}
          const totalSeconds = Math.floor(deltaMs / 1000);
          const minutes = Math.floor(totalSeconds / 60);
          const seconds = totalSeconds % 60;
          if (seconds === 0) {{
              return `${{minutes}}m`;
          }}
          return `${{minutes}}m${{seconds}}s`;
      }}

      function getTraceDuration(trace) {{
          const startTime = new Date(trace.start_time);
          const endTime = new Date(trace.end_time);
          if (!trace.end_time) {{
            return 0;
          }}
          return endTime.getTime() - startTime.getTime();
      }}

      function getSubtraceDuration(subTrace) {{
          if (!subTrace || !subTrace.events) return {{ duration: 0, firstInputTime: null, lastOutputTime: null }};
          let firstInputTime = null;
          let lastOutputTime = null;
          subTrace.events.forEach(event => {{
              if (!event.sub_trace && event.timestamp) {{
                  if (event.is_input) {{
                      if (!firstInputTime) {{
                          firstInputTime = event.timestamp;
                      }}
                  }} else {{
                      lastOutputTime = event.timestamp;
                  }}
              }}
          }});
          if (firstInputTime && lastOutputTime) {{
              return {{
                  duration: getTimeDeltaMs(firstInputTime, lastOutputTime),
                  firstInputTime: firstInputTime,
                  lastOutputTime: lastOutputTime
              }};
          }}
          if (firstInputTime) {{
              return {{ duration: 0, firstInputTime: firstInputTime, lastOutputTime: null }};
          }}
          if (lastOutputTime) {{
              return {{ duration: 0, firstInputTime: null, lastOutputTime: lastOutputTime }};
          }}
          return {{ duration: 0, firstInputTime: subTrace.start_time, lastOutputTime: subTrace.end_time }};
      }}

      function renderPart(partDict, partHash) {{
          const div = document.createElement('div');
          if (!partDict) {{
              div.innerText = 'No part data';
              return div;
          }}

          // partDict is result of ProcessorPart.to_dict(), from trace.py _add_part()
          let mimetype = partDict.mimetype || '';
          let data = partDict.data || ''; // base64 string
          let text = partDict.text || '';
          const metadata = partDict.metadata || {{}};
          const part = partDict.part || {{}};

          if(part.inline_data && part.inline_data.mime_type && part.inline_data.data) {{
              mimetype = part.inline_data.mime_type;
              data = part.inline_data.data; // base64 string
              text = '';
          }} else if (part.text && !text) {{
              text = part.text;
          }}

          const mime_div = document.createElement('div');
          mime_div.innerHTML = `<b>MIME-type:</b> ${{mimetype}}`;
          div.appendChild(mime_div);

          if (part.thought) {{
              const thought_div = document.createElement('div');
              thought_div.innerHTML = '<span class="thought-badge">💭 Thought</span>';
              div.appendChild(thought_div);
          }}

          if (part.function_call) {{
              const fc_div = document.createElement('div');
              fc_div.innerHTML = `<b>Function Call:</b> ${{part.function_call.name || 'unknown'}}`;
              div.appendChild(fc_div);
              if (part.function_call.id) {{
                  const id_div = document.createElement('div');
                  id_div.innerHTML = `<b>ID:</b> ${{part.function_call.id}}`;
                  div.appendChild(id_div);
              }}
              if (part.function_call.args) {{
                  const args_div = document.createElement('div');
                  args_div.innerHTML = '<b>Arguments:</b>';
                  const args_pre = document.createElement('pre');
                  args_pre.innerText = JSON.stringify(part.function_call.args, null, 2);
                  args_div.appendChild(args_pre);
                  div.appendChild(args_div);
              }}
          }} else if (part.function_response) {{
              const fr_div = document.createElement('div');
              fr_div.innerHTML = `<b>Function Response:</b> ${{part.function_response.name || 'unknown'}}`;
              div.appendChild(fr_div);
              if (part.function_response.id) {{
                  const id_div = document.createElement('div');
                  id_div.innerHTML = `<b>ID:</b> ${{part.function_response.id}}`;
                  div.appendChild(id_div);
              }}
              if (part.function_response.response) {{
                  const resp_div = document.createElement('div');
                  resp_div.innerHTML = '<b>Response:</b>';
                  const resp_pre = document.createElement('pre');
                  resp_pre.innerText = JSON.stringify(part.function_response.response, null, 2);
                  resp_div.appendChild(resp_pre);
                  div.appendChild(resp_div);
              }}
              if (part.function_response.parts && part.function_response.parts.length > 0) {{
                  const parts_div = document.createElement('div');
                  parts_div.innerHTML = '<b>Media Content:</b>';
                  div.appendChild(parts_div);
                  part.function_response.parts.forEach((frPart, idx) => {{
                      if (frPart.inline_data && frPart.inline_data.mime_type && frPart.inline_data.data) {{
                          const frMime = frPart.inline_data.mime_type;
                          const frData = frPart.inline_data.data;
                          if (frMime.startsWith('image/')) {{
                              const img = document.createElement('img');
                              img.src = `data:${{frMime}};base64,${{frData}}`;
                              img.style.maxWidth = '100%';
                              div.appendChild(img);
                          }} else if (frMime.startsWith('audio/')) {{
                              const audio = document.createElement('audio');
                              audio.controls = true;
                              audio.src = getAudioDataUrl(frMime, frData);
                              div.appendChild(audio);
                          }} else {{
                              const media_info = document.createElement('div');
                              media_info.className = 'media-indicator';
                              media_info.innerHTML = `📎 ${{frMime}} (part ${{idx + 1}})`;
                              div.appendChild(media_info);
                          }}
                      }}
                  }});
              }}
          }} else if (part.executable_code) {{
              const code_div = document.createElement('div');
              const lang = part.executable_code.language || 'unknown';
              code_div.innerHTML = `<b>Executable Code</b> <span class="code-lang">(${{lang}})</span>`;
              div.appendChild(code_div);
              if (part.executable_code.code) {{
                  const code_pre = document.createElement('pre');
                  code_pre.className = 'code-block';
                  code_pre.innerText = part.executable_code.code;
                  div.appendChild(code_pre);
              }}
          }} else if (part.code_execution_result) {{
              const result_div = document.createElement('div');
              const outcome = part.code_execution_result.outcome || 'UNKNOWN';
              const outcomeClass = outcome === 'OUTCOME_OK' ? 'outcome-success' : 'outcome-error';
              result_div.innerHTML = `<b>Code Execution Result:</b> <span class="${{outcomeClass}}">${{outcome}}</span>`;
              div.appendChild(result_div);
              if (part.code_execution_result.output) {{
                  const output_pre = document.createElement('pre');
                  output_pre.className = 'code-output';
                  output_pre.innerText = part.code_execution_result.output;
                  div.appendChild(output_pre);
              }}
          }} else if (part.file_data) {{
              const file_div = document.createElement('div');
              file_div.innerHTML = '<b>File Reference:</b>';
              div.appendChild(file_div);
              const fileInfo = document.createElement('div');
              fileInfo.className = 'file-info';
              const displayName = part.file_data.display_name || 'Unnamed file';
              const fileUri = part.file_data.file_uri || '';
              const fileMime = part.file_data.mime_type || '';
              if (fileUri) {{
                  const link = document.createElement('a');
                  link.href = fileUri;
                  link.target = '_blank';
                  link.textContent = displayName;
                  fileInfo.appendChild(link);
              }} else {{
                  fileInfo.textContent = displayName;
              }}
              if (fileMime) {{
                  const mimeSpan = document.createElement('span');
                  mimeSpan.className = 'file-mime';
                  mimeSpan.textContent = ` (${{fileMime}})`;
                  fileInfo.appendChild(mimeSpan);
              }}
              div.appendChild(fileInfo);
          }} else if (mimetype.startsWith('image/')) {{
              if (partHash !== undefined) {{
                  const cachedImg = getCachedPartContent(partHash, 'full');
                  if (cachedImg) {{
                      div.appendChild(cachedImg);
                  }}
              }} else {{
                  const img = document.createElement('img');
                  img.src = `data:${{mimetype}};base64,${{data}}`;
                  div.appendChild(img);
              }}
          }} else if (mimetype.startsWith('audio/')) {{
              if (partHash !== undefined) {{
                  const cachedAudio = getCachedPartContent(partHash, 'audio');
                  if (cachedAudio) {{
                      div.appendChild(cachedAudio);
                  }}
              }} else {{
                  const audio = document.createElement('audio');
                  audio.controls = true;
                  audio.src = getAudioDataUrl(mimetype, data);
                  div.appendChild(audio);
              }}
          }} else if (data) {{
              const pre = document.createElement('pre');
              pre.innerText = `Binary data of type ${{mimetype}}`;
              div.appendChild(pre);
          }} else if (text) {{
              const pre = document.createElement('pre');
              pre.innerText = text;
              div.appendChild(pre);
          }} else {{
              const unknown_div = document.createElement('div');
              unknown_div.innerHTML = '<b>Part Content:</b>';
              const unknown_pre = document.createElement('pre');
              unknown_pre.className = 'code-output';
              unknown_pre.innerText = JSON.stringify(part, null, 2);
              unknown_div.appendChild(unknown_pre);
              div.appendChild(unknown_div);
          }}

          const meta_div = document.createElement('div');
          meta_div.innerHTML = '<b>Metadata:</b>';
          const meta_pre = document.createElement('pre');
          meta_pre.innerText = JSON.stringify(metadata, null, 2);
          meta_div.appendChild(meta_pre);
          div.appendChild(meta_div);

          return div;
      }}

      function formatTimeDelta(start, current) {{
          const deltaMs = new Date(current).getTime() - new Date(start).getTime();
          if (deltaMs < 1000) {{
              return `${{deltaMs}}ms`;
          }}
          if (deltaMs < 60000) {{
              return `${{(deltaMs / 1000).toFixed(1)}}s`;
          }}
          const totalSeconds = Math.floor(deltaMs / 1000);
          const minutes = Math.floor(totalSeconds / 60);
          const seconds = totalSeconds % 60;
          if (seconds === 0) {{
              return `${{minutes}}m`;
          }}
          return `${{minutes}}m${{seconds}}s`;
      }}

      function renderEvent(event) {{
          const partDict = getPartDict(event);
          if (partDict) {{
            return renderPart(partDict, event.part_hash);
          }}
          return null;
      }}

      function getMimeType(partDict) {{
          if (!partDict) return null;
          const part = partDict.part || {{}};
          if(part.inline_data && part.inline_data.mime_type && part.inline_data.data) {{
              return part.inline_data.mime_type;
          }}
          return partDict.mimetype || null;
      }}

      function getText(partDict) {{
          if (!partDict) return null;
          const part = partDict.part || {{}};
          let text = partDict.text || '';
          if(part.inline_data && part.inline_data.mime_type && part.inline_data.data) {{
              text = '';
          }} else if (part.text && !text) {{
              text = part.text;
          }}
          return text || null;
      }}

      function buildEventsById(trace, eventsById, parentSubtraceEvent = null) {{
          trace.events.forEach(event => {{
              eventsById[event.id] = event;
              if(event.sub_trace) {{
                  buildEventsById(event.sub_trace, eventsById, event);
              }} else if (parentSubtraceEvent) {{
                  eventToParentSubtrace[event.id] = parentSubtraceEvent;
              }}
          }});
      }}

      function createSpan(text, className) {{
          const span = document.createElement('span');
          span.textContent = text;
          if (className) span.className = className;
          return span;
      }}

      function createRoleBadge(role) {{
          if (!role) return null;
          const span = document.createElement('span');
          span.className = 'role-badge';
          const roleLower = role.toLowerCase();
          // Rename 'tool' to 'Function Response' for clarity
          if (roleLower === 'tool') {{
              span.textContent = 'FUNCTION RESPONSE';
              span.classList.add('role-function-response');
          }} else {{
              span.textContent = role;
              if (roleLower === 'user') {{
                  span.classList.add('role-user');
              }} else if (roleLower === 'model') {{
                  span.classList.add('role-model');
              }} else if (roleLower === 'system') {{
                  span.classList.add('role-system');
              }} else {{
                  span.classList.add('role-unknown');
              }}
          }}
          return span;
      }}

      function renderLine1Content(line1, mimeType, data, text, is_input, substream) {{
        if (mimeType) {{
            if (mimeType.startsWith('image/')) {{
                const img = document.createElement('img');
                img.src = `data:${{mimeType}};base64,${{data}}`;
                img.className = 'thumb';
                line1.appendChild(img);
            }} else if (mimeType.startsWith('audio/')) {{
                const audio = document.createElement('audio');
                audio.controls = true;
                audio.src = getAudioDataUrl(mimeType, data);
                audio.className = 'audio-small';
                line1.appendChild(audio);
            }} else if (mimeType.startsWith('text/')) {{
                if (text) {{
                    let preview = text.substring(0, 100).replace(/\n/g, ' ');
                    if (text.length > 100) preview += '...';
                    const span = createSpan(`"${{preview}}"`, 'event-preview');
                    if (substream === 'status') span.classList.add('event-preview-status');
                    else if(is_input === true) span.classList.add('event-preview-in');
                    else if(is_input === false) span.classList.add('event-preview-out');
                    line1.appendChild(span);
                }}
            }} else if (data) {{
                const span = createSpan(`Binary data: ${{mimeType}}`, 'event-preview');
                if (substream === 'status') span.classList.add('event-preview-status');
                line1.appendChild(span);
            }} else {{
                line1.appendChild(createSpan(mimeType, 'event-preview'));
            }}
        }}
      }}

      function createTraceView(trace, eventsById, rootStartTime = null) {{
          const ul = document.createElement('ul');
          const eventsToRender = trace.events;
          eventsToRender.forEach(event => {{
              if (!event.sub_trace) {{
                  return;
              }}

              const li = document.createElement('li');
              li.dataset.eventId = event.id;

              const eventItem = document.createElement('div');
              eventItem.classList.add('event-item');
              eventItem.classList.add('event-item-sub');

              const details = document.createElement('div');
              details.className = 'event-details';

              const header = document.createElement('div');
              header.className = 'event-header';

              const hasChildren = event.sub_trace.events && event.sub_trace.events.some(e => e.sub_trace);
              if (hasChildren) {{
                  const collapseBtn = document.createElement('button');
                  collapseBtn.className = 'collapse-btn';
                  collapseBtn.textContent = '+';
                  collapseBtn.onclick = (e) => {{
                      li.classList.toggle('collapsed');
                      collapseBtn.textContent = li.classList.contains('collapsed') ? '+' : '−';
                      e.stopPropagation();
                  }};
                  header.appendChild(collapseBtn);
                  li.classList.add('collapsible');
                  li.classList.add('collapsed');
              }}

              header.appendChild(createSpan(event.sub_trace.name, ''));

              // Add status badges for error/cancelled traces
              if (event.sub_trace.error) {{
                  const errorBadge = document.createElement('span');
                  errorBadge.className = 'trace-status-badge trace-status-error';
                  errorBadge.textContent = 'ERROR';
                  errorBadge.title = event.sub_trace.error;
                  header.appendChild(errorBadge);
              }} else if (event.sub_trace.cancelled) {{
                  const cancelledBadge = document.createElement('span');
                  cancelledBadge.className = 'trace-status-badge trace-status-cancelled';
                  cancelledBadge.textContent = 'CANCELLED';
                  header.appendChild(cancelledBadge);
              }}

              const subTraceTiming = getSubtraceDuration(event.sub_trace);
              const effectiveRootStart = rootStartTime || trace.start_time;
              const subTraceEventStart = subTraceTiming.firstInputTime || event.sub_trace.start_time;
              const subTraceStartDelta = getTimeDeltaMs(effectiveRootStart, subTraceEventStart);
              header.appendChild(createSpan(`+${{formatDuration(subTraceStartDelta)}}`, 'event-time'));
              header.appendChild(createSpan(`dur:${{formatDuration(subTraceTiming.duration)}}`, 'event-time'));
              if (event.relation) {{
                  header.appendChild(createSpan(event.relation, 'event-time'));
              }}
              details.appendChild(header);

              eventItem.appendChild(details);
              li.appendChild(eventItem);

              eventItem.addEventListener('click', (e) => {{
                  if (!e.ctrlKey && !e.metaKey) {{
                      document.querySelectorAll('.selected').forEach(el => el.classList.remove('selected'));
                  }}
                  li.classList.add('selected');
                  setFocused(li);
                  renderSelectedEvents(eventsById);
                  e.stopPropagation();
              }});

              if (hasChildren) {{
                  const div = document.createElement('div');
                  div.className = 'sub-trace-container';
                  div.style.paddingLeft = "12px";
                  div.appendChild(createTraceView(event.sub_trace, eventsById, rootStartTime || trace.start_time));
                  li.appendChild(div);
              }}

              ul.appendChild(li);
          }});
          return ul;
      }}

      function collectDirectChildEvents(trace) {{
          const events = [];
          if (!trace || !trace.events) return events;
          trace.events.forEach(event => {{
              if (!event.sub_trace) {{
                  events.push(event);
              }}
          }});
          return events;
      }}

      function getModalityType(mimeType, partDict) {{
          const part = partDict?.part || {{}};
          if (part.function_call) return 'function_call';
          if (part.function_response) return 'function_response';
          if (part.executable_code) return 'executable_code';
          if (part.code_execution_result) return 'code_execution_result';
          if (part.file_data) return 'file_data';
          if (!mimeType) return 'text';
          if (mimeType.startsWith('audio/')) return 'audio';
          if (mimeType.startsWith('image/')) return 'image';
          return 'text';
      }}

      function getFunctionName(partDict) {{
          const part = partDict?.part || {{}};
          if (part.function_call) return part.function_call.name || '';
          if (part.function_response) return part.function_response.name || '';
          return '';
      }}

      function groupEventsIntoBlocks(events) {{
          const blocks = [];
          let currentBlock = null;

          events.forEach(event => {{
              // Handle error events specially - they always get their own block
              if (event.error_message) {{
                  if (currentBlock) blocks.push(currentBlock);
                  currentBlock = null;
                  blocks.push({{
                      role: '',
                      substream: '',
                      isInput: false,
                      modality: 'error',
                      events: [event],
                      eventIds: [event.id],
                      startTime: event.timestamp,
                      endTime: event.timestamp,
                      audioData: [],
                      errorMessage: event.error_message,
                      functionName: ''
                  }});
                  return;
              }}

              const partDict = getPartDict(event);
              const mimeType = getMimeType(partDict);
              const role = partDict?.role || '';
              const substream = partDict?.substream_name || '';
              const isInput = event.is_input;
              const modality = getModalityType(mimeType, partDict);
              const functionName = getFunctionName(partDict);

              // Split blocks when function name changes for function_call and function_response
              const needsNewBlock = !currentBlock ||
                  currentBlock.role !== role ||
                  currentBlock.isInput !== isInput ||
                  currentBlock.modality !== modality ||
                  currentBlock.substream !== substream ||
                  ((modality === 'function_call' || modality === 'function_response') && currentBlock.functionName !== functionName);

              if (needsNewBlock) {{
                  if (currentBlock) blocks.push(currentBlock);
                  currentBlock = {{
                      role: role,
                      substream: substream,
                      isInput: isInput,
                      modality: modality,
                      events: [event],
                      eventIds: [event.id],
                      startTime: event.timestamp,
                      endTime: event.timestamp,
                      audioData: [],
                      functionName: functionName
                  }};
              }} else {{
                  currentBlock.events.push(event);
                  currentBlock.eventIds.push(event.id);
                  currentBlock.endTime = event.timestamp;
              }}

              if (modality === 'audio') {{
                  const data = getData(partDict);
                  const mime = getMimeType(partDict);
                  if (data && mime) {{
                      if (!currentBlock.audioMimeType) currentBlock.audioMimeType = mime;
                      currentBlock.audioData.push(data);
                  }}
              }}
          }});

          if (currentBlock) blocks.push(currentBlock);
          return blocks;
      }}

      let currentAudio = null;
      let currentPlayingBlock = null;

      function stopCurrentAudio() {{
          if (currentAudio) {{
              currentAudio.pause();
              currentAudio = null;
          }}
          if (currentPlayingBlock) {{
              currentPlayingBlock.classList.remove('audio-playing');
              currentPlayingBlock = null;
          }}
          document.querySelectorAll('.audio-play-btn.playing').forEach(btn => btn.classList.remove('playing'));
      }}

      function playAudioBlocks(blocks, isInput, button) {{
          stopCurrentAudio();
          const audioBlocks = blocks.filter(b => b.modality === 'audio' && b.isInput === isInput);
          if (audioBlocks.length === 0) return;

          button.classList.add('playing');
          button.textContent = isInput ? '⏹ Input' : '⏹ Output';

          let blockIndex = 0;

          function playNextBlock() {{
              if (blockIndex >= audioBlocks.length) {{
                  stopCurrentAudio();
                  button.textContent = isInput ? '▶ Input' : '▶ Output';
                  return;
              }}

              const block = audioBlocks[blockIndex];
              const blockEl = document.querySelector(`[data-block-id="${{block.id}}"]`);
              if (currentPlayingBlock) currentPlayingBlock.classList.remove('audio-playing');
              if (blockEl) {{
                  blockEl.classList.add('audio-playing');
                  blockEl.scrollIntoView({{behavior: 'smooth', block: 'center'}});
                  currentPlayingBlock = blockEl;
              }}

              const audio = new Audio(combineAudioChunks(block.audioData, block.audioMimeType || 'audio/wav'));
              currentAudio = audio;

              audio.onended = () => {{
                  blockIndex++;
                  playNextBlock();
              }};
              audio.onerror = () => {{
                  blockIndex++;
                  playNextBlock();
              }};
              audio.play();
          }}

          playNextBlock();
      }}

      function playAllAudioBlocks(blocks, button) {{
          stopCurrentAudio();
          const audioBlocks = blocks.filter(b => b.modality === 'audio');
          if (audioBlocks.length === 0) return;

          button.classList.add('playing');
          button.textContent = '⏹ Stop';

          let blockIndex = 0;

          function playNextBlock() {{
              if (blockIndex >= audioBlocks.length) {{
                  stopCurrentAudio();
                  button.textContent = '▶ Play All';
                  return;
              }}

              const block = audioBlocks[blockIndex];
              const blockEl = document.querySelector(`[data-block-id="${{block.id}}"]`);
              if (currentPlayingBlock) currentPlayingBlock.classList.remove('audio-playing');
              if (blockEl) {{
                  blockEl.classList.add('audio-playing');
                  blockEl.scrollIntoView({{behavior: 'smooth', block: 'center'}});
                  currentPlayingBlock = blockEl;
              }}

              const audio = new Audio(combineAudioChunks(block.audioData, block.audioMimeType || 'audio/wav'));
              currentAudio = audio;

              audio.onended = () => {{
                  blockIndex++;
                  playNextBlock();
              }};
              audio.onerror = () => {{
                  blockIndex++;
                  playNextBlock();
              }};
              audio.play();
          }}

          playNextBlock();
      }}


      function renderContentBlock(block, blockId, subtraceStartTime) {{
          const div = document.createElement('div');
          // Handle error modality specially
          if (block.modality === 'error') {{
              div.className = 'event-block expanded error-event';
          }} else {{
              div.className = 'event-block expanded ' + (block.isInput ? 'content-block-input' : 'content-block-output');
          }}
          div.dataset.blockId = blockId;
          block.eventIds.forEach(id => div.dataset.eventIds = (div.dataset.eventIds || '') + id + ',');
          block.id = blockId;

          const header = document.createElement('div');
          header.className = 'event-block-header';

          let blockTitle = '';
          let useHtmlTitle = false;
          const firstEvent = block.events[0];
          const firstPartDict = getPartDict(firstEvent);
          const firstPart = firstPartDict?.part || {{}};
          const firstMime = getMimeType(firstPartDict);

          if (block.modality === 'error') {{
              useHtmlTitle = true;
              blockTitle = `<span class="event-type-badge badge-error">❌ Error</span>`;
          }} else if (block.modality === 'function_call') {{
              useHtmlTitle = true;
              blockTitle = `<span class="event-type-badge badge-function-call">📞 Function Call</span> ${{firstPart.function_call?.name || 'unknown'}}`;
          }} else if (block.modality === 'function_response') {{
              useHtmlTitle = true;
              blockTitle = `<span class="event-type-badge badge-function-response">📋 Function Response</span> ${{firstPart.function_response?.name || 'unknown'}}`;
          }} else if (block.modality === 'executable_code') {{
              useHtmlTitle = true;
              blockTitle = `<span class="event-type-badge badge-code">💻 Code</span> ${{firstPart.executable_code?.language || 'unknown'}}`;
          }} else if (block.modality === 'code_execution_result') {{
              const outcome = firstPart.code_execution_result?.outcome || 'UNKNOWN';
              const isOk = outcome === 'OUTCOME_OK';
              useHtmlTitle = true;
              blockTitle = `<span class="event-type-badge ${{isOk ? 'badge-code-ok' : 'badge-code-error'}}">${{isOk ? '✅' : '❌'}} Code Result</span> ${{outcome}}`;
          }} else if (block.modality === 'file_data') {{
              blockTitle = '📁 File Data';
          }} else if (block.modality === 'audio') {{
              blockTitle = '🔊 Audio';
          }} else if (block.modality === 'image') {{
              blockTitle = '🖼️ Image';
          }} else {{
              blockTitle = '📝 Text';
          }}

          const headerLeft = document.createElement('div');
          headerLeft.className = 'block-header-left';

          if (block.role) {{
              const roleBadge = createRoleBadge(block.role);
              if (roleBadge) headerLeft.appendChild(roleBadge);
          }}

          const titleSpan = document.createElement('span');
          titleSpan.className = 'event-block-title';
          if (useHtmlTitle) {{
              titleSpan.innerHTML = blockTitle;
          }} else {{
              titleSpan.textContent = blockTitle;
          }}
          headerLeft.appendChild(titleSpan);
          header.appendChild(headerLeft);

          const meta = document.createElement('div');
          meta.className = 'event-block-meta';

          if (firstMime) {{
              const mimeSpan = document.createElement('span');
              mimeSpan.textContent = firstMime;
              meta.appendChild(mimeSpan);
          }}

          if (block.startTime && rootTraceStartTime) {{
              const startMs = getTimeDeltaMs(rootTraceStartTime, block.startTime);
              const endMs = getTimeDeltaMs(rootTraceStartTime, block.endTime);
              const timeSpan = document.createElement('span');
              if (startMs === endMs) {{
                  timeSpan.textContent = `+${{formatDuration(startMs)}}`;
              }} else {{
                  timeSpan.textContent = `+${{formatDuration(startMs)}} → +${{formatDuration(endMs)}}`;
              }}
              meta.appendChild(timeSpan);
          }}
          header.appendChild(meta);

          div.appendChild(header);

          const body = document.createElement('div');
          body.className = 'event-block-body';

          if (block.modality === 'text') {{
              // Only merge text content, not metadata
              const allText = block.events.map(event => {{
                  const partDict = getPartDict(event);
                  return getText(partDict) || '';
              }}).join('');
              
              if (allText) {{
                  const pre = document.createElement('pre');
                  pre.className = 'sub-event-content';
                  pre.textContent = allText;
                  body.appendChild(pre);
              }} else {{
                  // Check if there's any non-empty part content or metadata
                  const hasNonEmptyContent = block.events.some(event => {{
                      const partDict = getPartDict(event);
                      const part = partDict?.part || {{}};
                      const metadata = partDict?.metadata || {{}};
                      const partKeys = Object.keys(part).filter(k => k !== 'text');
                      const metaKeys = Object.keys(metadata);
                      return partKeys.length > 0 || metaKeys.length > 0;
                  }});
                  
                  const titleSpan = header.querySelector('.event-block-title');
                  if (hasNonEmptyContent) {{
                      // Has metadata or part content - show as Metadata
                      if (titleSpan) {{
                          titleSpan.innerHTML = '📋 Metadata';
                      }}
                  }} else {{
                      // Truly empty - show as Empty
                      if (titleSpan) {{
                          titleSpan.innerHTML = '∅ Empty';
                      }}
                  }}
              }}
          }} else if (block.modality === 'audio') {{
              const indicator = document.createElement('div');
              indicator.className = 'audio-indicator';
              indicator.innerHTML = `<span class="audio-indicator-icon">🔊</span> Audio (${{block.events.length}} chunk${{block.events.length > 1 ? 's' : ''}})`;
              body.appendChild(indicator);
          }} else if (block.modality === 'image') {{
              block.events.forEach(event => {{
                  if (event.part_hash !== undefined) {{
                      const cachedImg = getCachedPartContent(event.part_hash, 'full');
                      if (cachedImg) {{
                          body.appendChild(cachedImg);
                      }}
                  }} else {{
                      const partDict = getPartDict(event);
                      const data = getData(partDict);
                      const mime = getMimeType(partDict);
                      if (data) {{
                          const img = document.createElement('img');
                          img.src = `data:${{mime}};base64,${{data}}`;
                          body.appendChild(img);
                      }}
                  }}
              }});
          }} else if (block.modality === 'error') {{
              const pre = document.createElement('pre');
              pre.className = 'code-output outcome-error';
              pre.textContent = block.errorMessage || 'Unknown error';
              body.appendChild(pre);
          }} else {{
              // For all other modalities (function_call, function_response, 
              // executable_code, code_execution_result, file_data, etc.), 
              // only show media elements - JSON details are in sub-events
              block.events.forEach(event => {{
                  const partDict = getPartDict(event);
                  const part = partDict?.part || {{}};
                  
                  // Check if part is empty
                  const isEmptyPart = Object.keys(part).length === 0 || 
                      (Object.keys(part).length === 1 && part.text === '');
                  
                  if (isEmptyPart) {{
                      const emptyDiv = document.createElement('div');
                      emptyDiv.style.color = '#888';
                      emptyDiv.style.fontStyle = 'italic';
                      emptyDiv.textContent = '∅ Empty';
                      body.appendChild(emptyDiv);
                  }} else {{
                      // Extract and display only media (images/audio) - no JSON
                      function extractAndShowMedia(obj) {{
                          if (!obj || typeof obj !== 'object') return;
                          
                          if (obj.inline_data && obj.inline_data.mime_type && obj.inline_data.data) {{
                              const mime = obj.inline_data.mime_type;
                              const data = obj.inline_data.data;
                              if (mime.startsWith('image/')) {{
                                  const img = document.createElement('img');
                                  img.src = `data:${{mime}};base64,${{data}}`;
                                  img.style.maxWidth = '100%';
                                  img.style.maxHeight = '300px';
                                  body.appendChild(img);
                              }} else if (mime.startsWith('audio/')) {{
                                  const audio = document.createElement('audio');
                                  audio.controls = true;
                                  audio.src = getAudioDataUrl(mime, data);
                                  body.appendChild(audio);
                              }}
                          }}
                          
                          // Recurse into arrays and objects
                          if (Array.isArray(obj)) {{
                              obj.forEach(item => extractAndShowMedia(item));
                          }} else {{
                              Object.keys(obj).forEach(key => {{
                                  if (key !== 'inline_data') {{
                                      extractAndShowMedia(obj[key]);
                                  }}
                              }});
                          }}
                      }}
                      
                      extractAndShowMedia(part);
                  }}
              }});
          }}

          if (block.events.length > 0) {{
              const subEventsSection = document.createElement('details');
              subEventsSection.style.marginTop = '8px';
              const summary = document.createElement('summary');
              summary.style.cursor = 'pointer';
              summary.style.fontSize = '10px';
              summary.style.color = '#666';
              summary.style.display = 'flex';
              summary.style.justifyContent = 'space-between';
              summary.style.alignItems = 'center';
              summary.style.listStyle = 'none';
              
              const eventsTextWrapper = document.createElement('span');
              const triangle = document.createElement('span');
              triangle.className = 'expand-triangle';
              triangle.textContent = '▶';
              eventsTextWrapper.appendChild(triangle);
              
              const eventsText = document.createElement('span');
              eventsText.textContent = `${{block.events.length}} event${{block.events.length > 1 ? 's' : ''}}`;
              eventsTextWrapper.appendChild(eventsText);
              summary.appendChild(eventsTextWrapper);
              
              if (block.substream) {{
                  const substreamBadge = document.createElement('span');
                  substreamBadge.className = 'substream-badge ' + (block.isInput ? 'substream-badge-input' : 'substream-badge-output');
                  substreamBadge.textContent = `substream: ${{block.substream}}`;
                  summary.appendChild(substreamBadge);
              }}
              
              subEventsSection.appendChild(summary);

              block.events.forEach((event, idx) => {{
                  const partDict = getPartDict(event);
                  const mimeType = getMimeType(partDict);
                  const metadata = partDict?.metadata || {{}};
                  const substream = partDict?.substream_name || '';
                  const part = partDict?.part || {{}};

                  const subEvent = document.createElement('div');
                  subEvent.className = 'sub-event';

                  const subHeader = document.createElement('div');
                  subHeader.className = 'sub-event-header';

                  const subMeta = document.createElement('div');
                  subMeta.className = 'sub-event-meta';
                  subMeta.appendChild(createSpan(`#${{idx + 1}}`, ''));
                  if (mimeType) subMeta.appendChild(createSpan(mimeType, ''));
                  subHeader.appendChild(subMeta);

                  const partRole = partDict?.role || '';
                  if (partRole) {{
                      const roleBadge = createRoleBadge(partRole);
                      if (roleBadge) {{
                          roleBadge.classList.add('sub-event-role');
                          subHeader.appendChild(roleBadge);
                      }}
                  }}

                  if (event.timestamp && rootTraceStartTime) {{
                      const timeMs = getTimeDeltaMs(rootTraceStartTime, event.timestamp);
                      const timeSpan = document.createElement('span');
                      timeSpan.className = 'sub-event-time';
                      timeSpan.textContent = `+${{timeMs}}ms`;
                      subHeader.appendChild(timeSpan);
                  }}

                  subEvent.appendChild(subHeader);

                  const subContent = document.createElement('div');
                  subContent.className = 'sub-event-content';

                  if (mimeType?.startsWith('audio/')) {{
                      subContent.innerHTML = '<span style="color:#888;font-style:italic;">🔊 Audio content</span>';
                  }} else if (mimeType?.startsWith('image/')) {{
                      if (event.part_hash !== undefined) {{
                          const cachedImg = getCachedPartContent(event.part_hash, 'full');
                          if (cachedImg) {{
                              subContent.appendChild(cachedImg);
                          }}
                      }} else {{
                          const data = getData(partDict);
                          if (data) {{
                              const img = document.createElement('img');
                              img.src = `data:${{mimeType}};base64,${{data}}`;
                              img.style.maxWidth = '200px';
                              img.style.maxHeight = '200px';
                              subContent.appendChild(img);
                          }}
                      }}
                  }} else {{
                      // For all other content types, show text or JSON
                      const text = getText(partDict);
                      if (text) {{
                          const pre = document.createElement('pre');
                          pre.style.fontSize = '10px';
                          pre.style.margin = '4px 0';
                          pre.style.whiteSpace = 'pre-wrap';
                          pre.textContent = text;
                          subContent.appendChild(pre);
                      }} else {{
                          // No text - check if part has content or is empty
                          const partKeys = Object.keys(part).filter(k => k !== 'text');
                          if (partKeys.length > 0) {{
                              // Render JSON with inline media and formatted text
                              function renderJsonValue(value, indent = 0) {{
                                  const container = document.createElement('span');
                                  const indentStr = '  '.repeat(indent);
                                  
                                  if (value === null) {{
                                      container.textContent = 'null';
                                      container.style.color = '#888';
                                  }} else if (typeof value === 'boolean') {{
                                      container.textContent = value.toString();
                                      container.style.color = '#0066cc';
                                  }} else if (typeof value === 'number') {{
                                      container.textContent = value.toString();
                                      container.style.color = '#008800';
                                  }} else if (typeof value === 'string') {{
                                      // Render text with newlines
                                      const pre = document.createElement('pre');
                                      pre.style.display = 'inline';
                                      pre.style.margin = '0';
                                      pre.style.padding = '0';
                                      pre.style.background = 'transparent';
                                      pre.style.border = 'none';
                                      pre.style.whiteSpace = 'pre-wrap';
                                      pre.style.color = '#aa3333';
                                      if (value.length > 200) {{
                                          pre.textContent = '"' + value.substring(0, 100) + `... (${{value.length}} chars)"`;
                                      }} else {{
                                          pre.textContent = '"' + value + '"';
                                      }}
                                      container.appendChild(pre);
                                  }} else if (Array.isArray(value)) {{
                                      container.appendChild(document.createTextNode('['));
                                      if (value.length > 20) {{
                                          // Truncate long arrays
                                          const truncated = [...value.slice(0, 10), `... (${{value.length - 20}} more) ...`, ...value.slice(-10)];
                                          truncated.forEach((item, i) => {{
                                              container.appendChild(document.createTextNode('\n' + indentStr + '  '));
                                              container.appendChild(renderJsonValue(item, indent + 1));
                                              if (i < truncated.length - 1) container.appendChild(document.createTextNode(','));
                                          }});
                                      }} else {{
                                          value.forEach((item, i) => {{
                                              container.appendChild(document.createTextNode('\n' + indentStr + '  '));
                                              container.appendChild(renderJsonValue(item, indent + 1));
                                              if (i < value.length - 1) container.appendChild(document.createTextNode(','));
                                          }});
                                      }}
                                      if (value.length > 0) container.appendChild(document.createTextNode('\n' + indentStr));
                                      container.appendChild(document.createTextNode(']'));
                                  }} else if (typeof value === 'object') {{
                                      // Check for objects with inline media (multiple patterns)
                                      // Pattern 1: {{inline_data: {{mime_type, data}}}}
                                      // Pattern 2: {{mime_type, data}} directly (like in function response parts)
                                      const inlineData = value.inline_data || 
                                          (value.mime_type && value.data ? value : null);
                                      
                                      if (inlineData && inlineData.mime_type && inlineData.data) {{
                                          const mime = inlineData.mime_type;
                                          const data = inlineData.data;
                                          
                                          // Build the JSON structure around the media
                                          if (value.inline_data) {{
                                              container.appendChild(document.createTextNode('{{ "inline_data": {{ "mime_type": "' + mime + '", "data": '));
                                          }} else {{
                                              container.appendChild(document.createTextNode('{{ "mime_type": "' + mime + '", "data": '));
                                          }}
                                          
                                          // Render media based on type
                                          if (mime.startsWith('image/')) {{
                                              const img = document.createElement('img');
                                              img.src = `data:${{mime}};base64,${{data}}`;
                                              img.style.maxWidth = '200px';
                                              img.style.maxHeight = '150px';
                                              img.style.verticalAlign = 'middle';
                                              img.style.margin = '4px';
                                              img.style.border = '1px solid #ccc';
                                              container.appendChild(img);
                                          }} else if (mime.startsWith('audio/')) {{
                                              const audio = document.createElement('audio');
                                              audio.controls = true;
                                              audio.src = getAudioDataUrl(mime, data);
                                              audio.style.verticalAlign = 'middle';
                                              audio.style.height = '24px';
                                              container.appendChild(audio);
                                          }} else if (mime.startsWith('video/')) {{
                                              const video = document.createElement('video');
                                              video.controls = true;
                                              video.src = `data:${{mime}};base64,${{data}}`;
                                              video.style.maxWidth = '200px';
                                              video.style.maxHeight = '150px';
                                              video.style.verticalAlign = 'middle';
                                              container.appendChild(video);
                                          }} else {{
                                              // Other binary data - show placeholder
                                              const placeholder = document.createElement('span');
                                              placeholder.style.color = '#888';
                                              placeholder.style.fontStyle = 'italic';
                                              placeholder.textContent = `[Binary: ${{data.length}} bytes]`;
                                              container.appendChild(placeholder);
                                          }}
                                          
                                          // Close the JSON structure
                                          if (value.inline_data) {{
                                              container.appendChild(document.createTextNode(' }} }}'));
                                          }} else {{
                                              container.appendChild(document.createTextNode(' }}'));
                                          }}
                                      }} else {{
                                          // Regular object - render all keys
                                          const keys = Object.keys(value);
                                          container.appendChild(document.createTextNode('{{'));
                                          keys.forEach((key, i) => {{
                                              container.appendChild(document.createTextNode('\n' + indentStr + '  "' + key + '": '));
                                              container.appendChild(renderJsonValue(value[key], indent + 1));
                                              if (i < keys.length - 1) container.appendChild(document.createTextNode(','));
                                          }});
                                          if (keys.length > 0) container.appendChild(document.createTextNode('\n' + indentStr));
                                          container.appendChild(document.createTextNode('}}'));
                                      }}
                                  }}
                                  return container;
                              }}
                              
                              const pre = document.createElement('pre');
                              pre.style.fontSize = '9px';
                              pre.style.margin = '4px 0';
                              pre.style.whiteSpace = 'pre-wrap';
                              pre.appendChild(renderJsonValue(part));
                              subContent.appendChild(pre);
                          }} else {{
                              // Empty part
                              const emptySpan = document.createElement('span');
                              emptySpan.style.color = '#888';
                              emptySpan.style.fontStyle = 'italic';
                              emptySpan.textContent = '∅ Empty';
                              subContent.appendChild(emptySpan);
                          }}
                      }}
                  }}

                  subEvent.appendChild(subContent);

                  // Footer with substream badge on the right
                  const hasMetadata = Object.keys(metadata).length > 0;
                  if (substream) {{
                      const subEventFooter = document.createElement('div');
                      subEventFooter.className = 'sub-event-footer';
                      subEventFooter.style.justifyContent = 'flex-end';
                      
                      const substreamBadge = document.createElement('span');
                      substreamBadge.className = 'substream-badge ' + (block.isInput ? 'substream-badge-input' : 'substream-badge-output');
                      substreamBadge.textContent = `substream: ${{substream}}`;
                      subEventFooter.appendChild(substreamBadge);
                      
                      subEvent.appendChild(subEventFooter);
                  }}

                  // Metadata section - collapsible if multiple keys, inline if single
                  if (hasMetadata) {{
                      const metaKeys = Object.keys(metadata);
                      const metaDiv = document.createElement('div');
                      metaDiv.className = 'sub-event-metadata';
                      
                      if (metaKeys.length === 1) {{
                          // Single key-value: render inline
                          const key = metaKeys[0];
                          const value = metadata[key];
                          const metaSpan = document.createElement('span');
                          metaSpan.innerHTML = `<b>${{key}}:</b> ${{typeof value === 'object' ? JSON.stringify(value) : value}}`;
                          metaDiv.appendChild(metaSpan);
                      }} else {{
                          // Multiple keys: render as collapsible
                          const details = document.createElement('details');
                          const summary = document.createElement('summary');
                          const triangle = document.createElement('span');
                          triangle.className = 'expand-triangle';
                          triangle.textContent = '\u25b6';
                          summary.appendChild(triangle);
                          const summaryText = document.createElement('span');
                          summaryText.textContent = `metadata (${{metaKeys.length}} entries)`;
                          summary.appendChild(summaryText);
                          details.appendChild(summary);
                          const pre = document.createElement('pre');
                          pre.textContent = JSON.stringify(metadata, null, 2);
                          details.appendChild(pre);
                          metaDiv.appendChild(details);
                      }}
                      
                      subEvent.appendChild(metaDiv);
                  }}

                  subEventsSection.appendChild(subEvent);
              }});

              body.appendChild(subEventsSection);
          }}

          div.appendChild(body);

          return div;
      }}

      function renderSubtraceContent(subtraceEvent, highlightEventId = null) {{
          const container = document.createElement('div');
          container.className = 'subtrace-container';

          const subTrace = subtraceEvent.sub_trace;
          const subtraceStartTime = subTrace.start_time;
          const directEvents = collectDirectChildEvents(subTrace);
          const blocks = groupEventsIntoBlocks(directEvents);

          currentSubtraceBlocks = blocks;
          currentSubtraceStartTime = subtraceStartTime;

          const hasInputAudio = blocks.some(b => b.modality === 'audio' && b.isInput);
          const hasOutputAudio = blocks.some(b => b.modality === 'audio' && !b.isInput);

          if (hasInputAudio || hasOutputAudio) {{
              const controlsHeader = document.createElement('div');
              controlsHeader.className = 'audio-controls-header';

              const playAllBtn = document.createElement('button');
              playAllBtn.className = 'audio-play-btn';
              playAllBtn.textContent = '▶ Play All';
              playAllBtn.onclick = () => {{
                  if (playAllBtn.classList.contains('playing')) {{
                      stopCurrentAudio();
                      playAllBtn.textContent = '▶ Play All';
                  }} else {{
                      playAllAudioBlocks(blocks, playAllBtn);
                  }}
              }};
              controlsHeader.appendChild(playAllBtn);

              if (hasInputAudio) {{
                  const inputBtn = document.createElement('button');
                  inputBtn.className = 'audio-play-btn audio-play-btn-input';
                  inputBtn.textContent = '▶ Input';
                  inputBtn.onclick = () => {{
                      if (inputBtn.classList.contains('playing')) {{
                          stopCurrentAudio();
                          inputBtn.textContent = '▶ Input';
                      }} else {{
                          playAudioBlocks(blocks, true, inputBtn);
                      }}
                  }};
                  controlsHeader.appendChild(inputBtn);
              }}

              if (hasOutputAudio) {{
                  const outputBtn = document.createElement('button');
                  outputBtn.className = 'audio-play-btn audio-play-btn-output';
                  outputBtn.textContent = '▶ Output';
                  outputBtn.onclick = () => {{
                      if (outputBtn.classList.contains('playing')) {{
                          stopCurrentAudio();
                          outputBtn.textContent = '▶ Output';
                      }} else {{
                          playAudioBlocks(blocks, false, outputBtn);
                      }}
                  }};
                  controlsHeader.appendChild(outputBtn);
              }}

              container.appendChild(controlsHeader);
          }}

          blocks.forEach((block, index) => {{
              const blockEl = renderContentBlock(block, `block-${{index}}`, subtraceStartTime);
              if (highlightEventId && block.eventIds.includes(highlightEventId)) {{
                  blockEl.classList.add('event-highlighted');
                  setTimeout(() => blockEl.scrollIntoView({{behavior: 'smooth', block: 'center'}}), 100);
              }}
              container.appendChild(blockEl);
          }});

          return container;
      }}

      function renderSelectedEvents(eventsById) {{
          const rightPanel = document.getElementById('right-panel');
          rightPanel.innerHTML = '';
          document.querySelectorAll('.selected').forEach(li => {{
              const eventId = li.dataset.eventId;
              const event = eventsById[eventId];
              if (event) {{
                  if (event.sub_trace) {{
                      const subtraceView = renderSubtraceContent(event);
                      rightPanel.appendChild(subtraceView);
                  }} else {{
                      const parentSubtrace = eventToParentSubtrace[eventId];
                      if (parentSubtrace) {{
                          const subtraceView = renderSubtraceContent(parentSubtrace, eventId);
                          rightPanel.appendChild(subtraceView);
                      }} else {{
                          const rendered = renderEvent(event);
                          if (rendered) {{
                              rightPanel.appendChild(rendered);
                          }}
                      }}
                  }}
              }}
          }});
      }}

      function getVisibleLis() {{
          const allLis = Array.from(document.querySelectorAll('#left-panel li'));
          return allLis.filter(li => li.offsetParent !== null);
      }}

      function setFocused(li) {{
          if (focusedLi) {{
              focusedLi.classList.remove('focused');
          }}
          focusedLi = li;
          if (focusedLi) {{
              focusedLi.classList.add('focused');
              focusedLi.scrollIntoView({{behavior: 'smooth', block: 'nearest'}});
          }}
      }}

      function moveFocus(direction) {{
          const visibleLis = getVisibleLis();
          if (visibleLis.length === 0) return;

          let currentIndex = -1;
          if(focusedLi) {{
            currentIndex = visibleLis.indexOf(focusedLi);
          }}

          let nextIndex;
          if (direction === 'down') {{
              nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % visibleLis.length;
          }} else {{
              nextIndex = currentIndex <= 0 ? visibleLis.length - 1 : currentIndex - 1;
          }}
          setFocused(visibleLis[nextIndex]);
          document.querySelectorAll('.selected').forEach(el => el.classList.remove('selected'));
          focusedLi.classList.add('selected');
          renderSelectedEvents(eventsById);
      }}

      function countEvents(trace) {{
          let count = 0;
          let subtraceCount = 0;
          if (!trace || !trace.events) return {{ events: 0, subtraces: 0 }};
          trace.events.forEach(event => {{
              if (event.sub_trace) {{
                  subtraceCount++;
                  const sub = countEvents(event.sub_trace);
                  count += sub.events;
                  subtraceCount += sub.subtraces;
              }} else {{
                  count++;
              }}
          }});
          return {{ events: count, subtraces: subtraceCount }};
      }}

      function collapseAll() {{
          document.querySelectorAll('.collapsible').forEach(li => {{
              li.classList.add('collapsed');
              const btn = li.querySelector('.collapse-btn');
              if (btn) btn.textContent = '+';
          }});
      }}

      function expandAll() {{
          document.querySelectorAll('.collapsible').forEach(li => {{
              li.classList.remove('collapsed');
              const btn = li.querySelector('.collapse-btn');
              if (btn) btn.textContent = '−';
          }});
      }}

      function jumpToTop() {{
          const firstLi = document.querySelector('#left-panel li');
          if (firstLi) {{
              setFocused(firstLi);
              document.querySelectorAll('.selected').forEach(el => el.classList.remove('selected'));
              firstLi.classList.add('selected');
              renderSelectedEvents(eventsById);
          }}
      }}

      function jumpToBottom() {{
          const allLis = Array.from(document.querySelectorAll('#left-panel li'));
          const visibleLis = allLis.filter(li => li.offsetParent !== null);
          if (visibleLis.length > 0) {{
              const lastLi = visibleLis[visibleLis.length - 1];
              setFocused(lastLi);
              document.querySelectorAll('.selected').forEach(el => el.classList.remove('selected'));
              lastLi.classList.add('selected');
              renderSelectedEvents(eventsById);
          }}
      }}

      function initTraceViewer() {{
          buildEventsById(traceData, eventsById);
          const leftPanel = document.getElementById('left-panel');
          const traceDate = new Date(traceData.start_time);
          const traceDuration = getTraceDuration(traceData);
          const stats = countEvents(traceData);
          const isLargeTrace = stats.events > 100 || stats.subtraces > 10;

          leftPanel.innerHTML = `<h3>${{traceData.name}}</h3><div class="trace-subtitle"><span class="trace-date">${{traceDate.toLocaleDateString()}} at ${{traceDate.toLocaleTimeString()}}</span><span class="event-time">Duration: ${{formatDuration(traceDuration)}}</span></div>`;
          
          const toolbar = document.createElement('div');
          toolbar.className = 'trace-toolbar';
          toolbar.innerHTML = `
              <div class="trace-controls">
                  <button onclick="collapseAll()" title="Collapse all subtraces">− All</button>
                  <button onclick="expandAll()" title="Expand all subtraces">+ All</button>
                  <button onclick="jumpToTop()" title="Jump to first event (Home)">⇱ Top</button>
                  <button onclick="jumpToBottom()" title="Jump to last event (End)">⇲ End</button>
              </div>
              <div class="trace-stats">
                  <span title="Total parts">${{stats.events}} parts</span>
                  <span title="Number of subtraces">${{stats.subtraces}} subtraces</span>
              </div>
          `;
          leftPanel.appendChild(toolbar);
          rootTraceStartTime = traceData.start_time;
          leftPanel.appendChild(createTraceView(traceData, eventsById, traceData.start_time));

          if (isLargeTrace) {{
              collapseAll();
          }}

          leftPanel.addEventListener('keydown', (e) => {{
              if (e.key === 'ArrowDown') {{
                  moveFocus('down');
                  e.preventDefault();
              }} else if (e.key === 'ArrowUp') {{
                  moveFocus('up');
                  e.preventDefault();
              }} else if (e.key === 'ArrowRight' && focusedLi && focusedLi.classList.contains('collapsible')) {{
                  focusedLi.classList.remove('collapsed');
                  const btn = focusedLi.querySelector('.collapse-btn');
                  if (btn) btn.textContent = '−';
                  e.preventDefault();
              }} else if (e.key === 'ArrowLeft' && focusedLi && focusedLi.classList.contains('collapsible')) {{
                  focusedLi.classList.add('collapsed');
                  const btn = focusedLi.querySelector('.collapse-btn');
                  if (btn) btn.textContent = '+';
                  e.preventDefault();
              }} else if (e.key === 'Home') {{
                  jumpToTop();
                  e.preventDefault();
              }} else if (e.key === 'End') {{
                  jumpToBottom();
                  e.preventDefault();
              }}
          }});

          // Prefer selecting the first subtrace so the right panel shows content immediately
          let firstSubtraceLi = document.querySelector('#left-panel li.collapsible');
          if (!firstSubtraceLi) {{
              firstSubtraceLi = document.querySelector('#left-panel li');
          }}
          if(firstSubtraceLi) {{
              setFocused(firstSubtraceLi);
              firstSubtraceLi.classList.add('selected');
              renderSelectedEvents(eventsById);
          }}
      }}

      initTraceViewer();

      // Resizable split panel
      const resizer = document.getElementById('resizer');
      const leftPanel = document.getElementById('left-panel');
      let isResizing = false;

      resizer.addEventListener('mousedown', (e) => {{
          isResizing = true;
          resizer.classList.add('dragging');
          document.body.style.cursor = 'col-resize';
          document.body.style.userSelect = 'none';
          e.preventDefault();
      }});

      document.addEventListener('mousemove', (e) => {{
          if (!isResizing) return;
          const containerWidth = document.body.clientWidth;
          const newWidth = Math.max(200, Math.min(containerWidth - 206, e.clientX));
          leftPanel.style.width = newWidth + 'px';
      }});

      document.addEventListener('mouseup', () => {{
          if (isResizing) {{
              isResizing = false;
              resizer.classList.remove('dragging');
              document.body.style.cursor = '';
              document.body.style.userSelect = '';
          }}
      }});
    </script>
  </body>
</html>
