refactor message stream layout
This commit is contained in:
@@ -35,7 +35,7 @@ CodeNomad is a cross-platform desktop application built with Electron that provi
|
|||||||
│ │ │ UI Components │ │ │
|
│ │ │ UI Components │ │ │
|
||||||
│ │ │ - InstanceTabs │ │ │
|
│ │ │ - InstanceTabs │ │ │
|
||||||
│ │ │ - SessionTabs │ │ │
|
│ │ │ - SessionTabs │ │ │
|
||||||
│ │ │ - MessageStreamV2 │ │ │
|
│ │ │ - MessageSection │ │ │
|
||||||
│ │ │ - PromptInput │ │ │
|
│ │ │ - PromptInput │ │ │
|
||||||
│ │ └────────────────────────────────────────────┘ │ │
|
│ │ └────────────────────────────────────────────┘ │ │
|
||||||
│ └──────────────────────────────────────────────────┘ │
|
│ └──────────────────────────────────────────────────┘ │
|
||||||
|
|||||||
105
packages/ui/src/components/message-block-list.tsx
Normal file
105
packages/ui/src/components/message-block-list.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { Index, createEffect, createSignal, type Accessor } from "solid-js"
|
||||||
|
import VirtualItem from "./virtual-item"
|
||||||
|
import MessageBlock from "./message-block"
|
||||||
|
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||||
|
|
||||||
|
const VIRTUAL_ITEM_MARGIN_PX = 800
|
||||||
|
const ESTIMATED_MESSAGE_HEIGHT = 320
|
||||||
|
const INITIAL_FORCE_MIN_ITEMS = 12
|
||||||
|
const INITIAL_FORCE_OVERSCAN = 6
|
||||||
|
|
||||||
|
interface MessageBlockListProps {
|
||||||
|
instanceId: string
|
||||||
|
sessionId: string
|
||||||
|
store: () => InstanceMessageStore
|
||||||
|
messageIds: () => string[]
|
||||||
|
messageIndexMap: () => Map<string, number>
|
||||||
|
lastAssistantIndex: () => number
|
||||||
|
showThinking: () => boolean
|
||||||
|
thinkingDefaultExpanded: () => boolean
|
||||||
|
showUsageMetrics: () => boolean
|
||||||
|
scrollContainer: Accessor<HTMLDivElement | undefined>
|
||||||
|
loading?: boolean
|
||||||
|
onRevert?: (messageId: string) => void
|
||||||
|
onFork?: (messageId?: string) => void
|
||||||
|
onContentRendered?: () => void
|
||||||
|
setBottomSentinel: (element: HTMLDivElement | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MessageBlockList(props: MessageBlockListProps) {
|
||||||
|
const [initialForceActive, setInitialForceActive] = createSignal(true)
|
||||||
|
const [initialForceInitialized, setInitialForceInitialized] = createSignal(false)
|
||||||
|
const [initialForceStartIndex, setInitialForceStartIndex] = createSignal(0)
|
||||||
|
const [, setInitialForceRemaining] = createSignal(0)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
props.instanceId
|
||||||
|
props.sessionId
|
||||||
|
setInitialForceActive(true)
|
||||||
|
setInitialForceInitialized(false)
|
||||||
|
setInitialForceStartIndex(0)
|
||||||
|
setInitialForceRemaining(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!initialForceActive() || initialForceInitialized()) return
|
||||||
|
const ids = props.messageIds()
|
||||||
|
if (ids.length === 0) return
|
||||||
|
const viewportHeight = props.scrollContainer()?.clientHeight ?? (typeof window !== "undefined" ? window.innerHeight : 800)
|
||||||
|
const estimatedCount = Math.min(
|
||||||
|
ids.length,
|
||||||
|
Math.max(INITIAL_FORCE_MIN_ITEMS, Math.ceil(viewportHeight / ESTIMATED_MESSAGE_HEIGHT) + INITIAL_FORCE_OVERSCAN),
|
||||||
|
)
|
||||||
|
setInitialForceStartIndex(Math.max(0, ids.length - estimatedCount))
|
||||||
|
setInitialForceRemaining(estimatedCount)
|
||||||
|
setInitialForceInitialized(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Index each={props.messageIds()}>
|
||||||
|
{(messageId) => {
|
||||||
|
const messageIndex = () => props.messageIndexMap().get(messageId()) ?? 0
|
||||||
|
const forceVisible = () => initialForceActive() && messageIndex() >= initialForceStartIndex()
|
||||||
|
const handleMeasured = () => {
|
||||||
|
if (!forceVisible()) return
|
||||||
|
setInitialForceRemaining((value) => {
|
||||||
|
const next = value > 0 ? value - 1 : 0
|
||||||
|
if (next === 0) {
|
||||||
|
setInitialForceActive(false)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<VirtualItem
|
||||||
|
cacheKey={messageId()}
|
||||||
|
scrollContainer={props.scrollContainer}
|
||||||
|
threshold={VIRTUAL_ITEM_MARGIN_PX}
|
||||||
|
placeholderClass="message-stream-placeholder"
|
||||||
|
virtualizationEnabled={() => !props.loading}
|
||||||
|
forceVisible={forceVisible}
|
||||||
|
onMeasured={handleMeasured}
|
||||||
|
>
|
||||||
|
<MessageBlock
|
||||||
|
messageId={messageId()}
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
sessionId={props.sessionId}
|
||||||
|
store={props.store}
|
||||||
|
messageIndexMap={props.messageIndexMap}
|
||||||
|
lastAssistantIndex={props.lastAssistantIndex}
|
||||||
|
showThinking={props.showThinking}
|
||||||
|
thinkingDefaultExpanded={props.thinkingDefaultExpanded}
|
||||||
|
showUsageMetrics={props.showUsageMetrics}
|
||||||
|
onRevert={props.onRevert}
|
||||||
|
onFork={props.onFork}
|
||||||
|
onContentRendered={props.onContentRendered}
|
||||||
|
/>
|
||||||
|
</VirtualItem>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Index>
|
||||||
|
<div ref={props.setBottomSentinel} aria-hidden="true" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,39 +1,24 @@
|
|||||||
import { For, Index, Match, Show, Switch, createMemo, createSignal, createEffect, onCleanup } from "solid-js"
|
import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
|
||||||
import MessageItem from "./message-item"
|
import MessageItem from "./message-item"
|
||||||
import VirtualItem from "./virtual-item"
|
|
||||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
|
||||||
import ToolCall from "./tool-call"
|
import ToolCall from "./tool-call"
|
||||||
import Kbd from "./kbd"
|
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||||
import type { MessageInfo, ClientPart } from "../types/message"
|
import type { ClientPart, MessageInfo } from "../types/message"
|
||||||
import { partHasRenderableText } from "../types/message"
|
import { partHasRenderableText } from "../types/message"
|
||||||
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"
|
|
||||||
import { buildRecordDisplayData, clearRecordDisplayCacheForInstance } from "../stores/message-v2/record-display-cache"
|
import { buildRecordDisplayData, clearRecordDisplayCacheForInstance } from "../stores/message-v2/record-display-cache"
|
||||||
import { useConfig } from "../stores/preferences"
|
import type { MessageRecord } from "../stores/message-v2/types"
|
||||||
import { sseManager } from "../lib/sse-manager"
|
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||||
import { formatTokenTotal } from "../lib/formatters"
|
import { formatTokenTotal } from "../lib/formatters"
|
||||||
import { useScrollCache } from "../lib/hooks/use-scroll-cache"
|
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
|
||||||
import { setActiveInstanceId } from "../stores/instances"
|
import { setActiveInstanceId } from "../stores/instances"
|
||||||
|
|
||||||
const SCROLL_SCOPE = "session"
|
|
||||||
const USER_SCROLL_INTENT_WINDOW_MS = 600
|
|
||||||
const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
|
|
||||||
|
|
||||||
const TOOL_ICON = "🔧"
|
const TOOL_ICON = "🔧"
|
||||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
|
||||||
|
|
||||||
const USER_BORDER_COLOR = "var(--message-user-border)"
|
const USER_BORDER_COLOR = "var(--message-user-border)"
|
||||||
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
|
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
|
||||||
const TOOL_BORDER_COLOR = "var(--message-tool-border)"
|
const TOOL_BORDER_COLOR = "var(--message-tool-border)"
|
||||||
const VIRTUAL_ITEM_MARGIN_PX = 800
|
|
||||||
const ESTIMATED_MESSAGE_HEIGHT = 320
|
|
||||||
const INITIAL_FORCE_MIN_ITEMS = 12
|
|
||||||
const INITIAL_FORCE_OVERSCAN = 6
|
|
||||||
|
|
||||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||||
|
|
||||||
|
|
||||||
type ToolState = import("@opencode-ai/sdk").ToolState
|
type ToolState = import("@opencode-ai/sdk").ToolState
|
||||||
type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning
|
type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning
|
||||||
type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted
|
type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted
|
||||||
@@ -121,10 +106,6 @@ function navigateToTaskSession(location: TaskSessionLocation) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTokens(tokens: number): string {
|
|
||||||
return formatTokenTotal(tokens)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CachedBlockEntry {
|
interface CachedBlockEntry {
|
||||||
signature: string
|
signature: string
|
||||||
block: MessageDisplayBlock
|
block: MessageDisplayBlock
|
||||||
@@ -159,7 +140,6 @@ function getSessionRenderCache(instanceId: string, sessionId: string): SessionRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearInstanceCaches(instanceId: string) {
|
function clearInstanceCaches(instanceId: string) {
|
||||||
|
|
||||||
clearRecordDisplayCacheForInstance(instanceId)
|
clearRecordDisplayCacheForInstance(instanceId)
|
||||||
const prefix = `${instanceId}:`
|
const prefix = `${instanceId}:`
|
||||||
for (const key of renderCaches.keys()) {
|
for (const key of renderCaches.keys()) {
|
||||||
@@ -171,16 +151,6 @@ function clearInstanceCaches(instanceId: string) {
|
|||||||
|
|
||||||
messageStoreBus.onInstanceDestroyed(clearInstanceCaches)
|
messageStoreBus.onInstanceDestroyed(clearInstanceCaches)
|
||||||
|
|
||||||
|
|
||||||
interface MessageStreamV2Props {
|
|
||||||
instanceId: string
|
|
||||||
sessionId: string
|
|
||||||
loading?: boolean
|
|
||||||
onRevert?: (messageId: string) => void
|
|
||||||
onFork?: (messageId?: string) => void
|
|
||||||
registerScrollToBottom?: (fn: () => void) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ContentDisplayItem {
|
interface ContentDisplayItem {
|
||||||
type: "content"
|
type: "content"
|
||||||
key: string
|
key: string
|
||||||
@@ -225,538 +195,6 @@ interface MessageDisplayBlock {
|
|||||||
items: MessageBlockItem[]
|
items: MessageBlockItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MessageStreamV2(props: MessageStreamV2Props) {
|
|
||||||
const { preferences } = useConfig()
|
|
||||||
const showUsagePreference = () => preferences().showUsageMetrics ?? true
|
|
||||||
const store = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
|
|
||||||
const messageIds = createMemo(() => store().getSessionMessageIds(props.sessionId))
|
|
||||||
|
|
||||||
const sessionRevision = createMemo(() => store().getSessionRevision(props.sessionId))
|
|
||||||
const usageSnapshot = createMemo(() => store().getSessionUsage(props.sessionId))
|
|
||||||
const sessionInfo = createMemo(() =>
|
|
||||||
getSessionInfo(props.instanceId, props.sessionId) ?? {
|
|
||||||
cost: 0,
|
|
||||||
contextWindow: 0,
|
|
||||||
isSubscriptionModel: false,
|
|
||||||
inputTokens: 0,
|
|
||||||
outputTokens: 0,
|
|
||||||
reasoningTokens: 0,
|
|
||||||
actualUsageTokens: 0,
|
|
||||||
modelOutputLimit: 0,
|
|
||||||
contextAvailableTokens: null,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
const tokenStats = createMemo(() => {
|
|
||||||
const usage = usageSnapshot()
|
|
||||||
const info = sessionInfo()
|
|
||||||
return {
|
|
||||||
used: usage?.actualUsageTokens ?? info.actualUsageTokens ?? 0,
|
|
||||||
avail: info.contextAvailableTokens,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const preferenceSignature = createMemo(() => {
|
|
||||||
const pref = preferences()
|
|
||||||
const showThinking = pref.showThinkingBlocks ? 1 : 0
|
|
||||||
const thinkingExpansion = pref.thinkingBlocksExpansion ?? "expanded"
|
|
||||||
const showUsage = (pref.showUsageMetrics ?? true) ? 1 : 0
|
|
||||||
return `${showThinking}|${thinkingExpansion}|${showUsage}`
|
|
||||||
})
|
|
||||||
|
|
||||||
const connectionStatus = () => sseManager.getStatus(props.instanceId)
|
|
||||||
|
|
||||||
const handleCommandPaletteClick = () => {
|
|
||||||
showCommandPalette(props.instanceId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const messageIndexMap = createMemo(() => {
|
|
||||||
const map = new Map<string, number>()
|
|
||||||
const ids = messageIds()
|
|
||||||
ids.forEach((id, index) => map.set(id, index))
|
|
||||||
return map
|
|
||||||
})
|
|
||||||
|
|
||||||
const lastAssistantIndex = createMemo(() => {
|
|
||||||
const ids = messageIds()
|
|
||||||
const resolvedStore = store()
|
|
||||||
for (let index = ids.length - 1; index >= 0; index--) {
|
|
||||||
const record = resolvedStore.getMessage(ids[index])
|
|
||||||
if (record?.role === "assistant") {
|
|
||||||
return index
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
})
|
|
||||||
|
|
||||||
const changeToken = createMemo(() => {
|
|
||||||
// Any change that can affect layout (new message, part update, revert,
|
|
||||||
// etc.) should bump the session revision. We use this as the primary
|
|
||||||
// signal for auto-scroll decisions.
|
|
||||||
return String(sessionRevision())
|
|
||||||
})
|
|
||||||
|
|
||||||
const scrollCache = useScrollCache({
|
|
||||||
instanceId: () => props.instanceId,
|
|
||||||
sessionId: () => props.sessionId,
|
|
||||||
scope: SCROLL_SCOPE,
|
|
||||||
})
|
|
||||||
|
|
||||||
const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
|
|
||||||
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null)
|
|
||||||
createEffect(() => {
|
|
||||||
if (bottomSentinel()) {
|
|
||||||
scheduleAnchorScroll(true)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const [initialForceActive, setInitialForceActive] = createSignal(true)
|
|
||||||
const [initialForceInitialized, setInitialForceInitialized] = createSignal(false)
|
|
||||||
const [initialForceStartIndex, setInitialForceStartIndex] = createSignal(0)
|
|
||||||
const [initialForceRemaining, setInitialForceRemaining] = createSignal(0)
|
|
||||||
const [autoScroll, setAutoScroll] = createSignal(true)
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
props.instanceId
|
|
||||||
props.sessionId
|
|
||||||
setInitialForceActive(true)
|
|
||||||
setInitialForceInitialized(false)
|
|
||||||
setInitialForceStartIndex(0)
|
|
||||||
setInitialForceRemaining(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!initialForceActive() || initialForceInitialized()) return
|
|
||||||
const ids = messageIds()
|
|
||||||
if (ids.length === 0) return
|
|
||||||
const viewportHeight = scrollElement()?.clientHeight ?? (typeof window !== "undefined" ? window.innerHeight : 800)
|
|
||||||
const estimatedCount = Math.min(
|
|
||||||
ids.length,
|
|
||||||
Math.max(INITIAL_FORCE_MIN_ITEMS, Math.ceil(viewportHeight / ESTIMATED_MESSAGE_HEIGHT) + INITIAL_FORCE_OVERSCAN),
|
|
||||||
)
|
|
||||||
setInitialForceStartIndex(Math.max(0, ids.length - estimatedCount))
|
|
||||||
setInitialForceRemaining(estimatedCount)
|
|
||||||
setInitialForceInitialized(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
|
|
||||||
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
|
|
||||||
|
|
||||||
let containerRef: HTMLDivElement | undefined
|
|
||||||
let lastKnownScrollTop = 0
|
|
||||||
let lastMeasuredScrollHeight = 0
|
|
||||||
let pendingScrollFrame: number | null = null
|
|
||||||
let pendingAnchorScroll: number | null = null
|
|
||||||
let userScrollIntentUntil = 0
|
|
||||||
let detachScrollIntentListeners: (() => void) | undefined
|
|
||||||
let hasRestoredScroll = false
|
|
||||||
// When the user explicitly clicks "scroll to bottom", we want the
|
|
||||||
// smooth scroll animation to complete without being immediately
|
|
||||||
// overridden by the auto-scroll effects that react to new messages.
|
|
||||||
let suppressAutoScrollOnce = false
|
|
||||||
|
|
||||||
function markUserScrollIntent() {
|
|
||||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
|
||||||
userScrollIntentUntil = now + USER_SCROLL_INTENT_WINDOW_MS
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasUserScrollIntent() {
|
|
||||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
|
||||||
return now <= userScrollIntentUntil
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachScrollIntentListeners(element: HTMLDivElement | undefined) {
|
|
||||||
if (detachScrollIntentListeners) {
|
|
||||||
detachScrollIntentListeners()
|
|
||||||
detachScrollIntentListeners = undefined
|
|
||||||
}
|
|
||||||
if (!element) return
|
|
||||||
const handlePointerIntent = () => markUserScrollIntent()
|
|
||||||
const handleKeyIntent = (event: KeyboardEvent) => {
|
|
||||||
if (SCROLL_INTENT_KEYS.has(event.key)) {
|
|
||||||
markUserScrollIntent()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
element.addEventListener("wheel", handlePointerIntent, { passive: true })
|
|
||||||
element.addEventListener("pointerdown", handlePointerIntent)
|
|
||||||
element.addEventListener("touchstart", handlePointerIntent, { passive: true })
|
|
||||||
element.addEventListener("keydown", handleKeyIntent)
|
|
||||||
detachScrollIntentListeners = () => {
|
|
||||||
element.removeEventListener("wheel", handlePointerIntent)
|
|
||||||
element.removeEventListener("pointerdown", handlePointerIntent)
|
|
||||||
element.removeEventListener("touchstart", handlePointerIntent)
|
|
||||||
element.removeEventListener("keydown", handleKeyIntent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setContainerRef(element: HTMLDivElement | null) {
|
|
||||||
containerRef = element || undefined
|
|
||||||
setScrollElement(containerRef)
|
|
||||||
lastKnownScrollTop = containerRef?.scrollTop ?? 0
|
|
||||||
lastMeasuredScrollHeight = containerRef?.scrollHeight ?? 0
|
|
||||||
attachScrollIntentListeners(containerRef)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = messageIds().length > 0
|
|
||||||
setShowScrollBottomButton(hasItems && !isNearBottom(element))
|
|
||||||
setShowScrollTopButton(hasItems && !isNearTop(element))
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollToBottom(immediate = false) {
|
|
||||||
if (!containerRef) return
|
|
||||||
const behavior = immediate ? "auto" : "smooth"
|
|
||||||
if (!immediate) {
|
|
||||||
suppressAutoScrollOnce = true
|
|
||||||
}
|
|
||||||
containerRef.scrollTo({ top: containerRef.scrollHeight, behavior })
|
|
||||||
setAutoScroll(true)
|
|
||||||
updateScrollIndicators(containerRef)
|
|
||||||
scheduleScrollPersist()
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollToTop(immediate = false) {
|
|
||||||
if (!containerRef) return
|
|
||||||
const behavior = immediate ? "auto" : "smooth"
|
|
||||||
setAutoScroll(false)
|
|
||||||
containerRef.scrollTo({ top: 0, behavior })
|
|
||||||
lastMeasuredScrollHeight = containerRef.scrollHeight
|
|
||||||
lastKnownScrollTop = containerRef.scrollTop
|
|
||||||
updateScrollIndicators(containerRef)
|
|
||||||
scheduleScrollPersist()
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleAnchorScroll(immediate = false) {
|
|
||||||
if (!autoScroll()) return
|
|
||||||
const sentinel = bottomSentinel()
|
|
||||||
if (!sentinel) return
|
|
||||||
if (pendingAnchorScroll !== null) {
|
|
||||||
cancelAnimationFrame(pendingAnchorScroll)
|
|
||||||
pendingAnchorScroll = null
|
|
||||||
}
|
|
||||||
pendingAnchorScroll = requestAnimationFrame(() => {
|
|
||||||
pendingAnchorScroll = null
|
|
||||||
sentinel.scrollIntoView({ block: "end", inline: "nearest", behavior: "auto" })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleContentRendered() {
|
|
||||||
scheduleAnchorScroll()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (props.registerScrollToBottom) {
|
|
||||||
props.registerScrollToBottom(() => scrollToBottom(true))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
let pendingScrollPersist: number | null = null
|
|
||||||
|
|
||||||
|
|
||||||
function scheduleScrollPersist() {
|
|
||||||
if (pendingScrollPersist !== null) return
|
|
||||||
pendingScrollPersist = requestAnimationFrame(() => {
|
|
||||||
pendingScrollPersist = null
|
|
||||||
if (!containerRef) return
|
|
||||||
scrollCache.persist(containerRef, { atBottomOffset: 48 })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function handleScroll(event: Event) {
|
|
||||||
|
|
||||||
if (!containerRef) return
|
|
||||||
if (pendingScrollFrame !== null) {
|
|
||||||
cancelAnimationFrame(pendingScrollFrame)
|
|
||||||
}
|
|
||||||
const isUserScroll = hasUserScrollIntent()
|
|
||||||
pendingScrollFrame = requestAnimationFrame(() => {
|
|
||||||
pendingScrollFrame = null
|
|
||||||
if (!containerRef) return
|
|
||||||
const currentTop = containerRef.scrollTop
|
|
||||||
lastKnownScrollTop = currentTop
|
|
||||||
lastMeasuredScrollHeight = containerRef.scrollHeight
|
|
||||||
const atBottom = isNearBottom(containerRef)
|
|
||||||
|
|
||||||
if (isUserScroll) {
|
|
||||||
// If the user scrolls and ends near the bottom, enable auto-scroll.
|
|
||||||
// If they scroll away from the bottom by more than our threshold,
|
|
||||||
// disable auto-scroll until they explicitly return.
|
|
||||||
if (atBottom) {
|
|
||||||
if (!autoScroll()) setAutoScroll(true)
|
|
||||||
} else {
|
|
||||||
if (autoScroll()) setAutoScroll(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateScrollIndicators(containerRef)
|
|
||||||
scheduleScrollPersist()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const target = containerRef
|
|
||||||
const loading = props.loading
|
|
||||||
|
|
||||||
if (!target) return
|
|
||||||
if (loading) return
|
|
||||||
if (hasRestoredScroll) return
|
|
||||||
|
|
||||||
scrollCache.restore(target, {
|
|
||||||
onApplied: (snapshot) => {
|
|
||||||
if (snapshot) {
|
|
||||||
setAutoScroll(snapshot.atBottom)
|
|
||||||
} else {
|
|
||||||
const atBottom = isNearBottom(target)
|
|
||||||
setAutoScroll(atBottom)
|
|
||||||
}
|
|
||||||
lastMeasuredScrollHeight = target.scrollHeight
|
|
||||||
updateScrollIndicators(target)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
hasRestoredScroll = true
|
|
||||||
})
|
|
||||||
|
|
||||||
let previousToken: string | undefined
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const token = changeToken()
|
|
||||||
const loading = props.loading
|
|
||||||
|
|
||||||
if (loading) return
|
|
||||||
if (!token || token === previousToken) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
previousToken = token
|
|
||||||
if (suppressAutoScrollOnce) {
|
|
||||||
suppressAutoScrollOnce = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (autoScroll()) {
|
|
||||||
scheduleAnchorScroll(true)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
preferenceSignature()
|
|
||||||
if (props.loading) return
|
|
||||||
if (!autoScroll()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (suppressAutoScrollOnce) {
|
|
||||||
suppressAutoScrollOnce = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
scheduleAnchorScroll(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (messageIds().length === 0) {
|
|
||||||
setShowScrollTopButton(false)
|
|
||||||
setShowScrollBottomButton(false)
|
|
||||||
setAutoScroll(true)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
if (pendingScrollFrame !== null) {
|
|
||||||
cancelAnimationFrame(pendingScrollFrame)
|
|
||||||
pendingScrollFrame = null
|
|
||||||
}
|
|
||||||
if (pendingScrollPersist !== null) {
|
|
||||||
cancelAnimationFrame(pendingScrollPersist)
|
|
||||||
pendingScrollPersist = null
|
|
||||||
}
|
|
||||||
if (pendingAnchorScroll !== null) {
|
|
||||||
cancelAnimationFrame(pendingAnchorScroll)
|
|
||||||
pendingAnchorScroll = null
|
|
||||||
}
|
|
||||||
if (detachScrollIntentListeners) {
|
|
||||||
detachScrollIntentListeners()
|
|
||||||
detachScrollIntentListeners = undefined
|
|
||||||
}
|
|
||||||
if (containerRef) {
|
|
||||||
scrollCache.persist(containerRef, { atBottomOffset: 48 })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="message-stream-container">
|
|
||||||
<div class="connection-status">
|
|
||||||
<div class="connection-status-text connection-status-info flex flex-wrap items-center gap-2 text-sm font-medium">
|
|
||||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
|
||||||
<span class="uppercase text-[10px] tracking-wide text-primary/70">Used</span>
|
|
||||||
<span class="font-semibold text-primary">{formatTokens(tokenStats().used)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
|
||||||
<span class="uppercase text-[10px] tracking-wide text-primary/70">Avail</span>
|
|
||||||
<span class="font-semibold text-primary">
|
|
||||||
{sessionInfo().contextAvailableTokens !== null ? formatTokens(sessionInfo().contextAvailableTokens ?? 0) : "--"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="connection-status-text connection-status-shortcut">
|
|
||||||
<div class="connection-status-shortcut-action">
|
|
||||||
<button type="button" class="connection-status-button" onClick={handleCommandPaletteClick} aria-label="Open command palette">
|
|
||||||
Command Palette
|
|
||||||
</button>
|
|
||||||
<span class="connection-status-shortcut-hint">
|
|
||||||
<Kbd shortcut="cmd+shift+p" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="connection-status-meta flex items-center justify-end gap-3">
|
|
||||||
<Show when={connectionStatus() === "connected"}>
|
|
||||||
<span class="status-indicator connected">
|
|
||||||
<span class="status-dot" />
|
|
||||||
Connected
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={connectionStatus() === "connecting"}>
|
|
||||||
<span class="status-indicator connecting">
|
|
||||||
<span class="status-dot" />
|
|
||||||
Connecting...
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={connectionStatus() === "error" || connectionStatus() === "disconnected"}>
|
|
||||||
<span class="status-indicator disconnected">
|
|
||||||
<span class="status-dot" />
|
|
||||||
Disconnected
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="message-stream"
|
|
||||||
ref={setContainerRef}
|
|
||||||
onScroll={handleScroll}
|
|
||||||
>
|
|
||||||
<Show when={!props.loading && messageIds().length === 0}>
|
|
||||||
<div class="empty-state">
|
|
||||||
<div class="empty-state-content">
|
|
||||||
<div class="flex flex-col items-center gap-3 mb-6">
|
|
||||||
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-48 w-auto" loading="lazy" />
|
|
||||||
<h1 class="text-3xl font-semibold text-primary">CodeNomad</h1>
|
|
||||||
</div>
|
|
||||||
<h3>Start a conversation</h3>
|
|
||||||
<p>Type a message below or open the Command Palette:</p>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<span>Command Palette</span>
|
|
||||||
<Kbd shortcut="cmd+shift+p" class="ml-2" />
|
|
||||||
</li>
|
|
||||||
<li>Ask about your codebase</li>
|
|
||||||
<li>
|
|
||||||
Attach files with <code>@</code>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={props.loading}>
|
|
||||||
<div class="loading-state">
|
|
||||||
<div class="spinner" />
|
|
||||||
<p>Loading messages...</p>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Index each={messageIds()}>
|
|
||||||
{(messageId) => {
|
|
||||||
const messageIndex = () => messageIndexMap().get(messageId()) ?? 0
|
|
||||||
const forceVisible = () => initialForceActive() && messageIndex() >= initialForceStartIndex()
|
|
||||||
const handleMeasured = () => {
|
|
||||||
if (!forceVisible()) return
|
|
||||||
setInitialForceRemaining((value) => {
|
|
||||||
const next = value > 0 ? value - 1 : 0
|
|
||||||
if (next === 0) {
|
|
||||||
setInitialForceActive(false)
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<VirtualItem
|
|
||||||
cacheKey={messageId()}
|
|
||||||
scrollContainer={scrollElement}
|
|
||||||
threshold={VIRTUAL_ITEM_MARGIN_PX}
|
|
||||||
placeholderClass="message-stream-placeholder"
|
|
||||||
virtualizationEnabled={() => !props.loading}
|
|
||||||
forceVisible={forceVisible}
|
|
||||||
onMeasured={handleMeasured}
|
|
||||||
>
|
|
||||||
<MessageBlock
|
|
||||||
messageId={messageId()}
|
|
||||||
instanceId={props.instanceId}
|
|
||||||
sessionId={props.sessionId}
|
|
||||||
store={store}
|
|
||||||
messageIndexMap={messageIndexMap}
|
|
||||||
lastAssistantIndex={lastAssistantIndex}
|
|
||||||
showThinking={() => preferences().showThinkingBlocks}
|
|
||||||
thinkingDefaultExpanded={() => (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded"}
|
|
||||||
showUsageMetrics={showUsagePreference}
|
|
||||||
onRevert={props.onRevert}
|
|
||||||
onFork={props.onFork}
|
|
||||||
onContentRendered={handleContentRendered}
|
|
||||||
/>
|
|
||||||
</VirtualItem>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</Index>
|
|
||||||
<div ref={setBottomSentinel} aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={showScrollTopButton() || showScrollBottomButton()}>
|
|
||||||
|
|
||||||
<div class="message-scroll-button-wrapper">
|
|
||||||
<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>
|
|
||||||
</button>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MessageBlockProps {
|
interface MessageBlockProps {
|
||||||
messageId: string
|
messageId: string
|
||||||
instanceId: string
|
instanceId: string
|
||||||
@@ -772,8 +210,7 @@ interface MessageBlockProps {
|
|||||||
onContentRendered?: () => void
|
onContentRendered?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function MessageBlock(props: MessageBlockProps) {
|
||||||
function MessageBlock(props: MessageBlockProps) {
|
|
||||||
const record = createMemo(() => props.store().getMessage(props.messageId))
|
const record = createMemo(() => props.store().getMessage(props.messageId))
|
||||||
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
||||||
const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId)
|
const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId)
|
||||||
@@ -787,11 +224,12 @@ function MessageBlock(props: MessageBlockProps) {
|
|||||||
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
|
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
|
||||||
const info = messageInfo()
|
const info = messageInfo()
|
||||||
const infoTime = (info?.time ?? {}) as { created?: number; updated?: number; completed?: number }
|
const infoTime = (info?.time ?? {}) as { created?: number; updated?: number; completed?: number }
|
||||||
const infoTimestamp = typeof infoTime.completed === "number"
|
const infoTimestamp =
|
||||||
? infoTime.completed
|
typeof infoTime.completed === "number"
|
||||||
: typeof infoTime.updated === "number"
|
? infoTime.completed
|
||||||
? infoTime.updated
|
: typeof infoTime.updated === "number"
|
||||||
: infoTime.created ?? 0
|
? infoTime.updated
|
||||||
|
: infoTime.created ?? 0
|
||||||
const infoError = (info as { error?: { name?: string } } | undefined)?.error
|
const infoError = (info as { error?: { name?: string } } | undefined)?.error
|
||||||
const infoErrorName = typeof infoError?.name === "string" ? infoError.name : ""
|
const infoErrorName = typeof infoError?.name === "string" ? infoError.name : ""
|
||||||
const cacheSignature = [
|
const cacheSignature = [
|
||||||
@@ -969,12 +407,10 @@ function MessageBlock(props: MessageBlockProps) {
|
|||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
isQueued={(item as ContentDisplayItem).isQueued}
|
isQueued={(item as ContentDisplayItem).isQueued}
|
||||||
showAgentMeta={(item as ContentDisplayItem).showAgentMeta}
|
showAgentMeta={(item as ContentDisplayItem).showAgentMeta}
|
||||||
|
|
||||||
onRevert={props.onRevert}
|
onRevert={props.onRevert}
|
||||||
onFork={props.onFork}
|
onFork={props.onFork}
|
||||||
onContentRendered={props.onContentRendered}
|
onContentRendered={props.onContentRendered}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={item.type === "tool"}>
|
<Match when={item.type === "tool"}>
|
||||||
{(() => {
|
{(() => {
|
||||||
@@ -1021,18 +457,12 @@ function MessageBlock(props: MessageBlockProps) {
|
|||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
onContentRendered={props.onContentRendered}
|
onContentRendered={props.onContentRendered}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={item.type === "step-start"}>
|
<Match when={item.type === "step-start"}>
|
||||||
<StepCard
|
<StepCard kind="start" part={(item as StepDisplayItem).part} messageInfo={(item as StepDisplayItem).messageInfo} showAgentMeta />
|
||||||
kind="start"
|
|
||||||
part={(item as StepDisplayItem).part}
|
|
||||||
messageInfo={(item as StepDisplayItem).messageInfo}
|
|
||||||
showAgentMeta
|
|
||||||
/>
|
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={item.type === "step-finish"}>
|
<Match when={item.type === "step-finish"}>
|
||||||
<StepCard
|
<StepCard
|
||||||
@@ -1169,6 +599,7 @@ function StepCard(props: StepCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCostValue(value: number) {
|
function formatCostValue(value: number) {
|
||||||
if (!value) return "$0.00"
|
if (!value) return "$0.00"
|
||||||
if (value < 0.01) return `$${value.toPrecision(2)}`
|
if (value < 0.01) return `$${value.toPrecision(2)}`
|
||||||
62
packages/ui/src/components/message-list-header.tsx
Normal file
62
packages/ui/src/components/message-list-header.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { Show } from "solid-js"
|
||||||
|
import Kbd from "./kbd"
|
||||||
|
|
||||||
|
interface MessageListHeaderProps {
|
||||||
|
usedTokens: number
|
||||||
|
availableTokens?: number | null
|
||||||
|
connectionStatus: "connected" | "connecting" | "error" | "disconnected" | "unknown" | null
|
||||||
|
onCommandPalette: () => void
|
||||||
|
formatTokens: (value: number) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MessageListHeader(props: MessageListHeaderProps) {
|
||||||
|
const hasAvailableTokens = () => typeof props.availableTokens === "number"
|
||||||
|
const availableDisplay = () => (hasAvailableTokens() ? props.formatTokens(props.availableTokens as number) : "--")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="connection-status">
|
||||||
|
<div class="connection-status-text connection-status-info flex flex-wrap items-center gap-2 text-sm font-medium">
|
||||||
|
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||||
|
<span class="uppercase text-[10px] tracking-wide text-primary/70">Used</span>
|
||||||
|
<span class="font-semibold text-primary">{props.formatTokens(props.usedTokens)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||||
|
<span class="uppercase text-[10px] tracking-wide text-primary/70">Avail</span>
|
||||||
|
<span class="font-semibold text-primary">{hasAvailableTokens() ? availableDisplay() : "--"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="connection-status-text connection-status-shortcut">
|
||||||
|
<div class="connection-status-shortcut-action">
|
||||||
|
<button type="button" class="connection-status-button" onClick={props.onCommandPalette} aria-label="Open command palette">
|
||||||
|
Command Palette
|
||||||
|
</button>
|
||||||
|
<span class="connection-status-shortcut-hint">
|
||||||
|
<Kbd shortcut="cmd+shift+p" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="connection-status-meta flex items-center justify-end gap-3">
|
||||||
|
<Show when={props.connectionStatus === "connected"}>
|
||||||
|
<span class="status-indicator connected">
|
||||||
|
<span class="status-dot" />
|
||||||
|
Connected
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={props.connectionStatus === "connecting"}>
|
||||||
|
<span class="status-indicator connecting">
|
||||||
|
<span class="status-dot" />
|
||||||
|
Connecting...
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={props.connectionStatus === "error" || props.connectionStatus === "disconnected"}>
|
||||||
|
<span class="status-indicator disconnected">
|
||||||
|
<span class="status-dot" />
|
||||||
|
Disconnected
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
412
packages/ui/src/components/message-section.tsx
Normal file
412
packages/ui/src/components/message-section.tsx
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||||
|
import Kbd from "./kbd"
|
||||||
|
import MessageBlockList from "./message-block-list"
|
||||||
|
import MessageListHeader from "./message-list-header"
|
||||||
|
import { useConfig } from "../stores/preferences"
|
||||||
|
import { getSessionInfo } from "../stores/sessions"
|
||||||
|
import { showCommandPalette } from "../stores/command-palette"
|
||||||
|
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||||
|
import { useScrollCache } from "../lib/hooks/use-scroll-cache"
|
||||||
|
import { sseManager } from "../lib/sse-manager"
|
||||||
|
import { formatTokenTotal } from "../lib/formatters"
|
||||||
|
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||||
|
|
||||||
|
const SCROLL_SCOPE = "session"
|
||||||
|
const USER_SCROLL_INTENT_WINDOW_MS = 600
|
||||||
|
const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
|
||||||
|
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||||
|
|
||||||
|
function formatTokens(tokens: number): string {
|
||||||
|
return formatTokenTotal(tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageSectionProps {
|
||||||
|
instanceId: string
|
||||||
|
sessionId: string
|
||||||
|
loading?: boolean
|
||||||
|
onRevert?: (messageId: string) => void
|
||||||
|
onFork?: (messageId?: string) => void
|
||||||
|
registerScrollToBottom?: (fn: () => void) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MessageSection(props: MessageSectionProps) {
|
||||||
|
const { preferences } = useConfig()
|
||||||
|
const showUsagePreference = () => preferences().showUsageMetrics ?? true
|
||||||
|
const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId))
|
||||||
|
const messageIds = createMemo(() => store().getSessionMessageIds(props.sessionId))
|
||||||
|
|
||||||
|
const sessionRevision = createMemo(() => store().getSessionRevision(props.sessionId))
|
||||||
|
const usageSnapshot = createMemo(() => store().getSessionUsage(props.sessionId))
|
||||||
|
const sessionInfo = createMemo(() =>
|
||||||
|
getSessionInfo(props.instanceId, props.sessionId) ?? {
|
||||||
|
cost: 0,
|
||||||
|
contextWindow: 0,
|
||||||
|
isSubscriptionModel: false,
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
reasoningTokens: 0,
|
||||||
|
actualUsageTokens: 0,
|
||||||
|
modelOutputLimit: 0,
|
||||||
|
contextAvailableTokens: null,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const tokenStats = createMemo(() => {
|
||||||
|
const usage = usageSnapshot()
|
||||||
|
const info = sessionInfo()
|
||||||
|
return {
|
||||||
|
used: usage?.actualUsageTokens ?? info.actualUsageTokens ?? 0,
|
||||||
|
avail: info.contextAvailableTokens,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const preferenceSignature = createMemo(() => {
|
||||||
|
const pref = preferences()
|
||||||
|
const showThinking = pref.showThinkingBlocks ? 1 : 0
|
||||||
|
const thinkingExpansion = pref.thinkingBlocksExpansion ?? "expanded"
|
||||||
|
const showUsage = (pref.showUsageMetrics ?? true) ? 1 : 0
|
||||||
|
return `${showThinking}|${thinkingExpansion}|${showUsage}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const connectionStatus = () => sseManager.getStatus(props.instanceId)
|
||||||
|
const handleCommandPaletteClick = () => {
|
||||||
|
showCommandPalette(props.instanceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageIndexMap = createMemo(() => {
|
||||||
|
const map = new Map<string, number>()
|
||||||
|
const ids = messageIds()
|
||||||
|
ids.forEach((id, index) => map.set(id, index))
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const lastAssistantIndex = createMemo(() => {
|
||||||
|
const ids = messageIds()
|
||||||
|
const resolvedStore = store()
|
||||||
|
for (let index = ids.length - 1; index >= 0; index--) {
|
||||||
|
const record = resolvedStore.getMessage(ids[index])
|
||||||
|
if (record?.role === "assistant") {
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
})
|
||||||
|
|
||||||
|
const changeToken = createMemo(() => String(sessionRevision()))
|
||||||
|
|
||||||
|
const scrollCache = useScrollCache({
|
||||||
|
instanceId: () => props.instanceId,
|
||||||
|
sessionId: () => props.sessionId,
|
||||||
|
scope: SCROLL_SCOPE,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
|
||||||
|
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null)
|
||||||
|
const [autoScroll, setAutoScroll] = createSignal(true)
|
||||||
|
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
|
||||||
|
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
|
||||||
|
|
||||||
|
let containerRef: HTMLDivElement | undefined
|
||||||
|
let pendingScrollFrame: number | null = null
|
||||||
|
let pendingAnchorScroll: number | null = null
|
||||||
|
let pendingScrollPersist: number | null = null
|
||||||
|
let userScrollIntentUntil = 0
|
||||||
|
let detachScrollIntentListeners: (() => void) | undefined
|
||||||
|
let hasRestoredScroll = false
|
||||||
|
let suppressAutoScrollOnce = false
|
||||||
|
|
||||||
|
function markUserScrollIntent() {
|
||||||
|
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||||
|
userScrollIntentUntil = now + USER_SCROLL_INTENT_WINDOW_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasUserScrollIntent() {
|
||||||
|
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||||
|
return now <= userScrollIntentUntil
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachScrollIntentListeners(element: HTMLDivElement | undefined) {
|
||||||
|
if (detachScrollIntentListeners) {
|
||||||
|
detachScrollIntentListeners()
|
||||||
|
detachScrollIntentListeners = undefined
|
||||||
|
}
|
||||||
|
if (!element) return
|
||||||
|
const handlePointerIntent = () => markUserScrollIntent()
|
||||||
|
const handleKeyIntent = (event: KeyboardEvent) => {
|
||||||
|
if (SCROLL_INTENT_KEYS.has(event.key)) {
|
||||||
|
markUserScrollIntent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
element.addEventListener("wheel", handlePointerIntent, { passive: true })
|
||||||
|
element.addEventListener("pointerdown", handlePointerIntent)
|
||||||
|
element.addEventListener("touchstart", handlePointerIntent, { passive: true })
|
||||||
|
element.addEventListener("keydown", handleKeyIntent)
|
||||||
|
detachScrollIntentListeners = () => {
|
||||||
|
element.removeEventListener("wheel", handlePointerIntent)
|
||||||
|
element.removeEventListener("pointerdown", handlePointerIntent)
|
||||||
|
element.removeEventListener("touchstart", handlePointerIntent)
|
||||||
|
element.removeEventListener("keydown", handleKeyIntent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setContainerRef(element: HTMLDivElement | null) {
|
||||||
|
containerRef = element || undefined
|
||||||
|
setScrollElement(containerRef)
|
||||||
|
attachScrollIntentListeners(containerRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = messageIds().length > 0
|
||||||
|
setShowScrollBottomButton(hasItems && !isNearBottom(element))
|
||||||
|
setShowScrollTopButton(hasItems && !isNearTop(element))
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleScrollPersist() {
|
||||||
|
if (pendingScrollPersist !== null) return
|
||||||
|
pendingScrollPersist = requestAnimationFrame(() => {
|
||||||
|
pendingScrollPersist = null
|
||||||
|
if (!containerRef) return
|
||||||
|
scrollCache.persist(containerRef, { atBottomOffset: 48 })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottom(immediate = false) {
|
||||||
|
if (!containerRef) return
|
||||||
|
const behavior = immediate ? "auto" : "smooth"
|
||||||
|
if (!immediate) {
|
||||||
|
suppressAutoScrollOnce = true
|
||||||
|
}
|
||||||
|
containerRef.scrollTo({ top: containerRef.scrollHeight, behavior })
|
||||||
|
setAutoScroll(true)
|
||||||
|
updateScrollIndicators(containerRef)
|
||||||
|
scheduleScrollPersist()
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToTop(immediate = false) {
|
||||||
|
if (!containerRef) return
|
||||||
|
const behavior = immediate ? "auto" : "smooth"
|
||||||
|
setAutoScroll(false)
|
||||||
|
containerRef.scrollTo({ top: 0, behavior })
|
||||||
|
updateScrollIndicators(containerRef)
|
||||||
|
scheduleScrollPersist()
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleAnchorScroll(immediate = false) {
|
||||||
|
if (!autoScroll()) return
|
||||||
|
const sentinel = bottomSentinel()
|
||||||
|
if (!sentinel) return
|
||||||
|
if (pendingAnchorScroll !== null) {
|
||||||
|
cancelAnimationFrame(pendingAnchorScroll)
|
||||||
|
pendingAnchorScroll = null
|
||||||
|
}
|
||||||
|
pendingAnchorScroll = requestAnimationFrame(() => {
|
||||||
|
pendingAnchorScroll = null
|
||||||
|
sentinel.scrollIntoView({ block: "end", inline: "nearest", behavior: immediate ? "auto" : "auto" })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleContentRendered() {
|
||||||
|
scheduleAnchorScroll()
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (props.registerScrollToBottom) {
|
||||||
|
props.registerScrollToBottom(() => scrollToBottom(true))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleScroll() {
|
||||||
|
if (!containerRef) return
|
||||||
|
if (pendingScrollFrame !== null) {
|
||||||
|
cancelAnimationFrame(pendingScrollFrame)
|
||||||
|
}
|
||||||
|
const isUserScroll = hasUserScrollIntent()
|
||||||
|
pendingScrollFrame = requestAnimationFrame(() => {
|
||||||
|
pendingScrollFrame = null
|
||||||
|
if (!containerRef) return
|
||||||
|
const atBottom = isNearBottom(containerRef)
|
||||||
|
|
||||||
|
if (isUserScroll) {
|
||||||
|
if (atBottom) {
|
||||||
|
if (!autoScroll()) setAutoScroll(true)
|
||||||
|
} else if (autoScroll()) {
|
||||||
|
setAutoScroll(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateScrollIndicators(containerRef)
|
||||||
|
scheduleScrollPersist()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const target = containerRef
|
||||||
|
const loading = props.loading
|
||||||
|
if (!target || loading || hasRestoredScroll) return
|
||||||
|
|
||||||
|
scrollCache.restore(target, {
|
||||||
|
onApplied: (snapshot) => {
|
||||||
|
if (snapshot) {
|
||||||
|
setAutoScroll(snapshot.atBottom)
|
||||||
|
} else {
|
||||||
|
setAutoScroll(isNearBottom(target))
|
||||||
|
}
|
||||||
|
updateScrollIndicators(target)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
hasRestoredScroll = true
|
||||||
|
})
|
||||||
|
|
||||||
|
let previousToken: string | undefined
|
||||||
|
createEffect(() => {
|
||||||
|
const token = changeToken()
|
||||||
|
const loading = props.loading
|
||||||
|
if (loading || !token || token === previousToken) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
previousToken = token
|
||||||
|
if (suppressAutoScrollOnce) {
|
||||||
|
suppressAutoScrollOnce = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (autoScroll()) {
|
||||||
|
scheduleAnchorScroll(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
preferenceSignature()
|
||||||
|
if (props.loading || !autoScroll()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (suppressAutoScrollOnce) {
|
||||||
|
suppressAutoScrollOnce = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scheduleAnchorScroll(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (messageIds().length === 0) {
|
||||||
|
setShowScrollTopButton(false)
|
||||||
|
setShowScrollBottomButton(false)
|
||||||
|
setAutoScroll(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (bottomSentinel()) {
|
||||||
|
scheduleAnchorScroll(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (pendingScrollFrame !== null) {
|
||||||
|
cancelAnimationFrame(pendingScrollFrame)
|
||||||
|
}
|
||||||
|
if (pendingScrollPersist !== null) {
|
||||||
|
cancelAnimationFrame(pendingScrollPersist)
|
||||||
|
}
|
||||||
|
if (pendingAnchorScroll !== null) {
|
||||||
|
cancelAnimationFrame(pendingAnchorScroll)
|
||||||
|
}
|
||||||
|
if (detachScrollIntentListeners) {
|
||||||
|
detachScrollIntentListeners()
|
||||||
|
}
|
||||||
|
if (containerRef) {
|
||||||
|
scrollCache.persist(containerRef, { atBottomOffset: 48 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="message-stream-container">
|
||||||
|
<MessageListHeader
|
||||||
|
usedTokens={tokenStats().used}
|
||||||
|
availableTokens={tokenStats().avail}
|
||||||
|
connectionStatus={connectionStatus()}
|
||||||
|
onCommandPalette={handleCommandPaletteClick}
|
||||||
|
formatTokens={formatTokens}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="message-stream" ref={setContainerRef} onScroll={handleScroll}>
|
||||||
|
<Show when={!props.loading && messageIds().length === 0}>
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-content">
|
||||||
|
<div class="flex flex-col items-center gap-3 mb-6">
|
||||||
|
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-48 w-auto" loading="lazy" />
|
||||||
|
<h1 class="text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||||
|
</div>
|
||||||
|
<h3>Start a conversation</h3>
|
||||||
|
<p>Type a message below or open the Command Palette:</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<span>Command Palette</span>
|
||||||
|
<Kbd shortcut="cmd+shift+p" class="ml-2" />
|
||||||
|
</li>
|
||||||
|
<li>Ask about your codebase</li>
|
||||||
|
<li>
|
||||||
|
Attach files with <code>@</code>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={props.loading}>
|
||||||
|
<div class="loading-state">
|
||||||
|
<div class="spinner" />
|
||||||
|
<p>Loading messages...</p>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<MessageBlockList
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
sessionId={props.sessionId}
|
||||||
|
store={store}
|
||||||
|
messageIds={messageIds}
|
||||||
|
messageIndexMap={messageIndexMap}
|
||||||
|
lastAssistantIndex={lastAssistantIndex}
|
||||||
|
showThinking={() => preferences().showThinkingBlocks}
|
||||||
|
thinkingDefaultExpanded={() => (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded"}
|
||||||
|
showUsageMetrics={showUsagePreference}
|
||||||
|
scrollContainer={scrollElement}
|
||||||
|
loading={props.loading}
|
||||||
|
onRevert={props.onRevert}
|
||||||
|
onFork={props.onFork}
|
||||||
|
onContentRendered={handleContentRendered}
|
||||||
|
setBottomSentinel={setBottomSentinel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={showScrollTopButton() || showScrollBottomButton()}>
|
||||||
|
<div class="message-scroll-button-wrapper">
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { Show, createMemo, createEffect, type Component } from "solid-js"
|
|||||||
import type { Session } from "../../types/session"
|
import type { Session } from "../../types/session"
|
||||||
import type { Attachment } from "../../types/attachment"
|
import type { Attachment } from "../../types/attachment"
|
||||||
import type { ClientPart } from "../../types/message"
|
import type { ClientPart } from "../../types/message"
|
||||||
import MessageStreamV2 from "../message-stream-v2"
|
import MessageSection from "../message-section"
|
||||||
import { messageStoreBus } from "../../stores/message-v2/bus"
|
import { messageStoreBus } from "../../stores/message-v2/bus"
|
||||||
import PromptInput from "../prompt-input"
|
import PromptInput from "../prompt-input"
|
||||||
import { instances } from "../../stores/instances"
|
import { instances } from "../../stores/instances"
|
||||||
@@ -141,7 +141,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
if (!activeSession) return null
|
if (!activeSession) return null
|
||||||
return (
|
return (
|
||||||
<div class="session-view">
|
<div class="session-view">
|
||||||
<MessageStreamV2
|
<MessageSection
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={activeSession.id}
|
sessionId={activeSession.id}
|
||||||
loading={messagesLoading()}
|
loading={messagesLoading()}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@import "./messaging/message-base.css";
|
@import "./messaging/message-base.css";
|
||||||
@import "./messaging/prompt-input.css";
|
@import "./messaging/prompt-input.css";
|
||||||
@import "./messaging/message-stream.css";
|
@import "./messaging/message-section.css";
|
||||||
|
@import "./messaging/message-block-list.css";
|
||||||
@import "./messaging/tool-call.css";
|
@import "./messaging/tool-call.css";
|
||||||
@import "./messaging/log-view.css";
|
@import "./messaging/log-view.css";
|
||||||
|
|
||||||
@@ -51,61 +52,6 @@
|
|||||||
animation: pulse 1.5s ease-in-out infinite;
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Message stream component utilities */
|
|
||||||
.message-stream-container {
|
|
||||||
@apply relative flex-1 min-h-0 flex flex-col overflow-hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.connection-status {
|
|
||||||
@apply grid items-center px-4 py-2 gap-4;
|
|
||||||
grid-template-columns: 1fr auto 1fr;
|
|
||||||
background-color: var(--surface-secondary);
|
|
||||||
border-bottom: 1px solid var(--border-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-stream {
|
|
||||||
@apply flex-1 min-h-0 overflow-y-auto flex flex-col gap-1;
|
|
||||||
background-color: var(--surface-base);
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-scroll-button-wrapper {
|
|
||||||
position: absolute;
|
|
||||||
right: 1rem;
|
|
||||||
bottom: 1rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-scroll-button {
|
|
||||||
@apply inline-flex items-center justify-center;
|
|
||||||
width: 2.75rem;
|
|
||||||
height: 2.75rem;
|
|
||||||
border-radius: 9999px;
|
|
||||||
border: 1px solid var(--border-base);
|
|
||||||
background-color: transparent;
|
|
||||||
color: var(--text-primary);
|
|
||||||
box-shadow: var(--scroll-elevation-shadow);
|
|
||||||
transition: background-color 0.2s ease, color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-scroll-button:hover {
|
|
||||||
background-color: var(--surface-hover);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-scroll-button:focus-visible {
|
|
||||||
outline: none;
|
|
||||||
box-shadow: 0 0 0 2px var(--surface-base), 0 0 0 4px var(--accent-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-scroll-icon {
|
|
||||||
font-size: var(--font-size-lg);
|
|
||||||
color: var(--accent-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-text {
|
.message-text {
|
||||||
font-size: var(--font-size-base);
|
font-size: var(--font-size-base);
|
||||||
line-height: var(--line-height-normal);
|
line-height: var(--line-height-normal);
|
||||||
|
|||||||
35
packages/ui/src/styles/messaging/message-block-list.css
Normal file
35
packages/ui/src/styles/messaging/message-block-list.css
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
.message-stream {
|
||||||
|
@apply flex-1 min-h-0 overflow-y-auto flex flex-col gap-0.5;
|
||||||
|
background-color: var(--surface-base);
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-stream-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.0625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.virtual-item-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.virtual-item-placeholder,
|
||||||
|
.message-stream-placeholder {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.virtual-item-content {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.virtual-item-content-hidden {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
/* Message stream container + status */
|
|
||||||
.message-stream-container {
|
.message-stream-container {
|
||||||
@apply relative flex-1 min-h-0 flex flex-col overflow-hidden;
|
@apply relative flex-1 min-h-0 flex flex-col overflow-hidden;
|
||||||
}
|
}
|
||||||
@@ -64,18 +63,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-stream {
|
|
||||||
@apply flex-1 min-h-0 overflow-y-auto flex flex-col gap-0.5;
|
|
||||||
background-color: var(--surface-base);
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-stream-block {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.0625rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-scroll-button-wrapper {
|
.message-scroll-button-wrapper {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 1rem;
|
right: 1rem;
|
||||||
@@ -112,27 +99,3 @@
|
|||||||
font-size: var(--font-size-lg);
|
font-size: var(--font-size-lg);
|
||||||
color: var(--accent-primary);
|
color: var(--accent-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.virtual-item-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.virtual-item-placeholder,
|
|
||||||
.message-stream-placeholder {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
position: relative;
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.virtual-item-content {
|
|
||||||
width: 100%;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.virtual-item-content-hidden {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user