Skip to content

Commit 261aec8

Browse files
committed
enh: consecutive details rendering
1 parent 61cfaab commit 261aec8

3 files changed

Lines changed: 224 additions & 6 deletions

File tree

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
<script lang="ts">
2+
import { getContext } from 'svelte';
3+
import { slide } from 'svelte/transition';
4+
import { quintOut } from 'svelte/easing';
5+
6+
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
7+
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
8+
import Spinner from '$lib/components/common/Spinner.svelte';
9+
import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte';
10+
import Sparkles from '$lib/components/icons/Sparkles.svelte';
11+
import CheckCircle from '$lib/components/icons/CheckCircle.svelte';
12+
13+
const i18n = getContext('i18n');
14+
15+
export let id = '';
16+
export let tokens: Array<{
17+
summary?: string;
18+
attributes?: {
19+
type?: string;
20+
name?: string;
21+
done?: string;
22+
duration?: string;
23+
};
24+
}> = [];
25+
26+
export let messageDone = true;
27+
28+
let open = false;
29+
30+
$: toolCallCount = tokens.filter((t) => t?.attributes?.type === 'tool_calls').length;
31+
$: reasoningCount = tokens.filter((t) => t?.attributes?.type === 'reasoning').length;
32+
$: hasPending = tokens.some(
33+
(t) => t?.attributes?.done !== undefined && t?.attributes?.done !== 'true'
34+
);
35+
36+
$: codeInterpreterCount = tokens.filter((t) => t?.attributes?.type === 'code_interpreter').length;
37+
38+
$: summaryText = (() => {
39+
const parts = [];
40+
41+
if (toolCallCount > 0) {
42+
// Group by tool name and show counts
43+
const nameCounts = {};
44+
tokens
45+
.filter((t) => t?.attributes?.type === 'tool_calls')
46+
.forEach((t) => {
47+
const name = t?.attributes?.name ?? 'tool';
48+
nameCounts[name] = (nameCounts[name] || 0) + 1;
49+
});
50+
51+
const toolParts = Object.entries(nameCounts).map(([name, count]) =>
52+
count > 1 ? `${count} ${name}` : name
53+
);
54+
parts.push(...toolParts);
55+
}
56+
57+
if (codeInterpreterCount > 0) {
58+
if (codeInterpreterCount === 1) {
59+
parts.push($i18n.t('Ran {{COUNT}} analysis', { COUNT: codeInterpreterCount }));
60+
} else {
61+
parts.push($i18n.t('Ran {{COUNT}} analyses', { COUNT: codeInterpreterCount }));
62+
}
63+
}
64+
65+
const prefix = hasPending ? $i18n.t('Exploring') : $i18n.t('Explored');
66+
const detail = parts.join(', ');
67+
return detail;
68+
})();
69+
70+
$: prefixText = hasPending ? $i18n.t('Exploring') : $i18n.t('Explored');
71+
</script>
72+
73+
<div {id} class="w-full">
74+
<!-- svelte-ignore a11y-no-static-element-interactions -->
75+
<button
76+
class="w-fit text-left text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition cursor-pointer"
77+
aria-label={$i18n.t('Toggle details')}
78+
aria-expanded={open}
79+
on:click={() => {
80+
open = !open;
81+
}}
82+
>
83+
<div class="flex items-center gap-1.5">
84+
<!-- Status icon -->
85+
{#if hasPending}
86+
<div>
87+
<Spinner className="size-4" />
88+
</div>
89+
{:else if toolCallCount > 0}
90+
<div class="text-emerald-500 dark:text-emerald-400">
91+
<CheckCircle className="size-4" strokeWidth="2" />
92+
</div>
93+
{:else}
94+
<div class="text-gray-400 dark:text-gray-500">
95+
<Sparkles className="size-3.5" />
96+
</div>
97+
{/if}
98+
99+
<!-- Summary text -->
100+
<div class="flex-1 line-clamp-1">
101+
<span class="text-gray-600 dark:text-gray-300 {hasPending ? 'shimmer' : ''}"
102+
>{prefixText}</span
103+
>
104+
{#if summaryText}
105+
<span class="text-gray-400 dark:text-gray-500">{summaryText}</span>
106+
{/if}
107+
</div>
108+
109+
<!-- Chevron -->
110+
<div class="flex shrink-0 self-center text-gray-400 dark:text-gray-500">
111+
{#if open}
112+
<ChevronUp strokeWidth="3.5" className="size-3" />
113+
{:else}
114+
<ChevronDown strokeWidth="3.5" className="size-3" />
115+
{/if}
116+
</div>
117+
</div>
118+
</button>
119+
120+
{#if open}
121+
<div transition:slide={{ duration: 300, easing: quintOut, axis: 'y' }}>
122+
<div class="mb-0.5 space-y-0.5">
123+
<slot name="content" />
124+
</div>
125+
</div>
126+
{/if}
127+
</div>

src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte

Lines changed: 95 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import ToolCallDisplay from '$lib/components/common/ToolCallDisplay.svelte';
2121
import Tooltip from '$lib/components/common/Tooltip.svelte';
2222
import Download from '$lib/components/icons/Download.svelte';
23+
import ConsecutiveDetailsGroup from './ConsecutiveDetailsGroup.svelte';
2324
2425
import HtmlToken from './HTMLToken.svelte';
2526
import Clipboard from '$lib/components/icons/Clipboard.svelte';
@@ -52,6 +53,51 @@
5253
return 'h' + depth;
5354
};
5455
56+
const GROUPABLE_DETAIL_TYPES = new Set(['tool_calls', 'reasoning', 'code_interpreter']);
57+
58+
const isGroupableDetailToken = (token: Token & { attributes?: { type?: string } }) => {
59+
return token?.type === 'details' && GROUPABLE_DETAIL_TYPES.has(token?.attributes?.type ?? '');
60+
};
61+
62+
const getDisplayTokens = (tokenList: Token[] = []) => {
63+
const displayTokens = [];
64+
let detailGroup = [];
65+
66+
const flushDetailGroup = () => {
67+
if (detailGroup.length > 1) {
68+
displayTokens.push({
69+
type: 'detail_group',
70+
items: [...detailGroup]
71+
});
72+
} else if (detailGroup.length === 1) {
73+
displayTokens.push(detailGroup[0]);
74+
}
75+
76+
detailGroup = [];
77+
};
78+
79+
for (const token of tokenList) {
80+
if (isGroupableDetailToken(token)) {
81+
detailGroup.push(token);
82+
} else {
83+
flushDetailGroup();
84+
displayTokens.push(token);
85+
}
86+
}
87+
88+
flushDetailGroup();
89+
90+
return displayTokens;
91+
};
92+
93+
const getDetailTextContent = (token) => {
94+
return decode(token?.text || '')
95+
.replace(/<summary>.*?<\/summary>/gi, '')
96+
.trim();
97+
};
98+
99+
$: displayTokens = getDisplayTokens(tokens);
100+
55101
const exportTableToCSVHandler = (token, tokenIdx = 0) => {
56102
console.log('Exporting table to CSV');
57103
@@ -92,7 +138,7 @@
92138
</script>
93139

94140
<!-- {JSON.stringify(tokens)} -->
95-
{#each tokens as token, tokenIdx (tokenIdx)}
141+
{#each displayTokens as token, tokenIdx (tokenIdx)}
96142
{#if token.type === 'hr'}
97143
<hr class=" border-gray-100/30 dark:border-gray-850/30" />
98144
{:else if token.type === 'heading'}
@@ -320,10 +366,55 @@
320366
{/each}
321367
</ul>
322368
{/if}
369+
{:else if token.type === 'detail_group'}
370+
<ConsecutiveDetailsGroup id={`${id}-${tokenIdx}-detail-group`} tokens={token.items} messageDone={done}>
371+
<div slot="content" class="space-y-1">
372+
{#each token.items as detailToken, detailIdx}
373+
{@const textContent = getDetailTextContent(detailToken)}
374+
375+
{#if detailToken?.attributes?.type === 'tool_calls'}
376+
<ToolCallDisplay
377+
id={`${id}-${tokenIdx}-${detailIdx}-tc`}
378+
attributes={detailToken.attributes}
379+
open={false}
380+
className="w-full space-y-1"
381+
/>
382+
{:else if textContent.length > 0}
383+
<Collapsible
384+
title={detailToken.summary}
385+
open={$settings?.expandDetails ?? false}
386+
attributes={detailToken?.attributes}
387+
className="w-full space-y-1"
388+
dir="auto"
389+
>
390+
<div class="mb-1.5" slot="content">
391+
<svelte:self
392+
id={`${id}-${tokenIdx}-${detailIdx}-d`}
393+
tokens={marked.lexer(decode(detailToken.text))}
394+
attributes={detailToken?.attributes}
395+
{done}
396+
{editCodeBlock}
397+
{onTaskClick}
398+
{sourceIds}
399+
{onSourceClick}
400+
/>
401+
</div>
402+
</Collapsible>
403+
{:else}
404+
<Collapsible
405+
title={detailToken.summary}
406+
open={false}
407+
disabled={true}
408+
attributes={detailToken?.attributes}
409+
className="w-full space-y-1"
410+
dir="auto"
411+
/>
412+
{/if}
413+
{/each}
414+
</div>
415+
</ConsecutiveDetailsGroup>
323416
{:else if token.type === 'details'}
324-
{@const textContent = decode(token.text || '')
325-
.replace(/<summary>.*?<\/summary>/gi, '')
326-
.trim()}
417+
{@const textContent = getDetailTextContent(token)}
327418

328419
{#if token?.attributes?.type === 'tool_calls'}
329420
<!-- Tool calls have dedicated handling with ToolCallDisplay component -->

src/lib/components/common/ToolCallDisplay.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,9 @@
138138
<!-- Label -->
139139
<div class="flex-1 line-clamp-1">
140140
<!-- Short label (below md) -->
141-
<span class="@md:hidden font-semibold text-black dark:text-white">{attributes.name}</span>
141+
<span class="@md:hidden text-black dark:text-white">{attributes.name}</span>
142142
<!-- Full label (md and above) -->
143-
<span class="hidden @md:inline">
143+
<span class="hidden @md:inline font-normal">
144144
{#if isDone}
145145
<Markdown
146146
id={`${componentId}-tool-call-title`}

0 commit comments

Comments
 (0)