diff --git a/src/components/markdown.tsx b/src/components/markdown.tsx index 742e49c6..dadb2fdf 100644 --- a/src/components/markdown.tsx +++ b/src/components/markdown.tsx @@ -7,6 +7,7 @@ interface MarkdownProps { isDark?: boolean size?: "base" | "sm" | "tight" disableHighlight?: boolean + onRendered?: () => void } export function Markdown(props: MarkdownProps) { @@ -14,6 +15,10 @@ export function Markdown(props: MarkdownProps) { let containerRef: HTMLDivElement | undefined let latestRequestedText = "" + const notifyRendered = () => { + Promise.resolve().then(() => props.onRendered?.()) + } + createEffect(async () => { const part = props.part const rawText = typeof part.text === "string" ? part.text : "" @@ -34,11 +39,13 @@ export function Markdown(props: MarkdownProps) { if (latestRequestedText === text) { setHtml(rendered) + notifyRendered() } } catch (error) { console.error("Failed to render markdown:", error) if (latestRequestedText === text) { setHtml(text) + notifyRendered() } } return @@ -47,6 +54,7 @@ export function Markdown(props: MarkdownProps) { const cache = part.renderCache if (cache && cache.text === text && cache.theme === themeKey) { setHtml(cache.html) + notifyRendered() return } @@ -56,12 +64,14 @@ export function Markdown(props: MarkdownProps) { if (latestRequestedText === text) { setHtml(rendered) part.renderCache = { text, html: rendered, theme: themeKey } + notifyRendered() } } catch (error) { console.error("Failed to render markdown:", error) if (latestRequestedText === text) { setHtml(text) part.renderCache = { text, html: text, theme: themeKey } + notifyRendered() } } }) @@ -110,6 +120,7 @@ export function Markdown(props: MarkdownProps) { setHtml(rendered) const themeKey = Boolean(props.isDark) ? "dark" : "light" part.renderCache = { text, html: rendered, theme: themeKey } + notifyRendered() } } catch (error) { console.error("Failed to re-render markdown after language load:", error) diff --git a/src/components/message-stream.tsx b/src/components/message-stream.tsx index f635a76a..3c1d8714 100644 --- a/src/components/message-stream.tsx +++ b/src/components/message-stream.tsx @@ -154,6 +154,7 @@ interface ToolCacheEntry { toolPart: any messageInfo?: any signature: string + contentKey: string item: ToolDisplayItem } @@ -174,9 +175,26 @@ export default function MessageStream(props: MessageStreamProps) { function createToolSignature(message: Message, toolPart: any, toolIndex: number, messageInfo?: any): string { const messageId = message.id const partId = typeof toolPart?.id === "string" ? toolPart.id : `${messageId}-tool-${toolIndex}` - const status = toolPart?.state?.status ?? messageInfo?.state?.status ?? "" - const version = message.version ?? 0 - return `${messageId}:${partId}:${status}:${version}` + return `${messageId}:${partId}` + } + + function createToolContentKey(toolPart: any, messageInfo?: any): string { + const state = toolPart?.state ?? {} + const metadata = state?.metadata ?? {} + const input = state?.input ?? {} + const output = state?.output ?? {} + const error = state?.error ?? null + const title = state?.title ?? null + return JSON.stringify({ + tool: toolPart?.tool ?? null, + status: state?.status ?? null, + title, + input, + output, + metadata, + error, + messageInfoState: messageInfo?.state ?? null, + }) } const sessionInfo = createMemo(() => { @@ -353,15 +371,27 @@ export default function MessageStream(props: MessageStreamProps) { const toolKey = typeof toolPart?.id === "string" ? toolPart.id : `${message.id}-tool-${toolIndex}` const toolSignature = createToolSignature(message, toolPart, toolIndex, messageInfo) + const contentKey = createToolContentKey(toolPart, messageInfo) const toolEntry = toolItemCache.get(toolKey) if (toolEntry && toolEntry.signature === toolSignature) { - toolEntry.toolPart = toolPart - toolEntry.messageInfo = messageInfo - toolEntry.signature = toolSignature - toolEntry.item.toolPart = toolPart - toolEntry.item.messageInfo = messageInfo - newToolCache.set(toolKey, toolEntry) - items.push(toolEntry.item) + if (toolEntry.contentKey !== contentKey) { + const updatedItem: ToolDisplayItem = { + ...toolEntry.item, + toolPart, + messageInfo, + } + toolEntry.toolPart = toolPart + toolEntry.messageInfo = messageInfo + toolEntry.signature = toolSignature + toolEntry.contentKey = contentKey + toolEntry.item = updatedItem + console.debug("[ToolCall] update", toolKey, toolPart?.state?.status) + newToolCache.set(toolKey, toolEntry) + items.push(updatedItem) + } else { + newToolCache.set(toolKey, toolEntry) + items.push(toolEntry.item) + } } else { const toolItem: ToolDisplayItem = { type: "tool", @@ -369,7 +399,8 @@ export default function MessageStream(props: MessageStreamProps) { toolPart, messageInfo, } - newToolCache.set(toolKey, { toolPart, messageInfo, signature: toolSignature, item: toolItem }) + console.debug("[ToolCall] create", toolKey, toolPart?.state?.status) + newToolCache.set(toolKey, { toolPart, messageInfo, signature: toolSignature, contentKey, item: toolItem }) items.push(toolItem) } } @@ -568,7 +599,7 @@ export default function MessageStream(props: MessageStreamProps) { Tool Call {toolPart?.tool || "unknown"} - + ) }} diff --git a/src/components/tool-call.tsx b/src/components/tool-call.tsx index 05b9be7f..674ed493 100644 --- a/src/components/tool-call.tsx +++ b/src/components/tool-call.tsx @@ -1,7 +1,63 @@ -import { createSignal, Show, For, createEffect } from "solid-js" +import { createSignal, Show, For, createEffect, onCleanup } from "solid-js" import { isToolCallExpanded, toggleToolCallExpanded, setToolCallExpanded } from "../stores/tool-call-state" import { Markdown } from "./markdown" import { useTheme } from "../lib/theme" +import type { TextPart } from "../types/message" + +// Module-level cache for stable TextPart objects per tool call +const markdownPartCache = new Map() +const toolScrollState = new Map() + +function updateScrollState(id: string, element: HTMLElement) { + if (!id) return + const distanceFromBottom = element.scrollHeight - (element.scrollTop + element.clientHeight) + const atBottom = distanceFromBottom <= 2 + toolScrollState.set(id, { scrollTop: element.scrollTop, atBottom }) +} + +function restoreScrollState(id: string, element: HTMLElement) { + if (!id) return + const state = toolScrollState.get(id) + if (!state) { + requestAnimationFrame(() => { + element.scrollTop = element.scrollHeight + updateScrollState(id, element) + }) + return + } + + requestAnimationFrame(() => { + if (state.atBottom) { + element.scrollTop = element.scrollHeight + } else { + const maxScrollTop = Math.max(element.scrollHeight - element.clientHeight, 0) + element.scrollTop = Math.min(state.scrollTop, maxScrollTop) + } + updateScrollState(id, element) + }) +} + +function getCachedMarkdownPart(id: string, text: string): TextPart { + if (!id) { + // No caching case - return fresh object + return { type: "text", text } + } + + const part = markdownPartCache.get(id) + if (!part) { + const freshPart: TextPart = { type: "text", text } + markdownPartCache.set(id, freshPart) + return freshPart + } + + if (part.text !== text) { + const freshPart: TextPart = { type: "text", text } + markdownPartCache.set(id, freshPart) + return freshPart + } + + return part +} interface ToolCallProps { toolCall: any @@ -103,6 +159,14 @@ export default function ToolCall(props: ToolCallProps) { const expanded = () => isToolCallExpanded(toolCallId()) const [initializedId, setInitializedId] = createSignal(null) + let markdownContainerRef: HTMLDivElement | undefined + + const handleMarkdownRendered = () => { + const id = toolCallId() + if (!id || !markdownContainerRef) return + restoreScrollState(id, markdownContainerRef) + } + createEffect(() => { const id = toolCallId() if (!id || initializedId() === id) return @@ -114,6 +178,32 @@ export default function ToolCall(props: ToolCallProps) { setInitializedId(id) }) + // Restore scroll position when content updates + createEffect(() => { + const id = toolCallId() + const element = markdownContainerRef + if (!id || !element) return + + const tool = toolName() + if (tool === "todowrite" || tool === "task") return + + const content = getMarkdownContent(tool, props.toolCall?.state || {}) + if (!content) return + + restoreScrollState(id, element) + }) + + // Cleanup cache entry when component unmounts or toolCallId changes + createEffect(() => { + const id = toolCallId() + if (!id) return + + onCleanup(() => { + markdownPartCache.delete(id) + toolScrollState.delete(id) + }) + }) + const statusIcon = () => { const status = props.toolCall?.state?.status || "" switch (status) { @@ -292,12 +382,33 @@ export default function ToolCall(props: ToolCallProps) { const messageClass = `message-text tool-call-markdown${isLarge ? " tool-call-markdown-large" : ""}` const disableHighlight = state?.status === "running" + const cachedPart = getCachedMarkdownPart(toolCallId(), content) + return ( -
+
{ + markdownContainerRef = element || undefined + const id = toolCallId() + if (!element || !id) return + + if (!toolScrollState.has(id)) { + requestAnimationFrame(() => { + if (!markdownContainerRef || toolCallId() !== id) return + markdownContainerRef.scrollTop = markdownContainerRef.scrollHeight + updateScrollState(id, markdownContainerRef) + }) + } else { + restoreScrollState(id, element) + } + }} + onScroll={(event) => updateScrollState(toolCallId(), event.currentTarget)} + >
) diff --git a/src/styles/components.css b/src/styles/components.css index a2ba9fed..b01a5c17 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -763,6 +763,15 @@ button.button-primary { padding: 0; font-size: var(--font-size-xs); line-height: var(--line-height-tight); + max-height: calc(15 * 1.4em); + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--border-base) transparent; + scrollbar-gutter: stable both-edges; +} + +.tool-call-markdown-large { + max-height: calc(50 * 1.4em); } .tool-call-markdown .markdown-code-block { @@ -780,33 +789,25 @@ button.button-primary { .tool-call-markdown .markdown-code-block pre { margin: 0 !important; min-height: auto; - max-height: calc(15 * 1.4em); - overflow-y: auto; - scrollbar-width: thin; - scrollbar-color: var(--border-base) transparent; - scrollbar-gutter: stable both-edges; + max-height: none; + overflow-y: visible; } -.tool-call-markdown .markdown-code-block pre::-webkit-scrollbar { +.tool-call-markdown::-webkit-scrollbar { width: 8px; } -.tool-call-markdown .markdown-code-block pre::-webkit-scrollbar-track { +.tool-call-markdown::-webkit-scrollbar-track { background: transparent; } -.tool-call-markdown .markdown-code-block pre::-webkit-scrollbar-thumb { +.tool-call-markdown::-webkit-scrollbar-thumb { background-color: var(--border-base); border-radius: 4px; border: 2px solid transparent; background-clip: padding-box; } -.tool-call-markdown-large .markdown-code-block pre { - min-height: auto; - max-height: calc(50 * 1.4em); -} - .tool-call-section h4 { font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold);