enh: consecutive details rendering

This commit is contained in:
Timothy Jaeryang Baek
2026-03-24 19:26:14 -05:00
parent 61cfaab915
commit 261aec8c86
3 changed files with 224 additions and 6 deletions
@@ -0,0 +1,127 @@
<script lang="ts">
import { getContext } from 'svelte';
import { slide } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte';
import Sparkles from '$lib/components/icons/Sparkles.svelte';
import CheckCircle from '$lib/components/icons/CheckCircle.svelte';
const i18n = getContext('i18n');
export let id = '';
export let tokens: Array<{
summary?: string;
attributes?: {
type?: string;
name?: string;
done?: string;
duration?: string;
};
}> = [];
export let messageDone = true;
let open = false;
$: toolCallCount = tokens.filter((t) => t?.attributes?.type === 'tool_calls').length;
$: reasoningCount = tokens.filter((t) => t?.attributes?.type === 'reasoning').length;
$: hasPending = tokens.some(
(t) => t?.attributes?.done !== undefined && t?.attributes?.done !== 'true'
);
$: codeInterpreterCount = tokens.filter((t) => t?.attributes?.type === 'code_interpreter').length;
$: summaryText = (() => {
const parts = [];
if (toolCallCount > 0) {
// Group by tool name and show counts
const nameCounts = {};
tokens
.filter((t) => t?.attributes?.type === 'tool_calls')
.forEach((t) => {
const name = t?.attributes?.name ?? 'tool';
nameCounts[name] = (nameCounts[name] || 0) + 1;
});
const toolParts = Object.entries(nameCounts).map(([name, count]) =>
count > 1 ? `${count} ${name}` : name
);
parts.push(...toolParts);
}
if (codeInterpreterCount > 0) {
if (codeInterpreterCount === 1) {
parts.push($i18n.t('Ran {{COUNT}} analysis', { COUNT: codeInterpreterCount }));
} else {
parts.push($i18n.t('Ran {{COUNT}} analyses', { COUNT: codeInterpreterCount }));
}
}
const prefix = hasPending ? $i18n.t('Exploring') : $i18n.t('Explored');
const detail = parts.join(', ');
return detail;
})();
$: prefixText = hasPending ? $i18n.t('Exploring') : $i18n.t('Explored');
</script>
<div {id} class="w-full">
<!-- svelte-ignore a11y-no-static-element-interactions -->
<button
class="w-fit text-left text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition cursor-pointer"
aria-label={$i18n.t('Toggle details')}
aria-expanded={open}
on:click={() => {
open = !open;
}}
>
<div class="flex items-center gap-1.5">
<!-- Status icon -->
{#if hasPending}
<div>
<Spinner className="size-4" />
</div>
{:else if toolCallCount > 0}
<div class="text-emerald-500 dark:text-emerald-400">
<CheckCircle className="size-4" strokeWidth="2" />
</div>
{:else}
<div class="text-gray-400 dark:text-gray-500">
<Sparkles className="size-3.5" />
</div>
{/if}
<!-- Summary text -->
<div class="flex-1 line-clamp-1">
<span class="text-gray-600 dark:text-gray-300 {hasPending ? 'shimmer' : ''}"
>{prefixText}</span
>
{#if summaryText}
<span class="text-gray-400 dark:text-gray-500">{summaryText}</span>
{/if}
</div>
<!-- Chevron -->
<div class="flex shrink-0 self-center text-gray-400 dark:text-gray-500">
{#if open}
<ChevronUp strokeWidth="3.5" className="size-3" />
{:else}
<ChevronDown strokeWidth="3.5" className="size-3" />
{/if}
</div>
</div>
</button>
{#if open}
<div transition:slide={{ duration: 300, easing: quintOut, axis: 'y' }}>
<div class="mb-0.5 space-y-0.5">
<slot name="content" />
</div>
</div>
{/if}
</div>
@@ -20,6 +20,7 @@
import ToolCallDisplay from '$lib/components/common/ToolCallDisplay.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Download from '$lib/components/icons/Download.svelte';
import ConsecutiveDetailsGroup from './ConsecutiveDetailsGroup.svelte';
import HtmlToken from './HTMLToken.svelte';
import Clipboard from '$lib/components/icons/Clipboard.svelte';
@@ -52,6 +53,51 @@
return 'h' + depth;
};
const GROUPABLE_DETAIL_TYPES = new Set(['tool_calls', 'reasoning', 'code_interpreter']);
const isGroupableDetailToken = (token: Token & { attributes?: { type?: string } }) => {
return token?.type === 'details' && GROUPABLE_DETAIL_TYPES.has(token?.attributes?.type ?? '');
};
const getDisplayTokens = (tokenList: Token[] = []) => {
const displayTokens = [];
let detailGroup = [];
const flushDetailGroup = () => {
if (detailGroup.length > 1) {
displayTokens.push({
type: 'detail_group',
items: [...detailGroup]
});
} else if (detailGroup.length === 1) {
displayTokens.push(detailGroup[0]);
}
detailGroup = [];
};
for (const token of tokenList) {
if (isGroupableDetailToken(token)) {
detailGroup.push(token);
} else {
flushDetailGroup();
displayTokens.push(token);
}
}
flushDetailGroup();
return displayTokens;
};
const getDetailTextContent = (token) => {
return decode(token?.text || '')
.replace(/<summary>.*?<\/summary>/gi, '')
.trim();
};
$: displayTokens = getDisplayTokens(tokens);
const exportTableToCSVHandler = (token, tokenIdx = 0) => {
console.log('Exporting table to CSV');
@@ -92,7 +138,7 @@
</script>
<!-- {JSON.stringify(tokens)} -->
{#each tokens as token, tokenIdx (tokenIdx)}
{#each displayTokens as token, tokenIdx (tokenIdx)}
{#if token.type === 'hr'}
<hr class=" border-gray-100/30 dark:border-gray-850/30" />
{:else if token.type === 'heading'}
@@ -320,10 +366,55 @@
{/each}
</ul>
{/if}
{:else if token.type === 'detail_group'}
<ConsecutiveDetailsGroup id={`${id}-${tokenIdx}-detail-group`} tokens={token.items} messageDone={done}>
<div slot="content" class="space-y-1">
{#each token.items as detailToken, detailIdx}
{@const textContent = getDetailTextContent(detailToken)}
{#if detailToken?.attributes?.type === 'tool_calls'}
<ToolCallDisplay
id={`${id}-${tokenIdx}-${detailIdx}-tc`}
attributes={detailToken.attributes}
open={false}
className="w-full space-y-1"
/>
{:else if textContent.length > 0}
<Collapsible
title={detailToken.summary}
open={$settings?.expandDetails ?? false}
attributes={detailToken?.attributes}
className="w-full space-y-1"
dir="auto"
>
<div class="mb-1.5" slot="content">
<svelte:self
id={`${id}-${tokenIdx}-${detailIdx}-d`}
tokens={marked.lexer(decode(detailToken.text))}
attributes={detailToken?.attributes}
{done}
{editCodeBlock}
{onTaskClick}
{sourceIds}
{onSourceClick}
/>
</div>
</Collapsible>
{:else}
<Collapsible
title={detailToken.summary}
open={false}
disabled={true}
attributes={detailToken?.attributes}
className="w-full space-y-1"
dir="auto"
/>
{/if}
{/each}
</div>
</ConsecutiveDetailsGroup>
{:else if token.type === 'details'}
{@const textContent = decode(token.text || '')
.replace(/<summary>.*?<\/summary>/gi, '')
.trim()}
{@const textContent = getDetailTextContent(token)}
{#if token?.attributes?.type === 'tool_calls'}
<!-- Tool calls have dedicated handling with ToolCallDisplay component -->
@@ -138,9 +138,9 @@
<!-- Label -->
<div class="flex-1 line-clamp-1">
<!-- Short label (below md) -->
<span class="@md:hidden font-semibold text-black dark:text-white">{attributes.name}</span>
<span class="@md:hidden text-black dark:text-white">{attributes.name}</span>
<!-- Full label (md and above) -->
<span class="hidden @md:inline">
<span class="hidden @md:inline font-normal">
{#if isDone}
<Markdown
id={`${componentId}-tool-call-title`}