Skip to content

Commit 9350fe9

Browse files
author
linyuan.yang
committed
message list
1 parent 1742052 commit 9350fe9

4 files changed

Lines changed: 105 additions & 23 deletions

File tree

packages/chat-ui/src/components/ChatArea.vue

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ const richInputRef = ref<InstanceType<typeof RichInput>>()
4848
const messagesEl = ref<HTMLElement | null>(null)
4949
const fileInputEl = ref<HTMLInputElement | null>(null)
5050
const isDragging = ref(false)
51+
const shouldStickToBottom = ref(true)
5152
let dragLeaveTimer: ReturnType<typeof setTimeout> | null = null
53+
let scrollFrame = 0
5254
5355
const { attachments, add: addFiles, remove: removeAttachment, drain: drainAttachments, isImageMime } = useAttachments()
5456
@@ -83,17 +85,26 @@ function isAtBottom(): boolean {
8385
return !el || el.scrollHeight - el.scrollTop - el.clientHeight < 60
8486
}
8587
88+
function updateStickToBottom() {
89+
shouldStickToBottom.value = isAtBottom()
90+
}
91+
8692
function scrollToBottom(force = false) {
87-
if (messagesEl.value && (force || isAtBottom())) {
88-
messagesEl.value.scrollTop = messagesEl.value.scrollHeight
89-
}
93+
if (!messagesEl.value || (!force && !shouldStickToBottom.value)) return
94+
if (scrollFrame) cancelAnimationFrame(scrollFrame)
95+
scrollFrame = requestAnimationFrame(() => {
96+
if (messagesEl.value && (force || shouldStickToBottom.value)) {
97+
messagesEl.value.scrollTop = messagesEl.value.scrollHeight
98+
}
99+
scrollFrame = 0
100+
})
90101
}
91102
92103
watch(() => props.messages.length, async () => {
93104
await nextTick(); scrollToBottom()
94105
})
95106
watch(() => props.streamingContent, async () => {
96-
await nextTick(); scrollToBottom(true)
107+
await nextTick(); scrollToBottom()
97108
})
98109
99110
// ── Attachments ──
@@ -142,6 +153,7 @@ function send() {
142153
143154
onBeforeUnmount(() => {
144155
if (dragLeaveTimer) clearTimeout(dragLeaveTimer)
156+
if (scrollFrame) cancelAnimationFrame(scrollFrame)
145157
})
146158
147159
defineExpose({ scrollToBottom })
@@ -166,7 +178,7 @@ defineExpose({ scrollToBottom })
166178
/>
167179

168180
<!-- Messages -->
169-
<div ref="messagesEl" class="chatui-messages-scroll">
181+
<div ref="messagesEl" class="chatui-messages-scroll" @scroll.passive="updateStickToBottom">
170182
<MessageList
171183
:messages="messages"
172184
:thinks-url-prefix="thinksUrlPrefix"

packages/chat-ui/src/components/MessageList.vue

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ function rowKindClass(m: StoredMessage) {
1919
exception: isException(m),
2020
}
2121
}
22-
import { fmtTs, fmtDateSep, toggleToolCall } from '../messageRender'
23-
import { inlineArgs, resultPreview } from '../toolCallFormat'
22+
import { fmtTs, fmtDateSep } from '../messageRender'
23+
import { inlineArgs, resultPreviewFromMessage } from '../toolCallFormat'
2424
import { resolveLabels, tpl } from '../labels'
2525
import ContentParts from './_ContentParts.vue'
2626
import ThinkDrawer from './ThinkDrawer.vue'
@@ -46,6 +46,51 @@ const props = withDefaults(defineProps<{
4646
4747
const L = computed(() => resolveLabels(props.labels))
4848
49+
const toolResultMap = computed(() => {
50+
const map = new Map<string, StoredMessage>()
51+
for (const msg of props.messages) {
52+
if (msg.message.role === MessageRole.Tool && msg.message.tool_call_id) {
53+
map.set(msg.message.tool_call_id, msg)
54+
}
55+
}
56+
return map
57+
})
58+
59+
const toolResultPreviewMap = computed(() => {
60+
const map = new Map<string, string>()
61+
for (const [toolCallId, msg] of toolResultMap.value) {
62+
const preview = resultPreviewFromMessage(msg)
63+
if (preview) map.set(toolCallId, preview)
64+
}
65+
return map
66+
})
67+
68+
const expandedToolCalls = ref<Set<string>>(new Set())
69+
70+
function messageKey(msg: StoredMessage, idx: number): string {
71+
if (msg.id != null) return `id:${msg.id}`
72+
const message = msg.message
73+
const role = String(message.role)
74+
const createdAt = msg.createdAt ?? 'na'
75+
const toolCallId = 'tool_call_id' in message ? (message.tool_call_id ?? '') : ''
76+
const toolCallIds = 'tool_calls' in message && message.tool_calls
77+
? message.tool_calls.map((tc: ToolCall) => tc.id).join(',')
78+
: ''
79+
const thinkId = msg.thinkId ?? ''
80+
return [role, createdAt, msg.kind, toolCallId, toolCallIds, thinkId, idx].join(':')
81+
}
82+
83+
function isToolCallExpanded(id: string): boolean {
84+
return expandedToolCalls.value.has(id)
85+
}
86+
87+
function toggleToolCall(id: string): void {
88+
const next = new Set(expandedToolCalls.value)
89+
if (next.has(id)) next.delete(id)
90+
else next.add(id)
91+
expandedToolCalls.value = next
92+
}
93+
4994
function sameDay(a?: number, b?: number) {
5095
if (!a || !b) return false
5196
return new Date(a * 1000).toDateString() === new Date(b * 1000).toDateString()
@@ -69,7 +114,7 @@ function openThink(thinkId: string) {
69114
}
70115
71116
function findToolResult(toolCallId: string): StoredMessage | undefined {
72-
return props.messages.find(m => m.message.role === MessageRole.Tool && m.message.tool_call_id === toolCallId)
117+
return toolResultMap.value.get(toolCallId)
73118
}
74119
75120
/** True when the row should be skipped (tool results are rendered nested inside their AI parent). */
@@ -82,7 +127,7 @@ function isEmbeddedTool(msg: StoredMessage): boolean {
82127
<div class="chatui-messages">
83128
<div v-if="messages.length === 0 && !isStreaming" class="chatui-empty">{{ L.noHistory }}</div>
84129

85-
<template v-for="(msg, idx) in messages" :key="idx">
130+
<template v-for="(msg, idx) in messages" :key="messageKey(msg, idx)">
86131
<div v-if="showDateSep(idx)" class="chatui-date-sep">
87132
<span>{{ fmtDateSep(msg.createdAt, L.dateToday, L.dateYesterday) }}</span>
88133
</div>
@@ -133,14 +178,20 @@ function isEmbeddedTool(msg: StoredMessage): boolean {
133178
</div>
134179
</div>
135180
<div v-for="tc in (msg.message.tool_calls as ToolCall[])" :key="tc.id" class="tool-call-item">
136-
<div class="tool-call-header" @click="toggleToolCall($event.currentTarget as HTMLElement)">
181+
<button
182+
type="button"
183+
class="tool-call-header"
184+
:class="{ expanded: isToolCallExpanded(tc.id) }"
185+
:aria-expanded="isToolCallExpanded(tc.id)"
186+
@click="toggleToolCall(tc.id)"
187+
>
137188
<span class="tool-call-name">{{ tc.name }}</span>
138189
<span v-if="inlineArgs(tc)" class="tool-call-inline-args">{{ inlineArgs(tc) }}</span>
139-
<span v-if="resultPreview(messages, tc.id)" class="tool-call-result-preview">↳ {{ resultPreview(messages, tc.id) }}</span>
140-
</div>
141-
<div class="tool-call-detail">
190+
<span v-if="toolResultPreviewMap.get(tc.id)" class="tool-call-result-preview">↳ {{ toolResultPreviewMap.get(tc.id) }}</span>
191+
</button>
192+
<div class="tool-call-detail" :class="{ show: isToolCallExpanded(tc.id) }">
142193
<div class="tool-call-args">{{ JSON.stringify(tc.args, null, 2) }}</div>
143-
<template v-if="findToolResult(tc.id) as StoredMessage | undefined">
194+
<template v-if="findToolResult(tc.id)">
144195
<div class="tool-call-result">
145196
<div class="tool-call-result-top">
146197
<div class="tool-call-result-label">{{ L.toolResult }}</div>
@@ -370,12 +421,18 @@ function isEmbeddedTool(msg: StoredMessage): boolean {
370421
overflow: hidden;
371422
}
372423
.tool-call-header {
424+
width: 100%;
425+
border: 0;
426+
background: transparent;
427+
color: inherit;
373428
padding: 6px 10px;
374429
cursor: pointer;
375430
display: flex;
376431
align-items: center;
377432
gap: 8px;
378433
font-weight: 500;
434+
font: inherit;
435+
text-align: left;
379436
user-select: none;
380437
}
381438
.tool-call-header::after {

packages/chat-ui/src/components/_ContentParts.vue

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
<script setup lang="ts">
22
import { computed } from 'vue'
33
import { ContentPartType } from '../types'
4-
import type { DisplayContent } from '../types'
4+
import type { DisplayContent, DisplayPart } from '../types'
55
import { getContentParts, renderMd } from '../messageRender'
66
7+
type RenderedDisplayPart = DisplayPart & { html?: string }
8+
79
const props = withDefaults(defineProps<{
810
content: DisplayContent | null | undefined
911
textClass?: string
@@ -15,12 +17,18 @@ const props = withDefaults(defineProps<{
1517
1618
const emit = defineEmits<{ openImage: [url: string] }>()
1719
18-
const parts = computed(() => getContentParts(props.content))
20+
const renderedParts = computed<RenderedDisplayPart[]>(() =>
21+
getContentParts(props.content).map((part) => (
22+
part.type === ContentPartType.Text
23+
? { ...part, html: renderMd(part.text ?? '') }
24+
: part
25+
)),
26+
)
1927
</script>
2028

2129
<template>
22-
<template v-for="(part, idx) in parts" :key="idx">
23-
<div v-if="part.type === ContentPartType.Text" :class="textClass" v-html="renderMd(part.text)" />
30+
<template v-for="(part, idx) in renderedParts" :key="idx">
31+
<div v-if="part.type === ContentPartType.Text" :class="textClass" v-html="part.html" />
2432
<div v-else-if="part.type === ContentPartType.Image" class="inline-image">
2533
<img :src="part.url" class="inline-image-thumb" @click="emit('openImage', part.url!)" />
2634
</div>

packages/chat-ui/src/toolCallFormat.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,8 @@ export function inlineArgs(tc: ToolCall): string {
1919
return keys.map((k) => `${k}=${truncate(stringify(obj[k]), 40)}`).join(' ')
2020
}
2121

22-
/** Find the tool-result message for `toolCallId` and produce a short single-line preview. */
23-
export function resultPreview(messages: StoredMessage[], toolCallId: string): string {
24-
const msg = messages.find(
25-
(m) => m.message.role === MessageRole.Tool && m.message.tool_call_id === toolCallId,
26-
)
22+
/** Produce a short single-line preview for a tool-result message. */
23+
export function resultPreviewFromMessage(msg: StoredMessage | undefined): string {
2724
const raw = msg?.message.content
2825
if (!raw) return ''
2926

@@ -53,3 +50,11 @@ export function resultPreview(messages: StoredMessage[], toolCallId: string): st
5350
const combined = [media, text].filter(Boolean).join(' ')
5451
return combined ? truncate(combined, 80) : ''
5552
}
53+
54+
/** Find the tool-result message for `toolCallId` and produce a short single-line preview. */
55+
export function resultPreview(messages: StoredMessage[], toolCallId: string): string {
56+
const msg = messages.find(
57+
(m) => m.message.role === MessageRole.Tool && m.message.tool_call_id === toolCallId,
58+
)
59+
return resultPreviewFromMessage(msg)
60+
}

0 commit comments

Comments
 (0)