From 3012322cf2c09709b4565238e7724fe87b9267d2 Mon Sep 17 00:00:00 2001 From: Kangyang Ji Date: Sun, 12 Apr 2026 22:56:20 +0100 Subject: [PATCH 1/7] feat(dashboard): add simple virtual scroll, need migrate --- dashboard/src/components/chat/Chat.vue | 526 ++++++++++-------- dashboard/src/components/chat/MessageList.vue | 84 ++- 2 files changed, 370 insertions(+), 240 deletions(-) diff --git a/dashboard/src/components/chat/Chat.vue b/dashboard/src/components/chat/Chat.vue index 2c667afb1b..b7a4aca538 100644 --- a/dashboard/src/components/chat/Chat.vue +++ b/dashboard/src/components/chat/Chat.vue @@ -326,7 +326,6 @@
@@ -342,239 +341,243 @@
{{ tm("welcome.title") }}
-
-
- - - - - -
-
+ -
+
-
- {{ - formatTime(msg.created_at) - }} - - - - - -
- {{ tm("stats.inputTokens") }} - {{ - inputTokens(messageContent(msg).agentStats) - }} -
-
- {{ tm("stats.outputTokens") }} - {{ - outputTokens(messageContent(msg).agentStats) - }} -
-
- {{ tm("stats.ttft") }} - {{ - agentTtft(messageContent(msg).agentStats) - }} -
-
- {{ tm("stats.duration") }} - {{ - agentDuration(messageContent(msg).agentStats) - }} -
-
-
-
- + {{ + formatTime(msg.created_at) + }} + + + + + +
+ {{ tm("stats.inputTokens") }} + {{ + inputTokens(messageContent(msg).agentStats) + }} +
+
+ {{ tm("stats.outputTokens") }} + {{ + outputTokens(messageContent(msg).agentStats) + }} +
+
+ {{ tm("stats.ttft") }} + {{ + agentTtft(messageContent(msg).agentStats) + }} +
+
+ {{ tm("stats.duration") }} + {{ + agentDuration(messageContent(msg).agentStats) + }} +
+
+
+
+ +
-
- + +
@@ -781,6 +784,9 @@ const loadingSessions = ref(false); const draft = ref(""); const downloadingFiles = ref(new Set()); const messagesContainer = ref(null); +const virtualScrollRef = ref<{ scrollToIndex: (index: number) => void } | null>(null); +const messageHeights = reactive(new Map()); +let resizeObserver: ResizeObserver | null = null; const inputRef = ref | null>(null); const shouldStickToBottom = ref(true); const replyTarget = ref(null); @@ -801,6 +807,15 @@ const chatSidebarDrawer = computed({ const isSidebarCollapsed = computed(() => lgAndUp.value ? sidebarCollapsed.value : !customizer.chatSidebarOpen, ); +const dynamicEstimatedItemHeight = computed(() => { + const msgs = activeMessages.value; + if (!msgs.length) return 120; + let total = 0; + for (let i = 0; i < msgs.length; i++) { + total += getEstimatedItemHeight(msgs[i], i); + } + return Math.max(120, Math.min(total / msgs.length, 300)); +}); const { loadingMessages, @@ -897,9 +912,18 @@ onMounted(async () => { } finally { loadingSessions.value = false; } + resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const id = entry.target.getAttribute("data-id"); + if (id) { + messageHeights.set(id, entry.contentRect.height); + } + } + }); }); onBeforeUnmount(() => { + resizeObserver?.disconnect(); cleanupMediaCache(); }); @@ -920,7 +944,7 @@ watch(activeMessages, () => { if (shouldStickToBottom.value) { scrollToBottom(); } -}); +}, { flush: "post" }); function getRouteSessionId() { const raw = route.params.conversationId; @@ -1225,8 +1249,9 @@ function scrollToMessage(messageId?: string | number) { (message) => String(message.id) === String(messageId), ); if (index < 0) return; - const rows = messagesContainer.value?.querySelectorAll(".message-row"); - rows?.[index]?.scrollIntoView({ behavior: "smooth", block: "center" }); + nextTick(() => { + virtualScrollRef.value?.scrollToIndex(index); + }); } function setReplyTarget(message: ChatRecord) { @@ -1366,23 +1391,57 @@ function stopRecording() { isRecording.value = false; } -function handleMessagesScroll() { - const container = messagesContainer.value; - if (!container) return; - const distance = - container.scrollHeight - container.scrollTop - container.clientHeight; +function handleVirtualScroll(event: Event) { + const el = event.target as HTMLElement; + if (!el) return; + const { scrollTop, scrollHeight, clientHeight } = el; + const distance = scrollHeight - scrollTop - clientHeight; shouldStickToBottom.value = distance < 80; } -function scrollToBottom() { - nextTick(() => { - const container = messagesContainer.value; - if (!container) return; - container.scrollTop = container.scrollHeight; - shouldStickToBottom.value = true; - }); +async function scrollToBottom() { + if (activeMessages.value.length === 0) return; + shouldStickToBottom.value = true; + await nextTick(); + const lastIndex = activeMessages.value.length - 1; + const scroll = () => { + virtualScrollRef.value?.scrollToIndex(lastIndex); + }; + scroll(); + requestAnimationFrame(scroll); + setTimeout(scroll, 200); } +const observeHeight = (el: HTMLElement | null, id: string | number | undefined) => { + if (el && resizeObserver) { + const stringId = id ? String(id) : el.dataset.index || "unknown"; + el.setAttribute("data-id", stringId); + resizeObserver.observe(el); + } +}; + +const getEstimatedItemHeight = (msg: ChatRecord, index: number) => { + const id = msg.id ? String(msg.id) : `idx-${index}`; + const cached = messageHeights.get(id); + if (cached) return cached; + + let h = 80; + const parts = messageParts(msg); + + if (messageContent(msg).reasoning) h += 100; + + for (const part of parts) { + if (part.type === "plain") { + const lines = Math.ceil(String(part.text || "").length / 50); + h += lines * 20; + } + if (part.type === "image") h += 250; + if (part.type === "tool_call") h += 150; + } + + return Math.min(h, 500); +}; + async function stopCurrentSession() { if (!currSessionId.value) return; try { @@ -1684,8 +1743,9 @@ function formatDuration(seconds: number) { .messages-panel { flex: 1; min-height: 0; - overflow-y: auto; - padding: 24px max(24px, calc((100% - 980px) / 2)) 18px; + display: flex; + flex-direction: column; + padding: 24px 0 18px; } .empty-chat .messages-panel { @@ -1739,15 +1799,17 @@ function formatDuration(seconds: number) { } .messages-list { - display: flex; - flex-direction: column; - gap: 22px; + flex: 1; + min-height: 0; + width: 100%; } .message-row { display: flex; gap: 10px; - max-width: 100%; + max-width: 75%; + padding-bottom: 22px; + margin: 0 auto; } .message-row.from-user { diff --git a/dashboard/src/components/chat/MessageList.vue b/dashboard/src/components/chat/MessageList.vue index b83fdeef92..97706565a7 100644 --- a/dashboard/src/components/chat/MessageList.vue +++ b/dashboard/src/components/chat/MessageList.vue @@ -8,7 +8,10 @@ -
+
- + @@ -277,11 +280,23 @@ const { tm } = useModuleI18n("features/chat"); const customMarkdownTags = ["ref"]; const downloadingFiles = ref(new Set()); const messageListRoot = ref(null); +const virtualScrollRef = ref<{ scrollToIndex: (index: number) => void } | null>(null); +const messageHeights = reactive(new Map()); +let resizeObserver: ResizeObserver | null = null; const imagePreview = reactive({ visible: false, url: "" }); const refsSidebarOpen = ref(false); const selectedRefs = ref | null>(null); const messages = computed(() => props.messages || []); +const dynamicEstimatedItemHeight = computed(() => { + const msgs = messages.value; + if (!msgs.length) return 120; + let total = 0; + for (let i = 0; i < msgs.length; i++) { + total += getEstimatedItemHeight(msgs[i], i); + } + return Math.max(120, Math.min(total / msgs.length, 300)); +}); function isUserMessage(message: ChatRecord) { return messageContent(message).type === "user"; @@ -364,11 +379,62 @@ function scrollToMessage(messageId?: string | number) { ); if (index < 0) return; nextTick(() => { - const rows = messageListRoot.value?.querySelectorAll(".message-row"); - rows?.[index]?.scrollIntoView({ behavior: "smooth", block: "center" }); + virtualScrollRef.value?.scrollToIndex(index); }); } +function handleVirtualScroll(event: Event) { + const el = event.target as HTMLElement; + if (!el) return; + const { scrollTop, scrollHeight, clientHeight } = el; + const distance = scrollHeight - scrollTop - clientHeight; + shouldStickToBottom.value = distance < 80; +} + +async function scrollToBottom() { + if (messages.value.length === 0) return; + shouldStickToBottom.value = true; + await nextTick(); + const lastIndex = messages.value.length - 1; + const scroll = () => { + virtualScrollRef.value?.scrollToIndex(lastIndex); + }; + scroll(); + requestAnimationFrame(scroll); + setTimeout(scroll, 200); +} + +const observeHeight = (el: HTMLElement | null, id: string | number | undefined) => { + if (el && resizeObserver) { + const stringId = id ? String(id) : el.dataset.index || "unknown"; + el.setAttribute("data-id", stringId); + resizeObserver.observe(el); + } +}; + +const getEstimatedItemHeight = (msg: ChatRecord, index: number) => { + const id = msg.id ? String(msg.id) : `idx-${index}`; + const cached = messageHeights.get(id); + if (cached) return cached; + + let h = 80; + const parts = messageParts(msg); + + if (messageContent(msg).reasoning) h += 100; + + for (const part of parts) { + if (part.type === "plain") { + const lines = Math.ceil(String(part.text || "").length / 50); + h += lines * 20; + } + if (part.type === "image") h += 250; + if (part.type === "tool_call") h += 150; + } + + return Math.min(h, 500); +}; + + function showMessageMeta(message: ChatRecord, msgIndex: number) { return !messageContent(message).isLoading && !isMessageStreaming(msgIndex); } @@ -557,15 +623,17 @@ function formatDuration(seconds: number) { } .messages-list { - display: flex; - flex-direction: column; - gap: 22px; + flex: 1; + min-height: 0; + width: 100%; } .message-row { display: flex; gap: 10px; - max-width: 100%; + max-width: 75%; + padding-bottom: 22px; + margin: 0 auto; } .message-row.from-user { From 0611ed6666f45d23002e31dbed1d07cf283f1e03 Mon Sep 17 00:00:00 2001 From: Kangyang Ji Date: Mon, 13 Apr 2026 12:33:10 +0100 Subject: [PATCH 2/7] rfc: move message list from Chat.vue --- dashboard/src/components/chat/Chat.vue | 556 +----------------- dashboard/src/components/chat/MessageList.vue | 154 +++-- 2 files changed, 132 insertions(+), 578 deletions(-) diff --git a/dashboard/src/components/chat/Chat.vue b/dashboard/src/components/chat/Chat.vue index b7a4aca538..599550f981 100644 --- a/dashboard/src/components/chat/Chat.vue +++ b/dashboard/src/components/chat/Chat.vue @@ -341,243 +341,17 @@
{{ tm("welcome.title") }}
- - - +
@@ -650,20 +424,6 @@ - - - - preview - @@ -691,14 +451,8 @@ import ProjectDialog, { import ProjectList, { type Project } from "@/components/chat/ProjectList.vue"; import ProjectView from "@/components/chat/ProjectView.vue"; import ChatInput from "@/components/chat/ChatInput.vue"; -import ReasoningBlock from "@/components/chat/message_list_comps/ReasoningBlock.vue"; -import ToolCallCard from "@/components/chat/message_list_comps/ToolCallCard.vue"; -import ToolCallItem from "@/components/chat/message_list_comps/ToolCallItem.vue"; -import IPythonToolBlock from "@/components/chat/message_list_comps/IPythonToolBlock.vue"; -import RefsSidebar from "@/components/chat/message_list_comps/RefsSidebar.vue"; +import MessageList from "@/components/chat/MessageList.vue"; import RefNode from "@/components/chat/message_list_comps/RefNode.vue"; -import ActionRef from "@/components/chat/message_list_comps/ActionRef.vue"; -import MarkdownMessagePart from "@/components/chat/message_list_comps/MarkdownMessagePart.vue"; import { useSessions, type Session } from "@/composables/useSessions"; import { useMessages, @@ -782,17 +536,11 @@ const savingSessionTitle = ref(false); const projectSessions = ref([]); const loadingSessions = ref(false); const draft = ref(""); -const downloadingFiles = ref(new Set()); -const messagesContainer = ref(null); -const virtualScrollRef = ref<{ scrollToIndex: (index: number) => void } | null>(null); -const messageHeights = reactive(new Map()); -let resizeObserver: ResizeObserver | null = null; +const messageListRef = ref | null>(null); const inputRef = ref | null>(null); const shouldStickToBottom = ref(true); const replyTarget = ref(null); const imagePreview = reactive({ visible: false, url: "" }); -const refsSidebarOpen = ref(false); -const selectedRefs = ref | null>(null); const enableStreaming = ref(true); const isRecording = ref(false); const sendShortcut = ref<"enter" | "shift_enter">("enter"); @@ -807,15 +555,6 @@ const chatSidebarDrawer = computed({ const isSidebarCollapsed = computed(() => lgAndUp.value ? sidebarCollapsed.value : !customizer.chatSidebarOpen, ); -const dynamicEstimatedItemHeight = computed(() => { - const msgs = activeMessages.value; - if (!msgs.length) return 120; - let total = 0; - for (let i = 0; i < msgs.length; i++) { - total += getEstimatedItemHeight(msgs[i], i); - } - return Math.max(120, Math.min(total / msgs.length, 300)); -}); const { loadingMessages, @@ -824,9 +563,6 @@ const { sessionProjects, activeMessages, isSessionRunning, - isUserMessage, - isMessageStreaming, - messageContent, messageParts, loadSessionMessages, createLocalExchange, @@ -912,18 +648,9 @@ onMounted(async () => { } finally { loadingSessions.value = false; } - resizeObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - const id = entry.target.getAttribute("data-id"); - if (id) { - messageHeights.set(id, entry.contentRect.height); - } - } - }); }); onBeforeUnmount(() => { - resizeObserver?.disconnect(); cleanupMediaCache(); }); @@ -940,12 +667,6 @@ watch( }, ); -watch(activeMessages, () => { - if (shouldStickToBottom.value) { - scrollToBottom(); - } -}, { flush: "post" }); - function getRouteSessionId() { const raw = route.params.conversationId; return Array.isArray(raw) ? raw[0] : raw || ""; @@ -1184,45 +905,12 @@ function buildOutgoingParts(text: string): MessagePart[] { return parts; } -function hasNonReasoningContent(message: ChatRecord) { - return messageParts(message).some((part) => { - if (part.type === "reply") return false; - if (part.type === "plain") return Boolean(String(part.text || "").trim()); - return true; - }); -} - function updateTitleFromText(sessionId: string, text: string) { const session = sessions.value.find((item) => item.session_id === sessionId); if (!session || session.display_name || !text) return; updateSessionTitle(sessionId, text.slice(0, 40)); } -function partUrl(part: MessagePart) { - if (part.embedded_url) return part.embedded_url; - if (part.embedded_file?.url) return part.embedded_file.url; - if (part.attachment_id) - return `/api/chat/get_attachment?attachment_id=${encodeURIComponent( - part.attachment_id, - )}`; - if (part.filename) - return `/api/chat/get_file?filename=${encodeURIComponent(part.filename)}`; - return ""; -} - -function formatJson(value: unknown) { - if (typeof value === "string") { - const parsed = parseJsonSafe(value); - if (parsed !== value) return JSON.stringify(parsed, null, 2); - return value; - } - try { - return JSON.stringify(value, null, 2); - } catch { - return String(value ?? ""); - } -} - function replyPreview(messageId?: string | number, fallback?: string) { if (fallback) return truncate(fallback, 80); const found = activeMessages.value.find( @@ -1243,131 +931,6 @@ function truncate(value: string, max: number) { return value.length > max ? `${value.slice(0, max)}...` : value; } -function scrollToMessage(messageId?: string | number) { - if (!messageId) return; - const index = activeMessages.value.findIndex( - (message) => String(message.id) === String(messageId), - ); - if (index < 0) return; - nextTick(() => { - virtualScrollRef.value?.scrollToIndex(index); - }); -} - -function setReplyTarget(message: ChatRecord) { - replyTarget.value = message; - nextTick(() => inputRef.value?.focusInput?.()); -} - -function showMessageMeta(message: ChatRecord, msgIndex: number) { - return ( - !messageContent(message).isLoading && !isMessageStreaming(message, msgIndex) - ); -} - -function messageRefs(message: ChatRecord) { - return resolvedMessageRefs(message).used; -} - -function resolvedMessageRefs(message: ChatRecord) { - return normalizeRefs(messageContent(message).refs); -} - -function normalizeRefs(refs: unknown) { - if (!refs) return { used: [] as Array> }; - const used = Array.isArray((refs as any)?.used) - ? (refs as any).used - : Array.isArray(refs) - ? refs - : []; - - return { - used: normalizeRefItems(used), - }; -} - -function normalizeRefItems(items: unknown[]) { - return items - .map((item: any) => ({ - index: item?.index, - title: item?.title || item?.url || tm("refs.title"), - url: item?.url, - snippet: item?.snippet, - favicon: item?.favicon, - })) - .filter((item) => item.url); -} - -function openRefsSidebar(refs: unknown) { - selectedRefs.value = - refs && typeof refs === "object" ? (refs as Record) : null; - refsSidebarOpen.value = true; -} - -function normalizeToolCall(tool: Record) { - const normalized = { ...tool }; - normalized.args = normalized.args ?? normalized.arguments ?? {}; - normalized.ts = normalized.ts ?? Date.now() / 1000; - if (normalized.result && typeof normalized.result === "object") { - normalized.result = JSON.stringify(normalized.result, null, 2); - } - return normalized; -} - -function isIPythonToolCall(tool: Record) { - const name = String(tool.name || "").toLowerCase(); - return name.includes("python") || name.includes("ipython"); -} - -function toolCallStatusText(tool: Record) { - if (tool.finished_ts) return tm("toolStatus.done"); - return tm("toolStatus.running"); -} - -function parseJsonSafe(value: unknown) { - if (typeof value !== "string") return value; - try { - return JSON.parse(value); - } catch { - return value; - } -} - -async function copyMessage(message: ChatRecord) { - const text = plainTextFromMessage(message); - if (!text) return; - await navigator.clipboard?.writeText(text); -} - -async function downloadPart(part: MessagePart) { - const key = part.attachment_id || part.filename || ""; - if (!key) return; - downloadingFiles.value = new Set(downloadingFiles.value).add(key); - try { - const response = await axios.get(partUrl(part), { responseType: "blob" }); - const url = URL.createObjectURL(response.data); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = part.filename || "file"; - anchor.click(); - URL.revokeObjectURL(url); - } finally { - const next = new Set(downloadingFiles.value); - next.delete(key); - downloadingFiles.value = next; - } -} - -function openImage(url: string) { - imagePreview.url = url; - imagePreview.visible = true; -} - -function closeImage() { - imagePreview.visible = false; - imagePreview.url = ""; -} - async function handleFilesSelected(files: FileList) { const selectedFiles = Array.from(files || []); for (const file of selectedFiles) { @@ -1391,56 +954,13 @@ function stopRecording() { isRecording.value = false; } -function handleVirtualScroll(event: Event) { - const el = event.target as HTMLElement; - if (!el) return; - const { scrollTop, scrollHeight, clientHeight } = el; - const distance = scrollHeight - scrollTop - clientHeight; - shouldStickToBottom.value = distance < 80; -} - async function scrollToBottom() { if (activeMessages.value.length === 0) return; shouldStickToBottom.value = true; await nextTick(); - const lastIndex = activeMessages.value.length - 1; - const scroll = () => { - virtualScrollRef.value?.scrollToIndex(lastIndex); - }; - scroll(); - requestAnimationFrame(scroll); - setTimeout(scroll, 200); -} - -const observeHeight = (el: HTMLElement | null, id: string | number | undefined) => { - if (el && resizeObserver) { - const stringId = id ? String(id) : el.dataset.index || "unknown"; - el.setAttribute("data-id", stringId); - resizeObserver.observe(el); - } -}; - -const getEstimatedItemHeight = (msg: ChatRecord, index: number) => { - const id = msg.id ? String(msg.id) : `idx-${index}`; - const cached = messageHeights.get(id); - if (cached) return cached; - - let h = 80; - const parts = messageParts(msg); - - if (messageContent(msg).reasoning) h += 100; - - for (const part of parts) { - if (part.type === "plain") { - const lines = Math.ceil(String(part.text || "").length / 50); - h += lines * 20; - } - if (part.type === "image") h += 250; - if (part.type === "tool_call") h += 150; - } + messageListRef.value?.scrollToBottom(); +} - return Math.min(h, 500); -}; async function stopCurrentSession() { if (!currSessionId.value) return; @@ -1455,44 +975,6 @@ function toggleTheme() { customizer.SET_UI_THEME(isDark.value ? "PurpleTheme" : "PurpleThemeDark"); } -function formatTime(value: string) { - const date = new Date(value); - if (Number.isNaN(date.getTime())) return ""; - return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); -} - -function inputTokens(stats: any) { - const usage = stats?.token_usage || {}; - return (usage.input_other || 0) + (usage.input_cached || 0); -} - -function outputTokens(stats: any) { - return stats?.token_usage?.output || 0; -} - -function agentDuration(stats: any) { - const directDuration = readPositiveNumber(stats, [ - "duration", - "total_duration", - ]); - if (directDuration !== null) return formatDuration(directDuration); - - const startTime = readPositiveNumber(stats, ["start_time"]); - const endTime = readPositiveNumber(stats, ["end_time"]); - if (startTime === null || endTime === null || endTime < startTime) return "-"; - return formatDuration(endTime - startTime); -} - -function agentTtft(stats: any) { - const ttft = readPositiveNumber(stats, [ - "time_to_first_token", - "ttft", - "first_token_latency", - ]); - if (ttft === null) return ""; - return formatDuration(ttft); -} - function readPositiveNumber(source: any, keys: string[]) { for (const key of keys) { const value = Number(source?.[key]); @@ -1500,14 +982,6 @@ function readPositiveNumber(source: any, keys: string[]) { } return null; } - -function formatDuration(seconds: number) { - if (seconds < 1) return `${Math.round(seconds * 1000)}ms`; - if (seconds < 60) return `${seconds.toFixed(1)}s`; - const minutes = Math.floor(seconds / 60); - const restSeconds = Math.round(seconds % 60); - return `${minutes}m ${restSeconds}s`; -}