| <script lang="ts"> |
| import type { TraceNode } from '$lib/types/trace'; |
| import TierBadge from '$lib/components/glyphs/TierBadge.svelte'; |
| import StatusGlyph from './StatusGlyph.svelte'; |
| import Self from './TraceRow.svelte'; |
| |
| interface Props { |
| node: TraceNode; |
| depth?: number; |
| defaultOpen?: boolean; |
| } |
| |
| let { node, depth = 0, defaultOpen = false }: Props = $props(); |
| let open = $state(defaultOpen); |
| let copied = $state(false); |
| let hasChildren = $derived(!!node.children?.length); |
| let hasOutput = $derived(node.output != null || !!node.error); |
| let canExpand = $derived(hasChildren || hasOutput); |
| let indent = $derived(depth * 16); |
| |
| function toggle() { |
| if (canExpand) open = !open; |
| } |
| |
| let outputIsObject = $derived( |
| node.output != null && typeof node.output === 'object' |
| ); |
| |
| let formattedOutput = $derived.by(() => { |
| if (node.error) return node.error; |
| if (node.output == null) return ''; |
| if (typeof node.output === 'string') return node.output; |
| try { |
| return JSON.stringify(node.output, null, 2); |
| } catch { |
| return String(node.output); |
| } |
| }); |
| |
| let outputLabel = $derived( |
| node.status === 'error' ? 'Error' |
| : node.status === 'silent' ? 'Silent reason' |
| : 'Output' |
| ); |
| |
| async function copyOutput(ev: MouseEvent) { |
| ev.stopPropagation(); |
| try { |
| await navigator.clipboard.writeText(formattedOutput); |
| copied = true; |
| setTimeout(() => (copied = false), 1500); |
| } catch { |
| /* ignore — older browser, no clipboard permission */ |
| } |
| } |
| </script> |
|
|
| <div class="trace-row trace-row-{node.status}" style:padding-left="{indent + 12}px"> |
| <button |
| type="button" |
| class="trace-row-toggle" |
| onclick={toggle} |
| aria-expanded={canExpand ? open : undefined} |
| aria-label="{node.name}, {node.ms}ms, {node.status}{node.note ? ', ' + node.note : ''}" |
| disabled={!canExpand} |
| > |
| <span class="trace-tree-glyph" aria-hidden="true"> |
| {hasChildren ? (open ? '▾' : '▸') : (hasOutput ? (open ? '▾' : '▸') : '·')} |
| </span> |
| <span class="trace-status-col"><StatusGlyph status={node.status} /></span> |
| <span class="trace-name-col"> |
| <span class="trace-name">{node.name}</span> |
| {#if node.note}<span class="trace-note"> · {node.note}</span>{/if} |
| {#if node.docId}<span class="trace-doc-id" title="cited in briefing as [{node.docId}]">[{node.docId}]</span>{/if} |
| </span> |
| <span class="trace-ms-col">{node.ms}ms</span> |
| <span class="trace-tier-col"> |
| {#if node.tier}<TierBadge tier={node.tier} compact />{/if} |
| {#if node.status === 'silent'}<span class="trace-silent-tag">silent</span>{/if} |
| </span> |
| </button> |
|
|
| {#if open && hasOutput} |
| <div class="trace-output-panel" style:margin-left="{indent + 44}px"> |
| <div class="trace-output-head"> |
| <span class="trace-output-label trace-output-label-{node.status}">{outputLabel}</span> |
| {#if node.model} |
| <span class="trace-output-model">model: <code>{node.model}</code></span> |
| {/if} |
| {#if node.claims != null} |
| <span class="trace-output-claims-count">{node.claims} claim{node.claims === 1 ? '' : 's'} cited</span> |
| {/if} |
| {#if formattedOutput} |
| <button |
| type="button" |
| class="trace-output-copy" |
| onclick={copyOutput} |
| aria-label="Copy {outputLabel.toLowerCase()} to clipboard" |
| > |
| {copied ? 'Copied' : 'Copy'} |
| </button> |
| {/if} |
| </div> |
| {#if outputIsObject} |
| <pre class="trace-output-pre">{formattedOutput}</pre> |
| {:else} |
| <p class="trace-output-text">{formattedOutput}</p> |
| {/if} |
| </div> |
| {/if} |
| </div> |
| {#if open && hasChildren && node.children} |
| {#each node.children as child (child.id)} |
| <Self node={child} depth={depth + 1} defaultOpen={child.status === 'fan'} /> |
| {/each} |
| {/if} |
|
|