Tool call scrolling and updates
This commit is contained in:
@@ -7,6 +7,7 @@ interface MarkdownProps {
|
|||||||
isDark?: boolean
|
isDark?: boolean
|
||||||
size?: "base" | "sm" | "tight"
|
size?: "base" | "sm" | "tight"
|
||||||
disableHighlight?: boolean
|
disableHighlight?: boolean
|
||||||
|
onRendered?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Markdown(props: MarkdownProps) {
|
export function Markdown(props: MarkdownProps) {
|
||||||
@@ -14,6 +15,10 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
let containerRef: HTMLDivElement | undefined
|
let containerRef: HTMLDivElement | undefined
|
||||||
let latestRequestedText = ""
|
let latestRequestedText = ""
|
||||||
|
|
||||||
|
const notifyRendered = () => {
|
||||||
|
Promise.resolve().then(() => props.onRendered?.())
|
||||||
|
}
|
||||||
|
|
||||||
createEffect(async () => {
|
createEffect(async () => {
|
||||||
const part = props.part
|
const part = props.part
|
||||||
const rawText = typeof part.text === "string" ? part.text : ""
|
const rawText = typeof part.text === "string" ? part.text : ""
|
||||||
@@ -34,11 +39,13 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
|
|
||||||
if (latestRequestedText === text) {
|
if (latestRequestedText === text) {
|
||||||
setHtml(rendered)
|
setHtml(rendered)
|
||||||
|
notifyRendered()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to render markdown:", error)
|
console.error("Failed to render markdown:", error)
|
||||||
if (latestRequestedText === text) {
|
if (latestRequestedText === text) {
|
||||||
setHtml(text)
|
setHtml(text)
|
||||||
|
notifyRendered()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -47,6 +54,7 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
const cache = part.renderCache
|
const cache = part.renderCache
|
||||||
if (cache && cache.text === text && cache.theme === themeKey) {
|
if (cache && cache.text === text && cache.theme === themeKey) {
|
||||||
setHtml(cache.html)
|
setHtml(cache.html)
|
||||||
|
notifyRendered()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,12 +64,14 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
if (latestRequestedText === text) {
|
if (latestRequestedText === text) {
|
||||||
setHtml(rendered)
|
setHtml(rendered)
|
||||||
part.renderCache = { text, html: rendered, theme: themeKey }
|
part.renderCache = { text, html: rendered, theme: themeKey }
|
||||||
|
notifyRendered()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to render markdown:", error)
|
console.error("Failed to render markdown:", error)
|
||||||
if (latestRequestedText === text) {
|
if (latestRequestedText === text) {
|
||||||
setHtml(text)
|
setHtml(text)
|
||||||
part.renderCache = { text, html: text, theme: themeKey }
|
part.renderCache = { text, html: text, theme: themeKey }
|
||||||
|
notifyRendered()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -110,6 +120,7 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
setHtml(rendered)
|
setHtml(rendered)
|
||||||
const themeKey = Boolean(props.isDark) ? "dark" : "light"
|
const themeKey = Boolean(props.isDark) ? "dark" : "light"
|
||||||
part.renderCache = { text, html: rendered, theme: themeKey }
|
part.renderCache = { text, html: rendered, theme: themeKey }
|
||||||
|
notifyRendered()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to re-render markdown after language load:", error)
|
console.error("Failed to re-render markdown after language load:", error)
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ interface ToolCacheEntry {
|
|||||||
toolPart: any
|
toolPart: any
|
||||||
messageInfo?: any
|
messageInfo?: any
|
||||||
signature: string
|
signature: string
|
||||||
|
contentKey: string
|
||||||
item: ToolDisplayItem
|
item: ToolDisplayItem
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,9 +175,26 @@ export default function MessageStream(props: MessageStreamProps) {
|
|||||||
function createToolSignature(message: Message, toolPart: any, toolIndex: number, messageInfo?: any): string {
|
function createToolSignature(message: Message, toolPart: any, toolIndex: number, messageInfo?: any): string {
|
||||||
const messageId = message.id
|
const messageId = message.id
|
||||||
const partId = typeof toolPart?.id === "string" ? toolPart.id : `${messageId}-tool-${toolIndex}`
|
const partId = typeof toolPart?.id === "string" ? toolPart.id : `${messageId}-tool-${toolIndex}`
|
||||||
const status = toolPart?.state?.status ?? messageInfo?.state?.status ?? ""
|
return `${messageId}:${partId}`
|
||||||
const version = message.version ?? 0
|
}
|
||||||
return `${messageId}:${partId}:${status}:${version}`
|
|
||||||
|
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(() => {
|
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 toolKey = typeof toolPart?.id === "string" ? toolPart.id : `${message.id}-tool-${toolIndex}`
|
||||||
|
|
||||||
const toolSignature = createToolSignature(message, toolPart, toolIndex, messageInfo)
|
const toolSignature = createToolSignature(message, toolPart, toolIndex, messageInfo)
|
||||||
|
const contentKey = createToolContentKey(toolPart, messageInfo)
|
||||||
const toolEntry = toolItemCache.get(toolKey)
|
const toolEntry = toolItemCache.get(toolKey)
|
||||||
if (toolEntry && toolEntry.signature === toolSignature) {
|
if (toolEntry && toolEntry.signature === toolSignature) {
|
||||||
toolEntry.toolPart = toolPart
|
if (toolEntry.contentKey !== contentKey) {
|
||||||
toolEntry.messageInfo = messageInfo
|
const updatedItem: ToolDisplayItem = {
|
||||||
toolEntry.signature = toolSignature
|
...toolEntry.item,
|
||||||
toolEntry.item.toolPart = toolPart
|
toolPart,
|
||||||
toolEntry.item.messageInfo = messageInfo
|
messageInfo,
|
||||||
newToolCache.set(toolKey, toolEntry)
|
}
|
||||||
items.push(toolEntry.item)
|
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 {
|
} else {
|
||||||
const toolItem: ToolDisplayItem = {
|
const toolItem: ToolDisplayItem = {
|
||||||
type: "tool",
|
type: "tool",
|
||||||
@@ -369,7 +399,8 @@ export default function MessageStream(props: MessageStreamProps) {
|
|||||||
toolPart,
|
toolPart,
|
||||||
messageInfo,
|
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)
|
items.push(toolItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -568,7 +599,7 @@ export default function MessageStream(props: MessageStreamProps) {
|
|||||||
<span>Tool Call</span>
|
<span>Tool Call</span>
|
||||||
<span class="tool-name">{toolPart?.tool || "unknown"}</span>
|
<span class="tool-name">{toolPart?.tool || "unknown"}</span>
|
||||||
</div>
|
</div>
|
||||||
<ToolCall toolCall={toolPart} toolCallId={toolPart?.id} />
|
<ToolCall toolCall={toolPart} toolCallId={item.key} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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 { isToolCallExpanded, toggleToolCallExpanded, setToolCallExpanded } from "../stores/tool-call-state"
|
||||||
import { Markdown } from "./markdown"
|
import { Markdown } from "./markdown"
|
||||||
import { useTheme } from "../lib/theme"
|
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<string, TextPart>()
|
||||||
|
const toolScrollState = new Map<string, { scrollTop: number; atBottom: boolean }>()
|
||||||
|
|
||||||
|
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 {
|
interface ToolCallProps {
|
||||||
toolCall: any
|
toolCall: any
|
||||||
@@ -103,6 +159,14 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
const expanded = () => isToolCallExpanded(toolCallId())
|
const expanded = () => isToolCallExpanded(toolCallId())
|
||||||
const [initializedId, setInitializedId] = createSignal<string | null>(null)
|
const [initializedId, setInitializedId] = createSignal<string | null>(null)
|
||||||
|
|
||||||
|
let markdownContainerRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
|
const handleMarkdownRendered = () => {
|
||||||
|
const id = toolCallId()
|
||||||
|
if (!id || !markdownContainerRef) return
|
||||||
|
restoreScrollState(id, markdownContainerRef)
|
||||||
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const id = toolCallId()
|
const id = toolCallId()
|
||||||
if (!id || initializedId() === id) return
|
if (!id || initializedId() === id) return
|
||||||
@@ -114,6 +178,32 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
setInitializedId(id)
|
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 statusIcon = () => {
|
||||||
const status = props.toolCall?.state?.status || ""
|
const status = props.toolCall?.state?.status || ""
|
||||||
switch (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 messageClass = `message-text tool-call-markdown${isLarge ? " tool-call-markdown-large" : ""}`
|
||||||
const disableHighlight = state?.status === "running"
|
const disableHighlight = state?.status === "running"
|
||||||
|
|
||||||
|
const cachedPart = getCachedMarkdownPart(toolCallId(), content)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={messageClass}>
|
<div
|
||||||
|
class={messageClass}
|
||||||
|
ref={(element) => {
|
||||||
|
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)}
|
||||||
|
>
|
||||||
<Markdown
|
<Markdown
|
||||||
part={{ type: "text", text: content }}
|
part={cachedPart}
|
||||||
isDark={isDark()}
|
isDark={isDark()}
|
||||||
disableHighlight={disableHighlight}
|
disableHighlight={disableHighlight}
|
||||||
|
onRendered={handleMarkdownRendered}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -763,6 +763,15 @@ button.button-primary {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
line-height: var(--line-height-tight);
|
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 {
|
.tool-call-markdown .markdown-code-block {
|
||||||
@@ -780,33 +789,25 @@ button.button-primary {
|
|||||||
.tool-call-markdown .markdown-code-block pre {
|
.tool-call-markdown .markdown-code-block pre {
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
max-height: calc(15 * 1.4em);
|
max-height: none;
|
||||||
overflow-y: auto;
|
overflow-y: visible;
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--border-base) transparent;
|
|
||||||
scrollbar-gutter: stable both-edges;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-call-markdown .markdown-code-block pre::-webkit-scrollbar {
|
.tool-call-markdown::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-call-markdown .markdown-code-block pre::-webkit-scrollbar-track {
|
.tool-call-markdown::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-call-markdown .markdown-code-block pre::-webkit-scrollbar-thumb {
|
.tool-call-markdown::-webkit-scrollbar-thumb {
|
||||||
background-color: var(--border-base);
|
background-color: var(--border-base);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
background-clip: padding-box;
|
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 {
|
.tool-call-section h4 {
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
|
|||||||
Reference in New Issue
Block a user