From 4e0e5dcdca2ec8166ba5f5565460e5ec0af0ea2c Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 26 Nov 2025 15:28:48 +0000 Subject: [PATCH] Restore tool navigation and balanced scroll controls --- .../ui/src/components/message-stream-v2.tsx | 480 ++++++++---------- packages/ui/src/components/tool-call.tsx | 21 +- .../src/styles/messaging/message-stream.css | 28 - 3 files changed, 210 insertions(+), 319 deletions(-) diff --git a/packages/ui/src/components/message-stream-v2.tsx b/packages/ui/src/components/message-stream-v2.tsx index 0079f59e..d9bd3277 100644 --- a/packages/ui/src/components/message-stream-v2.tsx +++ b/packages/ui/src/components/message-stream-v2.tsx @@ -3,7 +3,7 @@ import MessageItem from "./message-item" import ToolCall from "./tool-call" import Kbd from "./kbd" import type { MessageInfo, ClientPart } from "../types/message" -import { getSessionInfo } from "../stores/sessions" +import { getSessionInfo, sessions, setActiveParentSession, setActiveSession } from "../stores/sessions" import { showCommandPalette } from "../stores/command-palette" import { messageStoreBus } from "../stores/message-v2/bus" import type { MessageRecord } from "../stores/message-v2/types" @@ -12,24 +12,81 @@ import { useConfig } from "../stores/preferences" import { sseManager } from "../lib/sse-manager" import { formatTokenTotal } from "../lib/formatters" import { useScrollCache } from "../lib/hooks/use-scroll-cache" +import { setActiveInstanceId } from "../stores/instances" const SCROLL_SCOPE = "session" + const TOOL_ICON = "🔧" const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href -const INITIAL_BATCH_COUNT = 150 -const PREPEND_CHUNK_COUNT = 50 -const LOAD_MORE_THRESHOLD_PX = 320 -const ESTIMATED_MESSAGE_HEIGHT = 120 - const messageItemCache = new Map() const toolItemCache = new Map() +type ToolState = import("@opencode-ai/sdk").ToolState +type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning +type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted +type ToolStateError = import("@opencode-ai/sdk").ToolStateError + +function isToolStateRunning(state: ToolState | undefined): state is ToolStateRunning { + return Boolean(state && state.status === "running") +} + +function isToolStateCompleted(state: ToolState | undefined): state is ToolStateCompleted { + return Boolean(state && state.status === "completed") +} + +function isToolStateError(state: ToolState | undefined): state is ToolStateError { + return Boolean(state && state.status === "error") +} + +function extractTaskSessionId(state: ToolState | undefined): string { + if (!state) return "" + const metadata = (state as unknown as { metadata?: Record }).metadata ?? {} + const directId = metadata?.sessionId ?? metadata?.sessionID + return typeof directId === "string" ? directId : "" +} + +interface TaskSessionLocation { + sessionId: string + instanceId: string + parentId: string | null +} + +function findTaskSessionLocation(sessionId: string): TaskSessionLocation | null { + if (!sessionId) return null + const allSessions = sessions() + for (const [instanceId, sessionMap] of allSessions) { + const session = sessionMap?.get(sessionId) + if (session) { + return { + sessionId: session.id, + instanceId, + parentId: session.parentId ?? null, + } + } + } + return null +} + +function navigateToTaskSession(location: TaskSessionLocation) { + setActiveInstanceId(location.instanceId) + const parentToActivate = location.parentId ?? location.sessionId + setActiveParentSession(location.instanceId, parentToActivate) + if (location.parentId) { + setActiveSession(location.instanceId, location.sessionId) + } +} + +function formatTokens(tokens: number): string { + return formatTokenTotal(tokens) +} + function makeInstanceCacheKey(instanceId: string, id: string) { return `${instanceId}:${id}` } function clearInstanceCaches(instanceId: string) { + clearRecordDisplayCacheForInstance(instanceId) const prefix = `${instanceId}:` for (const key of messageItemCache.keys()) { @@ -46,11 +103,6 @@ function clearInstanceCaches(instanceId: string) { messageStoreBus.onInstanceDestroyed(clearInstanceCaches) -function formatTokens(tokens: number): string { - return formatTokenTotal(tokens) -} - - interface MessageStreamV2Props { instanceId: string sessionId: string @@ -84,11 +136,6 @@ interface MessageDisplayBlock { toolItems: ToolDisplayItem[] } -interface MeasurementEntry { - revision: number - height: number -} - function hasRenderableContent(record: MessageRecord, combinedParts: ClientPart[], info?: MessageInfo): boolean { if (record.role !== "assistant" && record.role !== "user") { return false @@ -112,62 +159,6 @@ export default function MessageStreamV2(props: MessageStreamV2Props) { .filter((record): record is MessageRecord => Boolean(record)), ) - const [visibleRange, setVisibleRange] = createSignal({ start: 0, end: 0 }) - const [rangeInitialized, setRangeInitialized] = createSignal(false) - const [forceFullHistory, setForceFullHistory] = createSignal(false) - const messageMeasurements = new Map() - const [measurementVersion, setMeasurementVersion] = createSignal(0) - const [virtualPadding, setVirtualPadding] = createSignal(0) - const [reachedAbsoluteTop, setReachedAbsoluteTop] = createSignal(false) - const showLoadOlderButton = createMemo(() => visibleRange().start > 0 && reachedAbsoluteTop()) - - function updateMeasurementCache(messageId: string, revision: number, height: number) { - const safeHeight = Math.max(0, height) - const existing = messageMeasurements.get(messageId) - if (existing && existing.revision === revision && Math.abs(existing.height - safeHeight) < 1) { - return - } - messageMeasurements.set(messageId, { revision, height: safeHeight }) - setMeasurementVersion((value) => value + 1) - } - - function getAverageMeasuredHeight() { - if (messageMeasurements.size === 0) { - return ESTIMATED_MESSAGE_HEIGHT - } - let total = 0 - for (const entry of messageMeasurements.values()) { - total += entry.height - } - return total / messageMeasurements.size - } - - const messageIndexMap = createMemo(() => { - const map = new Map() - const records = messageRecords() - records.forEach((record, index) => map.set(record.id, index)) - return map - }) - - const lastAssistantIndex = createMemo(() => { - const records = messageRecords() - for (let index = records.length - 1; index >= 0; index--) { - if (records[index].role === "assistant") { - return index - } - } - return -1 - }) - - const visibleRecords = createMemo(() => { - const records = messageRecords() - const range = visibleRange() - if (range.end === 0) { - return records - } - return records.slice(range.start, range.end) - }) - const sessionRevision = createMemo(() => store().getSessionRevision(props.sessionId)) const usageSnapshot = createMemo(() => store().getSessionUsage(props.sessionId)) const sessionInfo = createMemo(() => @@ -199,8 +190,7 @@ export default function MessageStreamV2(props: MessageStreamV2Props) { const messageInfoMap = createMemo(() => { const map = new Map() - const records = visibleRecords() - records.forEach((record) => { + messageRecords().forEach((record) => { const info = store().getMessageInfo(record.id) if (info) { map.set(record.id, info) @@ -210,26 +200,21 @@ export default function MessageStreamV2(props: MessageStreamV2Props) { }) const revertTarget = createMemo(() => store().getSessionRevert(props.sessionId)) - const scrollCache = useScrollCache({ - instanceId: () => props.instanceId, - sessionId: () => props.sessionId, - scope: SCROLL_SCOPE, + const messageIndexMap = createMemo(() => { + const map = new Map() + const records = messageRecords() + records.forEach((record, index) => map.set(record.id, index)) + return map }) - let previousToken: string | undefined - - createEffect(() => { - const sessionId = props.sessionId - store() - messageMeasurements.clear() - setMeasurementVersion((value) => value + 1) - setVirtualPadding(0) - setVisibleRange({ start: 0, end: 0 }) - setRangeInitialized(false) - setReachedAbsoluteTop(false) - const snapshot = store().getScrollSnapshot(sessionId, SCROLL_SCOPE) - setForceFullHistory(Boolean(snapshot && !snapshot.atBottom)) - previousToken = undefined + const lastAssistantIndex = createMemo(() => { + const records = messageRecords() + for (let index = records.length - 1; index >= 0; index--) { + if (records[index].role === "assistant") { + return index + } + } + return -1 }) const displayBlocks = createMemo(() => { @@ -240,12 +225,11 @@ export default function MessageStreamV2(props: MessageStreamV2Props) { const blocks: MessageDisplayBlock[] = [] const usedMessageKeys = new Set() const usedToolKeys = new Set() - const records = visibleRecords() - const globalAssistantIndex = lastAssistantIndex() + const records = messageRecords() + const assistantIndex = lastAssistantIndex() const indexMap = messageIndexMap() - for (let index = 0; index < records.length; index++) { - const record = records[index] + for (const record of records) { if (revert?.messageID && record.id === revert.messageID) { break } @@ -254,7 +238,7 @@ export default function MessageStreamV2(props: MessageStreamV2Props) { const messageInfo = infoMap.get(record.id) const recordCacheKey = makeInstanceCacheKey(instanceId, record.id) const recordIndex = indexMap.get(record.id) ?? 0 - const isQueued = record.role === "user" && (globalAssistantIndex === -1 || recordIndex > globalAssistantIndex) + const isQueued = record.role === "user" && (assistantIndex === -1 || recordIndex > assistantIndex) let messageItem: MessageDisplayItem | null = null if (hasRenderableContent(record, textAndReasoningParts, messageInfo)) { @@ -285,8 +269,8 @@ export default function MessageStreamV2(props: MessageStreamV2Props) { const partVersion = typeof toolPart.version === "number" ? toolPart.version : 0 const messageVersion = record.revision const key = `${record.id}:${toolPart.id ?? toolIndex}` - const toolCacheKey = makeInstanceCacheKey(instanceId, key) - let toolItem = toolItemCache.get(toolCacheKey) + const cacheKey = makeInstanceCacheKey(instanceId, key) + let toolItem = toolItemCache.get(cacheKey) if (!toolItem) { toolItem = { type: "tool", @@ -297,7 +281,7 @@ export default function MessageStreamV2(props: MessageStreamV2Props) { messageVersion, partVersion, } - toolItemCache.set(toolCacheKey, toolItem) + toolItemCache.set(cacheKey, toolItem) } else { toolItem.key = key toolItem.toolPart = toolPart @@ -307,7 +291,7 @@ export default function MessageStreamV2(props: MessageStreamV2Props) { toolItem.partVersion = partVersion } toolItems.push(toolItem) - usedToolKeys.add(toolCacheKey) + usedToolKeys.add(cacheKey) }) if (!messageItem && toolItems.length === 0) { @@ -322,7 +306,6 @@ export default function MessageStreamV2(props: MessageStreamV2Props) { messageItemCache.delete(key) } } - for (const key of toolItemCache.keys()) { if (!usedToolKeys.has(key)) { toolItemCache.delete(key) @@ -332,126 +315,68 @@ export default function MessageStreamV2(props: MessageStreamV2Props) { return blocks }) - createEffect(() => { - const records = messageRecords() - const total = records.length - const requireFullHistory = forceFullHistory() - if (total === 0) { - setVisibleRange({ start: 0, end: 0 }) - setRangeInitialized(false) - return - } - - setVisibleRange((current) => { - if (!rangeInitialized() || requireFullHistory) { - const start = requireFullHistory ? 0 : Math.max(0, total - INITIAL_BATCH_COUNT) - if (!rangeInitialized()) { - setRangeInitialized(true) - } - if (requireFullHistory) { - setForceFullHistory(false) - } - return { start, end: total } - } - const nextEnd = total - let nextStart = current.start - if (nextStart > nextEnd) { - nextStart = Math.max(0, nextEnd - INITIAL_BATCH_COUNT) - } - return { start: nextStart, end: nextEnd } - }) - }) - - createEffect(() => { - measurementVersion() - const range = visibleRange() - if (range.start <= 0) { - setVirtualPadding(0) - return - } - const records = messageRecords() - const trimmed = records.slice(0, range.start) - if (trimmed.length === 0) { - setVirtualPadding(0) - return - } - const fallback = getAverageMeasuredHeight() - let total = 0 - for (const record of trimmed) { - const entry = messageMeasurements.get(record.id) - total += entry?.height ?? fallback - } - setVirtualPadding(total) - }) - const changeToken = createMemo(() => { const revisionValue = sessionRevision() - const range = visibleRange() const blocks = displayBlocks() if (blocks.length === 0) { - return `${revisionValue}:${range.start}:${range.end}:empty` + return `${revisionValue}:empty` } const lastBlock = blocks[blocks.length - 1] const lastTool = lastBlock.toolItems[lastBlock.toolItems.length - 1] const tailSignature = lastTool ? `tool:${lastTool.key}:${lastTool.partVersion}` : `msg:${lastBlock.record.id}:${lastBlock.record.revision}` - return `${revisionValue}:${range.start}:${range.end}:${tailSignature}` + return `${revisionValue}:${tailSignature}` + }) + + const scrollCache = useScrollCache({ + instanceId: () => props.instanceId, + sessionId: () => props.sessionId, + scope: SCROLL_SCOPE, }) const [autoScroll, setAutoScroll] = createSignal(true) - const [showScrollButton, setShowScrollButton] = createSignal(false) + const [showScrollTopButton, setShowScrollTopButton] = createSignal(false) + const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false) let containerRef: HTMLDivElement | undefined - function captureScrollSnapshot() { - if (!containerRef) return { height: 0, top: 0 } - return { height: containerRef.scrollHeight, top: containerRef.scrollTop } - } - - function restoreScrollSnapshot(snapshot?: { height: number; top: number }) { - if (!containerRef || !snapshot) return - requestAnimationFrame(() => { - if (!containerRef) return - const delta = containerRef.scrollHeight - snapshot.height - containerRef.scrollTop = snapshot.top + delta - }) - } - - function prependChunk(amount = PREPEND_CHUNK_COUNT) { - if (visibleRange().start === 0) { - return - } - const snapshot = captureScrollSnapshot() - setVisibleRange((range) => { - if (range.start === 0) { - return range - } - const nextStart = Math.max(0, range.start - amount) - return { start: nextStart, end: range.end } - }) - restoreScrollSnapshot(snapshot) - } - - function loadAllOlderMessages() { - if (visibleRange().start === 0) { - return - } - const snapshot = captureScrollSnapshot() - setVisibleRange((range) => ({ start: 0, end: range.end })) - restoreScrollSnapshot(snapshot) - } - function isNearBottom(element: HTMLDivElement, offset = 48) { const { scrollTop, scrollHeight, clientHeight } = element return scrollHeight - (scrollTop + clientHeight) <= offset } + function isNearTop(element: HTMLDivElement, offset = 48) { + return element.scrollTop <= offset + } + + function updateScrollIndicators(element: HTMLDivElement) { + const hasItems = displayBlocks().length > 0 + setShowScrollBottomButton(hasItems && !isNearBottom(element)) + setShowScrollTopButton(hasItems && !isNearTop(element)) + } + function scrollToBottom(immediate = false) { if (!containerRef) return const behavior = immediate ? "auto" : "smooth" containerRef.scrollTo({ top: containerRef.scrollHeight, behavior }) setAutoScroll(true) - persistScrollState() + requestAnimationFrame(() => { + if (!containerRef) return + updateScrollIndicators(containerRef) + persistScrollState() + }) + } + + function scrollToTop(immediate = false) { + if (!containerRef) return + const behavior = immediate ? "auto" : "smooth" + setAutoScroll(false) + containerRef.scrollTo({ top: 0, behavior }) + requestAnimationFrame(() => { + if (!containerRef) return + updateScrollIndicators(containerRef) + persistScrollState() + }) } function persistScrollState() { @@ -461,22 +386,19 @@ export default function MessageStreamV2(props: MessageStreamV2Props) { function handleScroll(event: Event) { if (!containerRef) return - const atBottom = isNearBottom(containerRef) - setShowScrollButton(!atBottom) - const atAbsoluteTop = containerRef.scrollTop <= 4 - setReachedAbsoluteTop(atAbsoluteTop) + updateScrollIndicators(containerRef) if (event.isTrusted) { - setAutoScroll(atBottom) - if (containerRef.scrollTop <= LOAD_MORE_THRESHOLD_PX && visibleRange().start > 0) { - prependChunk() + const atBottom = isNearBottom(containerRef) + if (!atBottom) { + setAutoScroll(false) + } else { + setAutoScroll(true) } } persistScrollState() } createEffect(() => { - const sessionId = props.sessionId - store() const target = containerRef if (!target) return scrollCache.restore(target, { @@ -484,17 +406,16 @@ export default function MessageStreamV2(props: MessageStreamV2Props) { onApplied: (snapshot) => { if (snapshot) { setAutoScroll(snapshot.atBottom) - setShowScrollButton(!snapshot.atBottom) } else { const atBottom = isNearBottom(target) setAutoScroll(atBottom) - setShowScrollButton(!atBottom) } + updateScrollIndicators(target) }, }) - void sessionId }) + let previousToken: string | undefined createEffect(() => { const token = changeToken() if (!token || token === previousToken) { @@ -508,7 +429,8 @@ export default function MessageStreamV2(props: MessageStreamV2Props) { createEffect(() => { if (messageRecords().length === 0) { - setShowScrollButton(false) + setShowScrollTopButton(false) + setShowScrollBottomButton(false) setAutoScroll(true) } }) @@ -572,7 +494,7 @@ export default function MessageStreamV2(props: MessageStreamV2Props) { }} onScroll={handleScroll} > - +
@@ -602,64 +524,40 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
- 0}> - - +
- + + + + + +
diff --git a/packages/ui/src/components/tool-call.tsx b/packages/ui/src/components/tool-call.tsx index be968ba1..5b0f93f5 100644 --- a/packages/ui/src/components/tool-call.tsx +++ b/packages/ui/src/components/tool-call.tsx @@ -40,14 +40,12 @@ const TOOL_CALL_CACHE_SCOPE = "tool-call" function makeRenderCacheKey( toolCallId?: string | null, messageId?: string, - messageVersion?: number, - partVersion?: number, + partId?: string | null, variant = "default", ) { const messageComponent = messageId ?? "unknown-message" - const toolCallComponent = toolCallId ?? "unknown-tool-call" - const versionComponent = `${messageVersion ?? 0}:${partVersion ?? 0}` - return `${messageComponent}:${toolCallComponent}:${versionComponent}:${variant}` + const toolCallComponent = partId ?? toolCallId ?? "unknown-tool-call" + return `${messageComponent}:${toolCallComponent}:${variant}` } @@ -326,8 +324,7 @@ export default function ToolCall(props: ToolCallProps) { const cacheContext = createMemo(() => ({ toolCallId: toolCallId(), messageId: props.messageId, - messageVersion: props.messageVersion ?? 0, - partVersion: props.partVersion ?? 0, + partId: props.toolCall?.id ?? null, })) const createVariantCache = (variant: string) => @@ -337,20 +334,14 @@ export default function ToolCall(props: ToolCallProps) { scope: TOOL_CALL_CACHE_SCOPE, key: () => { const context = cacheContext() - return makeRenderCacheKey( - context.toolCallId || undefined, - context.messageId, - context.messageVersion, - context.partVersion, - variant, - ) + return makeRenderCacheKey(context.toolCallId || undefined, context.messageId, context.partId, variant) }, }) const diffCache = createVariantCache("diff") const permissionDiffCache = createVariantCache("permission-diff") const markdownCache = createVariantCache("markdown") - const permissionState = createMemo(() => store().getPermissionState(props.messageId, toolCallId() || props.toolCall?.id)) + const permissionState = createMemo(() => store().getPermissionState(props.messageId, props.toolCall?.id)) const pendingPermission = createMemo(() => { const state = permissionState() if (state) { diff --git a/packages/ui/src/styles/messaging/message-stream.css b/packages/ui/src/styles/messaging/message-stream.css index 62c3b548..f7b40f71 100644 --- a/packages/ui/src/styles/messaging/message-stream.css +++ b/packages/ui/src/styles/messaging/message-stream.css @@ -70,40 +70,12 @@ color: inherit; } -.message-stream-virtual-padding { - width: 100%; - flex-shrink: 0; -} - .message-stream-block { display: flex; flex-direction: column; gap: 0.25rem; } -.message-stream-load-older { - display: flex; - justify-content: center; - padding: 0.5rem 0; -} - -.message-stream-load-older-button { - @apply inline-flex items-center justify-center rounded-md border text-sm font-medium px-3 py-1.5 transition-colors; - border-color: var(--border-base); - background-color: var(--surface-base); - color: var(--text-secondary); -} - -.message-stream-load-older-button:hover { - background-color: var(--surface-hover); - color: var(--text-primary); -} - -.message-stream-load-older-button:focus-visible { - outline: none; - box-shadow: 0 0 0 2px var(--surface-base), 0 0 0 4px var(--accent-primary); -} - .message-scroll-button-wrapper { position: absolute; right: 1rem;