diff --git a/src/components/code-block-inline.tsx b/src/components/code-block-inline.tsx index 7c3f1b76..150a9b1b 100644 --- a/src/components/code-block-inline.tsx +++ b/src/components/code-block-inline.tsx @@ -5,6 +5,9 @@ import { getSharedHighlighter, escapeHtml } from "../lib/markdown" const inlineLoadedLanguages = new Set() +type LoadLanguageArg = Parameters[0] +type CodeToHtmlOptions = Parameters[1] + interface CodeBlockInlineProps { code: string language?: string @@ -41,13 +44,14 @@ export function CodeBlockInline(props: CodeBlockInlineProps) { } try { + const language = props.language as LoadLanguageArg if (!inlineLoadedLanguages.has(props.language)) { - await highlighter.loadLanguage(props.language) + await highlighter.loadLanguage(language) inlineLoadedLanguages.add(props.language) } const highlighted = highlighter.codeToHtml(props.code, { - lang: props.language, + lang: props.language as CodeToHtmlOptions["lang"], theme: isDark() ? "github-dark" : "github-light", }) setHtml(highlighted) diff --git a/src/components/diff-viewer.tsx b/src/components/diff-viewer.tsx index 350572ad..baba1be9 100644 --- a/src/components/diff-viewer.tsx +++ b/src/components/diff-viewer.tsx @@ -1,62 +1,56 @@ -import { createMemo, Show } from "solid-js" -import { DiffView, DiffModeEnum } from "@git-diff-view/solid" -import { getLanguageFromPath } from "../lib/markdown" +import { createMemo } from "solid-js" +import type { TextPart } from "../types/message" +import { Markdown } from "./markdown" import { normalizeDiffText } from "../lib/diff-utils" -import type { DiffViewMode } from "../stores/preferences" +import { getToolRenderCache, setToolRenderCache } from "../lib/tool-render-cache" + +type ThemeKey = "light" | "dark" interface ToolCallDiffViewerProps { diffText: string filePath?: string - theme: "light" | "dark" - mode: DiffViewMode + theme: ThemeKey + renderCacheKey?: string } -type DiffData = { - oldFile?: { fileName?: string | null; fileLang?: string | null; content?: string | null } - newFile?: { fileName?: string | null; fileLang?: string | null; content?: string | null } - hunks: string[] + +function formatDiffMarkdown(diffText: string, filePath?: string): string { + const body = normalizeDiffText(diffText) || diffText + const trimmed = body.trimStart() + const alreadyFenced = trimmed.startsWith("```") + const fenced = alreadyFenced ? body : `\`\`\`diff\n${body}\n\`\`\`` + + if (!filePath) { + return fenced + } + return `### ${filePath}\n\n${fenced}` } export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) { - const diffData = createMemo(() => { - const normalized = normalizeDiffText(props.diffText) - if (!normalized) { - return null - } - - const language = getLanguageFromPath(props.filePath) || "text" - const fileName = props.filePath || "diff" - - return { - oldFile: { - fileName, - fileLang: language, - }, - newFile: { - fileName, - fileLang: language, - }, - hunks: [normalized], - } + const diffMarkdown = createMemo(() => { + return formatDiffMarkdown(props.diffText, props.filePath) }) + const diffPart = createMemo(() => { + const part: TextPart = { type: "text", text: diffMarkdown() } + if (props.renderCacheKey) { + const cached = getToolRenderCache(props.renderCacheKey) + if (cached) { + part.renderCache = cached + } + } + return part + }) + + const handleRendered = () => { + if (!props.renderCacheKey) return + setToolRenderCache(props.renderCacheKey, diffPart().renderCache) + } + return (
- {props.diffText}} - > - {(data) => ( - - )} - +
) } + diff --git a/src/components/message-stream.tsx b/src/components/message-stream.tsx index 0f5a4626..38f4b306 100644 --- a/src/components/message-stream.tsx +++ b/src/components/message-stream.tsx @@ -170,6 +170,9 @@ interface ToolDisplayItem { key: string toolPart: any messageInfo?: any + messageId: string + messageVersion: number + partVersion: number } type DisplayItem = MessageDisplayItem | ToolDisplayItem @@ -191,14 +194,35 @@ interface ToolCacheEntry { item: ToolDisplayItem } +interface SessionCache { + messageItemCache: Map + toolItemCache: Map +} + +const sessionCaches = new Map() + +function getSessionCache(instanceId: string, sessionId: string): SessionCache { + const key = `${instanceId}:${sessionId}` + let cache = sessionCaches.get(key) + if (!cache) { + cache = { + messageItemCache: new Map(), + toolItemCache: new Map(), + } + sessionCaches.set(key, cache) + } + return cache +} + export default function MessageStream(props: MessageStreamProps) { let containerRef: HTMLDivElement | undefined const [autoScroll, setAutoScroll] = createSignal(true) const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false) const [showScrollTopButton, setShowScrollTopButton] = createSignal(false) - let messageItemCache = new Map() - let toolItemCache = new Map() + const sessionCache = getSessionCache(props.instanceId, props.sessionId) + let messageItemCache = sessionCache.messageItemCache + let toolItemCache = sessionCache.toolItemCache let scrollAnimationFrame: number | null = null let lastKnownScrollTop = 0 @@ -334,7 +358,6 @@ export default function MessageStream(props: MessageStreamProps) { } const messageView = createMemo(() => { - // Ensure memo reacts to preference changes const showThinking = preferences().showThinkingBlocks const items: DisplayItem[] = [] @@ -358,7 +381,6 @@ export default function MessageStream(props: MessageStreamProps) { const message = props.messages[index] const messageInfo = props.messagesInfo?.get(message.id) - // If we hit the revert point, stop rendering messages if (props.revert?.messageID && message.id === props.revert.messageID) { break } @@ -367,9 +389,9 @@ export default function MessageStream(props: MessageStreamProps) { const baseDisplayParts = message.displayParts const displayParts: MessageDisplayParts = - baseDisplayParts && baseDisplayParts.showThinking === showThinking - ? baseDisplayParts - : computeDisplayParts(message, showThinking) + !baseDisplayParts || baseDisplayParts.showThinking !== showThinking + ? computeDisplayParts(message, showThinking) + : (baseDisplayParts as MessageDisplayParts) const combinedParts = displayParts.combined const version = message.version ?? 0 @@ -424,6 +446,8 @@ export default function MessageStream(props: MessageStreamProps) { for (let toolIndex = 0; toolIndex < displayParts.tool.length; toolIndex++) { const toolPart = displayParts.tool[toolIndex] const toolKey = typeof toolPart?.id === "string" ? toolPart.id : `${message.id}-tool-${toolIndex}` + const messageVersion = typeof message.version === "number" ? message.version : 0 + const partVersion = typeof toolPart?.version === "number" ? toolPart.version : 0 const toolSignature = createToolSignature(message, toolPart, toolIndex, messageInfo) const contentKey = createToolContentKey(toolPart, messageInfo) @@ -434,6 +458,9 @@ export default function MessageStream(props: MessageStreamProps) { ...toolEntry.item, toolPart, messageInfo, + messageId: message.id, + messageVersion, + partVersion, } toolEntry.toolPart = toolPart toolEntry.messageInfo = messageInfo @@ -444,8 +471,16 @@ export default function MessageStream(props: MessageStreamProps) { newToolCache.set(toolKey, toolEntry) items.push(updatedItem) } else { + const cachedItem = toolEntry.item + cachedItem.toolPart = toolPart + cachedItem.messageInfo = messageInfo + cachedItem.messageId = message.id + cachedItem.messageVersion = messageVersion + cachedItem.partVersion = partVersion + toolEntry.toolPart = toolPart + toolEntry.messageInfo = messageInfo newToolCache.set(toolKey, toolEntry) - items.push(toolEntry.item) + items.push(cachedItem) } } else { const toolItem: ToolDisplayItem = { @@ -453,6 +488,9 @@ export default function MessageStream(props: MessageStreamProps) { key: toolKey, toolPart, messageInfo, + messageId: message.id, + messageVersion, + partVersion, } console.debug("[ToolCall] create", toolKey, toolPart?.state?.status) newToolCache.set(toolKey, { toolPart, messageInfo, signature: toolSignature, contentKey, item: toolItem }) @@ -463,6 +501,8 @@ export default function MessageStream(props: MessageStreamProps) { messageItemCache = newMessageCache toolItemCache = newToolCache + sessionCache.messageItemCache = messageItemCache + sessionCache.toolItemCache = toolItemCache tokenSegments.push(`items:${items.length}`) @@ -681,7 +721,13 @@ export default function MessageStream(props: MessageStreamProps) { - + ) }} diff --git a/src/components/prompt-input.tsx b/src/components/prompt-input.tsx index 198d42fc..af2d7351 100644 --- a/src/components/prompt-input.tsx +++ b/src/components/prompt-input.tsx @@ -47,10 +47,12 @@ export default function PromptInput(props: PromptInputProps) { const minHeight = lineHeight * MIN_TEXTAREA_LINES textarea.style.height = "auto" - const newHeight = Math.min(Math.max(textarea.scrollHeight, minHeight), MAX_TEXTAREA_HEIGHT) + const scrollHeight = textarea.scrollHeight + const newHeight = Math.min(Math.max(scrollHeight, minHeight), MAX_TEXTAREA_HEIGHT) textarea.style.height = newHeight + "px" } + const attachments = () => getAttachments(props.instanceId, props.sessionId) const instanceAgents = () => agents().get(props.instanceId) || [] @@ -309,7 +311,9 @@ export default function PromptInput(props: PromptInputProps) { function handleKeyDown(e: KeyboardEvent) { const textarea = textareaRef - if (!textarea) return + if (!textarea) { + return + } if (e.key === "Backspace" || e.key === "Delete") { const cursorPos = textarea.selectionStart @@ -478,7 +482,6 @@ export default function PromptInput(props: PromptInputProps) { setTimeout(() => { adjustTextareaHeight(textarea) }, 0) - return } } @@ -497,11 +500,9 @@ export default function PromptInput(props: PromptInputProps) { try { await addToHistory(props.instanceFolder, text) - const updated = await getHistory(props.instanceFolder) setHistory(updated) setHistoryIndex(-1) - await props.onSend(text, currentAttachments) } catch (error) { console.error("Failed to send message:", error) diff --git a/src/components/session-list.tsx b/src/components/session-list.tsx index 0c9babe6..ed2594b9 100644 --- a/src/components/session-list.tsx +++ b/src/components/session-list.tsx @@ -7,6 +7,7 @@ import { keyboardRegistry } from "../lib/keyboard-registry" import { formatShortcut } from "../lib/keyboard-utils" import { showToastNotification } from "../lib/notifications" + interface SessionListProps { instanceId: string sessions: Map @@ -51,6 +52,10 @@ const SessionList: Component = (props) => { const [startWidth, setStartWidth] = createSignal(DEFAULT_WIDTH) const infoShortcut = keyboardRegistry.get("switch-to-info") + const selectSession = (sessionId: string) => { + props.onSelect(sessionId) + } + let mouseMoveHandler: ((event: MouseEvent) => void) | null = null let mouseUpHandler: (() => void) | null = null let touchMoveHandler: ((event: TouchEvent) => void) | null = null @@ -246,7 +251,7 @@ const SessionList: Component = (props) => {
- -
+
+ {toolbarLabel}
) @@ -419,8 +414,22 @@ export default function ToolCall(props: ToolCallProps) { const isLarge = toolName === "edit" || toolName === "write" || toolName === "patch" const messageClass = `message-text tool-call-markdown${isLarge ? " tool-call-markdown-large" : ""}` const disableHighlight = state?.status === "running" + const cacheKey = makeRenderCacheKey(toolCallId(), props.messageId, props.messageVersion, props.partVersion) const markdownPart: TextPart = { type: "text", text: content } + if (cacheKey) { + const cached = getToolRenderCache(cacheKey) + if (cached) { + markdownPart.renderCache = cached + } + } + + const handleMarkdownRendered = () => { + if (cacheKey) { + setToolRenderCache(cacheKey, markdownPart.renderCache) + } + handleScrollRendered() + } return (
) diff --git a/src/lib/tool-render-cache.ts b/src/lib/tool-render-cache.ts new file mode 100644 index 00000000..a98e5270 --- /dev/null +++ b/src/lib/tool-render-cache.ts @@ -0,0 +1,22 @@ +import type { RenderCache } from "../types/message" + +const toolRenderCache = new Map() + +export function getToolRenderCache(key?: string | null): RenderCache | undefined { + if (!key) return undefined + return toolRenderCache.get(key) +} + +export function setToolRenderCache(key: string | undefined | null, cache?: RenderCache): void { + if (!key) return + if (cache) { + toolRenderCache.set(key, cache) + } else { + toolRenderCache.delete(key) + } +} + +export function clearToolRenderCache(key?: string | null): void { + if (!key) return + toolRenderCache.delete(key) +} diff --git a/src/stores/sessions.ts b/src/stores/sessions.ts index 0f351905..04139ff7 100644 --- a/src/stores/sessions.ts +++ b/src/stores/sessions.ts @@ -17,6 +17,27 @@ interface SessionInfo { contextUsageTokens: number } +interface SessionForkResponse { + id: string + title?: string + parentID?: string | null + agent?: string + model?: { + providerID?: string + modelID?: string + } + time?: { + created?: number + updated?: number + } + revert?: { + messageID?: string + partID?: string + snapshot?: string + diff?: string + } +} + const DEFAULT_MODEL_OUTPUT_LIMIT = 32_000 const ALLOWED_TOAST_VARIANTS = new Set(["info", "success", "warning", "error"]) @@ -103,13 +124,6 @@ const [loading, setLoading] = createSignal({ const [messagesLoaded, setMessagesLoaded] = createSignal>>(new Map()) const [sessionInfoByInstance, setSessionInfoByInstance] = createSignal>>(new Map()) -if (typeof globalThis !== "undefined") { - const debugGlobal = globalThis as any - debugGlobal.__OPENCODE_DEBUG__ = { - ...(debugGlobal.__OPENCODE_DEBUG__ ?? {}), - getSessions: () => sessions(), - } -} // Message index cache structure: instanceId -> sessionId -> { messageIndex, partIndex } const sessionIndexes = new Map< @@ -718,8 +732,8 @@ async function forkSession( throw new Error("Failed to fork session: No data returned") } - const info = response.data - const forkedSession: Session = { + const info = response.data as SessionForkResponse + const forkedSession = { id: info.id, instanceId, title: info.title || "Forked Session", @@ -743,7 +757,7 @@ async function forkSession( : undefined, messages: [], messagesInfo: new Map(), - } + } as unknown as Session setSessions((prev) => { const next = new Map(prev)