Restore tool navigation and balanced scroll controls
This commit is contained in:
@@ -3,7 +3,7 @@ import MessageItem from "./message-item"
|
|||||||
import ToolCall from "./tool-call"
|
import ToolCall from "./tool-call"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
import type { MessageInfo, ClientPart } from "../types/message"
|
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 { showCommandPalette } from "../stores/command-palette"
|
||||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||||
import type { MessageRecord } from "../stores/message-v2/types"
|
import type { MessageRecord } from "../stores/message-v2/types"
|
||||||
@@ -12,24 +12,81 @@ import { useConfig } from "../stores/preferences"
|
|||||||
import { sseManager } from "../lib/sse-manager"
|
import { sseManager } from "../lib/sse-manager"
|
||||||
import { formatTokenTotal } from "../lib/formatters"
|
import { formatTokenTotal } from "../lib/formatters"
|
||||||
import { useScrollCache } from "../lib/hooks/use-scroll-cache"
|
import { useScrollCache } from "../lib/hooks/use-scroll-cache"
|
||||||
|
import { setActiveInstanceId } from "../stores/instances"
|
||||||
|
|
||||||
const SCROLL_SCOPE = "session"
|
const SCROLL_SCOPE = "session"
|
||||||
|
|
||||||
const TOOL_ICON = "🔧"
|
const TOOL_ICON = "🔧"
|
||||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
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<string, MessageDisplayItem>()
|
const messageItemCache = new Map<string, MessageDisplayItem>()
|
||||||
const toolItemCache = new Map<string, ToolDisplayItem>()
|
const toolItemCache = new Map<string, ToolDisplayItem>()
|
||||||
|
|
||||||
|
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<string, unknown> }).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) {
|
function makeInstanceCacheKey(instanceId: string, id: string) {
|
||||||
return `${instanceId}:${id}`
|
return `${instanceId}:${id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearInstanceCaches(instanceId: string) {
|
function clearInstanceCaches(instanceId: string) {
|
||||||
|
|
||||||
clearRecordDisplayCacheForInstance(instanceId)
|
clearRecordDisplayCacheForInstance(instanceId)
|
||||||
const prefix = `${instanceId}:`
|
const prefix = `${instanceId}:`
|
||||||
for (const key of messageItemCache.keys()) {
|
for (const key of messageItemCache.keys()) {
|
||||||
@@ -46,11 +103,6 @@ function clearInstanceCaches(instanceId: string) {
|
|||||||
|
|
||||||
messageStoreBus.onInstanceDestroyed(clearInstanceCaches)
|
messageStoreBus.onInstanceDestroyed(clearInstanceCaches)
|
||||||
|
|
||||||
function formatTokens(tokens: number): string {
|
|
||||||
return formatTokenTotal(tokens)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
interface MessageStreamV2Props {
|
interface MessageStreamV2Props {
|
||||||
instanceId: string
|
instanceId: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
@@ -84,11 +136,6 @@ interface MessageDisplayBlock {
|
|||||||
toolItems: ToolDisplayItem[]
|
toolItems: ToolDisplayItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MeasurementEntry {
|
|
||||||
revision: number
|
|
||||||
height: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasRenderableContent(record: MessageRecord, combinedParts: ClientPart[], info?: MessageInfo): boolean {
|
function hasRenderableContent(record: MessageRecord, combinedParts: ClientPart[], info?: MessageInfo): boolean {
|
||||||
if (record.role !== "assistant" && record.role !== "user") {
|
if (record.role !== "assistant" && record.role !== "user") {
|
||||||
return false
|
return false
|
||||||
@@ -112,62 +159,6 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
|
|||||||
.filter((record): record is MessageRecord => Boolean(record)),
|
.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<string, MeasurementEntry>()
|
|
||||||
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<string, number>()
|
|
||||||
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 sessionRevision = createMemo(() => store().getSessionRevision(props.sessionId))
|
||||||
const usageSnapshot = createMemo(() => store().getSessionUsage(props.sessionId))
|
const usageSnapshot = createMemo(() => store().getSessionUsage(props.sessionId))
|
||||||
const sessionInfo = createMemo(() =>
|
const sessionInfo = createMemo(() =>
|
||||||
@@ -199,8 +190,7 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
|
|||||||
|
|
||||||
const messageInfoMap = createMemo(() => {
|
const messageInfoMap = createMemo(() => {
|
||||||
const map = new Map<string, MessageInfo>()
|
const map = new Map<string, MessageInfo>()
|
||||||
const records = visibleRecords()
|
messageRecords().forEach((record) => {
|
||||||
records.forEach((record) => {
|
|
||||||
const info = store().getMessageInfo(record.id)
|
const info = store().getMessageInfo(record.id)
|
||||||
if (info) {
|
if (info) {
|
||||||
map.set(record.id, info)
|
map.set(record.id, info)
|
||||||
@@ -210,26 +200,21 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
|
|||||||
})
|
})
|
||||||
const revertTarget = createMemo(() => store().getSessionRevert(props.sessionId))
|
const revertTarget = createMemo(() => store().getSessionRevert(props.sessionId))
|
||||||
|
|
||||||
const scrollCache = useScrollCache({
|
const messageIndexMap = createMemo(() => {
|
||||||
instanceId: () => props.instanceId,
|
const map = new Map<string, number>()
|
||||||
sessionId: () => props.sessionId,
|
const records = messageRecords()
|
||||||
scope: SCROLL_SCOPE,
|
records.forEach((record, index) => map.set(record.id, index))
|
||||||
|
return map
|
||||||
})
|
})
|
||||||
|
|
||||||
let previousToken: string | undefined
|
const lastAssistantIndex = createMemo(() => {
|
||||||
|
const records = messageRecords()
|
||||||
createEffect(() => {
|
for (let index = records.length - 1; index >= 0; index--) {
|
||||||
const sessionId = props.sessionId
|
if (records[index].role === "assistant") {
|
||||||
store()
|
return index
|
||||||
messageMeasurements.clear()
|
}
|
||||||
setMeasurementVersion((value) => value + 1)
|
}
|
||||||
setVirtualPadding(0)
|
return -1
|
||||||
setVisibleRange({ start: 0, end: 0 })
|
|
||||||
setRangeInitialized(false)
|
|
||||||
setReachedAbsoluteTop(false)
|
|
||||||
const snapshot = store().getScrollSnapshot(sessionId, SCROLL_SCOPE)
|
|
||||||
setForceFullHistory(Boolean(snapshot && !snapshot.atBottom))
|
|
||||||
previousToken = undefined
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const displayBlocks = createMemo<MessageDisplayBlock[]>(() => {
|
const displayBlocks = createMemo<MessageDisplayBlock[]>(() => {
|
||||||
@@ -240,12 +225,11 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
|
|||||||
const blocks: MessageDisplayBlock[] = []
|
const blocks: MessageDisplayBlock[] = []
|
||||||
const usedMessageKeys = new Set<string>()
|
const usedMessageKeys = new Set<string>()
|
||||||
const usedToolKeys = new Set<string>()
|
const usedToolKeys = new Set<string>()
|
||||||
const records = visibleRecords()
|
const records = messageRecords()
|
||||||
const globalAssistantIndex = lastAssistantIndex()
|
const assistantIndex = lastAssistantIndex()
|
||||||
const indexMap = messageIndexMap()
|
const indexMap = messageIndexMap()
|
||||||
|
|
||||||
for (let index = 0; index < records.length; index++) {
|
for (const record of records) {
|
||||||
const record = records[index]
|
|
||||||
if (revert?.messageID && record.id === revert.messageID) {
|
if (revert?.messageID && record.id === revert.messageID) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -254,7 +238,7 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
|
|||||||
const messageInfo = infoMap.get(record.id)
|
const messageInfo = infoMap.get(record.id)
|
||||||
const recordCacheKey = makeInstanceCacheKey(instanceId, record.id)
|
const recordCacheKey = makeInstanceCacheKey(instanceId, record.id)
|
||||||
const recordIndex = indexMap.get(record.id) ?? 0
|
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
|
let messageItem: MessageDisplayItem | null = null
|
||||||
if (hasRenderableContent(record, textAndReasoningParts, messageInfo)) {
|
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 partVersion = typeof toolPart.version === "number" ? toolPart.version : 0
|
||||||
const messageVersion = record.revision
|
const messageVersion = record.revision
|
||||||
const key = `${record.id}:${toolPart.id ?? toolIndex}`
|
const key = `${record.id}:${toolPart.id ?? toolIndex}`
|
||||||
const toolCacheKey = makeInstanceCacheKey(instanceId, key)
|
const cacheKey = makeInstanceCacheKey(instanceId, key)
|
||||||
let toolItem = toolItemCache.get(toolCacheKey)
|
let toolItem = toolItemCache.get(cacheKey)
|
||||||
if (!toolItem) {
|
if (!toolItem) {
|
||||||
toolItem = {
|
toolItem = {
|
||||||
type: "tool",
|
type: "tool",
|
||||||
@@ -297,7 +281,7 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
|
|||||||
messageVersion,
|
messageVersion,
|
||||||
partVersion,
|
partVersion,
|
||||||
}
|
}
|
||||||
toolItemCache.set(toolCacheKey, toolItem)
|
toolItemCache.set(cacheKey, toolItem)
|
||||||
} else {
|
} else {
|
||||||
toolItem.key = key
|
toolItem.key = key
|
||||||
toolItem.toolPart = toolPart
|
toolItem.toolPart = toolPart
|
||||||
@@ -307,7 +291,7 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
|
|||||||
toolItem.partVersion = partVersion
|
toolItem.partVersion = partVersion
|
||||||
}
|
}
|
||||||
toolItems.push(toolItem)
|
toolItems.push(toolItem)
|
||||||
usedToolKeys.add(toolCacheKey)
|
usedToolKeys.add(cacheKey)
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!messageItem && toolItems.length === 0) {
|
if (!messageItem && toolItems.length === 0) {
|
||||||
@@ -322,7 +306,6 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
|
|||||||
messageItemCache.delete(key)
|
messageItemCache.delete(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const key of toolItemCache.keys()) {
|
for (const key of toolItemCache.keys()) {
|
||||||
if (!usedToolKeys.has(key)) {
|
if (!usedToolKeys.has(key)) {
|
||||||
toolItemCache.delete(key)
|
toolItemCache.delete(key)
|
||||||
@@ -332,126 +315,68 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
|
|||||||
return blocks
|
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 changeToken = createMemo(() => {
|
||||||
const revisionValue = sessionRevision()
|
const revisionValue = sessionRevision()
|
||||||
const range = visibleRange()
|
|
||||||
const blocks = displayBlocks()
|
const blocks = displayBlocks()
|
||||||
if (blocks.length === 0) {
|
if (blocks.length === 0) {
|
||||||
return `${revisionValue}:${range.start}:${range.end}:empty`
|
return `${revisionValue}:empty`
|
||||||
}
|
}
|
||||||
const lastBlock = blocks[blocks.length - 1]
|
const lastBlock = blocks[blocks.length - 1]
|
||||||
const lastTool = lastBlock.toolItems[lastBlock.toolItems.length - 1]
|
const lastTool = lastBlock.toolItems[lastBlock.toolItems.length - 1]
|
||||||
const tailSignature = lastTool
|
const tailSignature = lastTool
|
||||||
? `tool:${lastTool.key}:${lastTool.partVersion}`
|
? `tool:${lastTool.key}:${lastTool.partVersion}`
|
||||||
: `msg:${lastBlock.record.id}:${lastBlock.record.revision}`
|
: `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 [autoScroll, setAutoScroll] = createSignal(true)
|
||||||
const [showScrollButton, setShowScrollButton] = createSignal(false)
|
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
|
||||||
|
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
|
||||||
let containerRef: HTMLDivElement | undefined
|
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) {
|
function isNearBottom(element: HTMLDivElement, offset = 48) {
|
||||||
const { scrollTop, scrollHeight, clientHeight } = element
|
const { scrollTop, scrollHeight, clientHeight } = element
|
||||||
return scrollHeight - (scrollTop + clientHeight) <= offset
|
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) {
|
function scrollToBottom(immediate = false) {
|
||||||
if (!containerRef) return
|
if (!containerRef) return
|
||||||
const behavior = immediate ? "auto" : "smooth"
|
const behavior = immediate ? "auto" : "smooth"
|
||||||
containerRef.scrollTo({ top: containerRef.scrollHeight, behavior })
|
containerRef.scrollTo({ top: containerRef.scrollHeight, behavior })
|
||||||
setAutoScroll(true)
|
setAutoScroll(true)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!containerRef) return
|
||||||
|
updateScrollIndicators(containerRef)
|
||||||
persistScrollState()
|
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() {
|
function persistScrollState() {
|
||||||
@@ -461,22 +386,19 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
|
|||||||
|
|
||||||
function handleScroll(event: Event) {
|
function handleScroll(event: Event) {
|
||||||
if (!containerRef) return
|
if (!containerRef) return
|
||||||
const atBottom = isNearBottom(containerRef)
|
updateScrollIndicators(containerRef)
|
||||||
setShowScrollButton(!atBottom)
|
|
||||||
const atAbsoluteTop = containerRef.scrollTop <= 4
|
|
||||||
setReachedAbsoluteTop(atAbsoluteTop)
|
|
||||||
if (event.isTrusted) {
|
if (event.isTrusted) {
|
||||||
setAutoScroll(atBottom)
|
const atBottom = isNearBottom(containerRef)
|
||||||
if (containerRef.scrollTop <= LOAD_MORE_THRESHOLD_PX && visibleRange().start > 0) {
|
if (!atBottom) {
|
||||||
prependChunk()
|
setAutoScroll(false)
|
||||||
|
} else {
|
||||||
|
setAutoScroll(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
persistScrollState()
|
persistScrollState()
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const sessionId = props.sessionId
|
|
||||||
store()
|
|
||||||
const target = containerRef
|
const target = containerRef
|
||||||
if (!target) return
|
if (!target) return
|
||||||
scrollCache.restore(target, {
|
scrollCache.restore(target, {
|
||||||
@@ -484,17 +406,16 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
|
|||||||
onApplied: (snapshot) => {
|
onApplied: (snapshot) => {
|
||||||
if (snapshot) {
|
if (snapshot) {
|
||||||
setAutoScroll(snapshot.atBottom)
|
setAutoScroll(snapshot.atBottom)
|
||||||
setShowScrollButton(!snapshot.atBottom)
|
|
||||||
} else {
|
} else {
|
||||||
const atBottom = isNearBottom(target)
|
const atBottom = isNearBottom(target)
|
||||||
setAutoScroll(atBottom)
|
setAutoScroll(atBottom)
|
||||||
setShowScrollButton(!atBottom)
|
|
||||||
}
|
}
|
||||||
|
updateScrollIndicators(target)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
void sessionId
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let previousToken: string | undefined
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const token = changeToken()
|
const token = changeToken()
|
||||||
if (!token || token === previousToken) {
|
if (!token || token === previousToken) {
|
||||||
@@ -508,7 +429,8 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (messageRecords().length === 0) {
|
if (messageRecords().length === 0) {
|
||||||
setShowScrollButton(false)
|
setShowScrollTopButton(false)
|
||||||
|
setShowScrollBottomButton(false)
|
||||||
setAutoScroll(true)
|
setAutoScroll(true)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -572,7 +494,7 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
|
|||||||
}}
|
}}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
>
|
>
|
||||||
<Show when={!props.loading && messageRecords().length === 0}>
|
<Show when={!props.loading && displayBlocks().length === 0}>
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-state-content">
|
<div class="empty-state-content">
|
||||||
<div class="flex flex-col items-center gap-3 mb-6">
|
<div class="flex flex-col items-center gap-3 mb-6">
|
||||||
@@ -602,46 +524,9 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={virtualPadding() > 0}>
|
|
||||||
<div class="message-stream-virtual-padding" style={{ height: `${virtualPadding()}px` }} aria-hidden="true" />
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={showLoadOlderButton()}>
|
|
||||||
<div class="message-stream-load-older">
|
|
||||||
<button type="button" class="message-stream-load-older-button" onClick={loadAllOlderMessages}>
|
|
||||||
Load older messages
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<For each={displayBlocks()}>
|
<For each={displayBlocks()}>
|
||||||
{(block) => {
|
{(block) => (
|
||||||
let blockRef: HTMLDivElement | undefined
|
<div class="message-stream-block" data-message-id={block.record.id}>
|
||||||
|
|
||||||
const scheduleMeasurement = () => {
|
|
||||||
if (!blockRef) return
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (!blockRef) return
|
|
||||||
updateMeasurementCache(block.record.id, block.record.revision, blockRef.clientHeight)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
void block.record.revision
|
|
||||||
scheduleMeasurement()
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
class="message-stream-block"
|
|
||||||
data-message-id={block.record.id}
|
|
||||||
ref={(element) => {
|
|
||||||
blockRef = element || undefined
|
|
||||||
if (element) {
|
|
||||||
scheduleMeasurement()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Show when={block.messageItem} keyed>
|
<Show when={block.messageItem} keyed>
|
||||||
{(message) => (
|
{(message) => (
|
||||||
<MessageItem
|
<MessageItem
|
||||||
@@ -659,7 +544,20 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<For each={block.toolItems}>
|
<For each={block.toolItems}>
|
||||||
{(item) => (
|
{(item) => {
|
||||||
|
const toolState = item.toolPart.state as ToolState | undefined
|
||||||
|
const hasToolState =
|
||||||
|
Boolean(toolState) && (isToolStateRunning(toolState) || isToolStateCompleted(toolState) || isToolStateError(toolState))
|
||||||
|
const taskSessionId = hasToolState ? extractTaskSessionId(toolState) : ""
|
||||||
|
const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId) : null
|
||||||
|
const handleGoToTaskSession = (event: MouseEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
if (!taskLocation) return
|
||||||
|
navigateToTaskSession(taskLocation)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<div class="tool-call-message" data-key={item.key}>
|
<div class="tool-call-message" data-key={item.key}>
|
||||||
<div class="tool-call-header-label">
|
<div class="tool-call-header-label">
|
||||||
<div class="tool-call-header-meta">
|
<div class="tool-call-header-meta">
|
||||||
@@ -667,6 +565,17 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
|
|||||||
<span>Tool Call</span>
|
<span>Tool Call</span>
|
||||||
<span class="tool-name">{item.toolPart.tool || "unknown"}</span>
|
<span class="tool-name">{item.toolPart.tool || "unknown"}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<Show when={taskSessionId}>
|
||||||
|
<button
|
||||||
|
class="tool-call-header-button"
|
||||||
|
type="button"
|
||||||
|
disabled={!taskLocation}
|
||||||
|
onClick={handleGoToTaskSession}
|
||||||
|
title={!taskLocation ? "Session not available yet" : "Go to session"}
|
||||||
|
>
|
||||||
|
Go to Session
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<ToolCall
|
<ToolCall
|
||||||
toolCall={item.toolPart}
|
toolCall={item.toolPart}
|
||||||
@@ -678,21 +587,40 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
|
|||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Show when={showScrollButton()}>
|
<Show when={showScrollTopButton() || showScrollBottomButton()}>
|
||||||
<div class="message-scroll-button-wrapper">
|
<div class="message-scroll-button-wrapper">
|
||||||
<button type="button" class="message-scroll-button" onClick={() => scrollToBottom()} aria-label="Scroll to latest message">
|
<Show when={showScrollTopButton()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="message-scroll-button"
|
||||||
|
onClick={() => scrollToTop()}
|
||||||
|
aria-label="Scroll to first message"
|
||||||
|
>
|
||||||
|
<span class="message-scroll-icon" aria-hidden="true">
|
||||||
|
↑
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
<Show when={showScrollBottomButton()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="message-scroll-button"
|
||||||
|
onClick={() => scrollToBottom()}
|
||||||
|
aria-label="Scroll to latest message"
|
||||||
|
>
|
||||||
<span class="message-scroll-icon" aria-hidden="true">
|
<span class="message-scroll-icon" aria-hidden="true">
|
||||||
↓
|
↓
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,14 +40,12 @@ const TOOL_CALL_CACHE_SCOPE = "tool-call"
|
|||||||
function makeRenderCacheKey(
|
function makeRenderCacheKey(
|
||||||
toolCallId?: string | null,
|
toolCallId?: string | null,
|
||||||
messageId?: string,
|
messageId?: string,
|
||||||
messageVersion?: number,
|
partId?: string | null,
|
||||||
partVersion?: number,
|
|
||||||
variant = "default",
|
variant = "default",
|
||||||
) {
|
) {
|
||||||
const messageComponent = messageId ?? "unknown-message"
|
const messageComponent = messageId ?? "unknown-message"
|
||||||
const toolCallComponent = toolCallId ?? "unknown-tool-call"
|
const toolCallComponent = partId ?? toolCallId ?? "unknown-tool-call"
|
||||||
const versionComponent = `${messageVersion ?? 0}:${partVersion ?? 0}`
|
return `${messageComponent}:${toolCallComponent}:${variant}`
|
||||||
return `${messageComponent}:${toolCallComponent}:${versionComponent}:${variant}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -326,8 +324,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
const cacheContext = createMemo(() => ({
|
const cacheContext = createMemo(() => ({
|
||||||
toolCallId: toolCallId(),
|
toolCallId: toolCallId(),
|
||||||
messageId: props.messageId,
|
messageId: props.messageId,
|
||||||
messageVersion: props.messageVersion ?? 0,
|
partId: props.toolCall?.id ?? null,
|
||||||
partVersion: props.partVersion ?? 0,
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const createVariantCache = (variant: string) =>
|
const createVariantCache = (variant: string) =>
|
||||||
@@ -337,20 +334,14 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
scope: TOOL_CALL_CACHE_SCOPE,
|
scope: TOOL_CALL_CACHE_SCOPE,
|
||||||
key: () => {
|
key: () => {
|
||||||
const context = cacheContext()
|
const context = cacheContext()
|
||||||
return makeRenderCacheKey(
|
return makeRenderCacheKey(context.toolCallId || undefined, context.messageId, context.partId, variant)
|
||||||
context.toolCallId || undefined,
|
|
||||||
context.messageId,
|
|
||||||
context.messageVersion,
|
|
||||||
context.partVersion,
|
|
||||||
variant,
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const diffCache = createVariantCache("diff")
|
const diffCache = createVariantCache("diff")
|
||||||
const permissionDiffCache = createVariantCache("permission-diff")
|
const permissionDiffCache = createVariantCache("permission-diff")
|
||||||
const markdownCache = createVariantCache("markdown")
|
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 pendingPermission = createMemo(() => {
|
||||||
const state = permissionState()
|
const state = permissionState()
|
||||||
if (state) {
|
if (state) {
|
||||||
|
|||||||
@@ -70,40 +70,12 @@
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-stream-virtual-padding {
|
|
||||||
width: 100%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-stream-block {
|
.message-stream-block {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.25rem;
|
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 {
|
.message-scroll-button-wrapper {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 1rem;
|
right: 1rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user