Compare commits

..

5 Commits

Author SHA1 Message Date
Pascal André
1907a4da03 perf(ui): virtualize message timeline rendering, #274 follow-up ( BIG SPEED IMPROVEMENT ) (#291)
## Summary
- virtualize MessageTimeline so large session histories stop rendering
the full timeline sidebar at once.
- keep the existing full render path in selection mode so xray/selection
behavior stays intact.
- route active-segment scrolling through the virtualizer so timeline
navigation still follows the selected message.

## Benefit
- prompt field was very laggy in cession with big history and timeline
had many bugs, this is fixed.
- the session with big history now load as fast as a new session .
2026-04-11 22:52:00 +01:00
Shantur Rathore
abf4c67fcc fix(ui): separate dictated prompt text 2026-04-11 20:34:53 +01:00
Shantur Rathore
bc130ceb5b fix(ui): portal timeline preview tooltip 2026-04-11 19:53:25 +01:00
Shantur Rathore
8505a43b16 fix(ui): add toggle for holding long assistant replies 2026-04-11 19:47:57 +01:00
Shantur Rathore
2a3329b5ed fix(ui): hold auto-follow on oversized assistant replies 2026-04-11 19:28:27 +01:00
15 changed files with 618 additions and 179 deletions

View File

@@ -1,5 +1,5 @@
import { Show, createEffect, createMemo, createSignal, onCleanup, on, untrack } from "solid-js" import { Show, createEffect, createMemo, createSignal, onCleanup, on, untrack } from "solid-js"
import { MoreHorizontal, Trash, X } from "lucide-solid" import { MoreHorizontal, Pause, Trash, X } from "lucide-solid"
import Kbd from "./kbd" import Kbd from "./kbd"
import MessageBlock from "./message-block" import MessageBlock from "./message-block"
import { getMessageAnchorId, getMessageIdFromAnchorId } from "./message-anchors" import { getMessageAnchorId, getMessageIdFromAnchorId } from "./message-anchors"
@@ -16,12 +16,14 @@ import { showAlertDialog } from "../stores/alerts"
import { deleteMessage, deleteMessagePart } from "../stores/session-actions" import { deleteMessage, deleteMessagePart } from "../stores/session-actions"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
import type { DeleteHoverState } from "../types/delete-hover" import type { DeleteHoverState } from "../types/delete-hover"
import { partHasRenderableText } from "../types/message"
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache" import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
import { getPartCharCount } from "../lib/token-utils" import { getPartCharCount } from "../lib/token-utils"
const SCROLL_SENTINEL_MARGIN_PX = 8 const SCROLL_SENTINEL_MARGIN_PX = 8
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream" const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
const QUOTE_SELECTION_MAX_LENGTH = 2000 const QUOTE_SELECTION_MAX_LENGTH = 2000
const STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX = 8
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
export interface MessageSectionProps { export interface MessageSectionProps {
@@ -40,10 +42,11 @@ export interface MessageSectionProps {
} }
export default function MessageSection(props: MessageSectionProps) { export default function MessageSection(props: MessageSectionProps) {
const { preferences } = useConfig() const { preferences, updatePreferences } = useConfig()
const { t } = useI18n() const { t } = useI18n()
const showUsagePreference = () => preferences().showUsageMetrics ?? true const showUsagePreference = () => preferences().showUsageMetrics ?? true
const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true
const holdLongAssistantRepliesEnabled = () => preferences().holdLongAssistantReplies ?? true
const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId)) const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId))
const messageIds = createMemo(() => store().getSessionMessageIds(props.sessionId)) const messageIds = createMemo(() => store().getSessionMessageIds(props.sessionId))
const visibleMessageIds = createMemo(() => { const visibleMessageIds = createMemo(() => {
@@ -594,7 +597,10 @@ export default function MessageSection(props: MessageSectionProps) {
const [streamElement, setStreamElement] = createSignal<HTMLDivElement | undefined>() const [streamElement, setStreamElement] = createSignal<HTMLDivElement | undefined>()
const [streamShellElement, setStreamShellElement] = createSignal<HTMLDivElement | undefined>() const [streamShellElement, setStreamShellElement] = createSignal<HTMLDivElement | undefined>()
const followToken = createMemo(() => `${sessionRevision()}|${preferenceSignature()}`) // Only preferences should force a follow-token re-anchor. Message/session
// revision churn at the end of a turn (message.updated, session.idle, etc.)
// should not trigger an immediate scroll-to-bottom.
const followToken = createMemo(() => preferenceSignature())
const initialScrollSnapshot = createMemo(() => store().getScrollSnapshot(props.sessionId, MESSAGE_SCROLL_CACHE_SCOPE)) const initialScrollSnapshot = createMemo(() => store().getScrollSnapshot(props.sessionId, MESSAGE_SCROLL_CACHE_SCOPE))
const initialAutoScroll = createMemo(() => initialScrollSnapshot()?.atBottom ?? true) const initialAutoScroll = createMemo(() => initialScrollSnapshot()?.atBottom ?? true)
@@ -624,6 +630,35 @@ export default function MessageSection(props: MessageSectionProps) {
const [quoteSelection, setQuoteSelection] = createSignal<{ text: string; top: number; left: number } | null>(null) const [quoteSelection, setQuoteSelection] = createSignal<{ text: string; top: number; left: number } | null>(null)
const lastVisibleMessageId = createMemo(() => {
const ids = visibleMessageIds()
return ids[ids.length - 1] ?? null
})
const autoPinHoldTargetKey = createMemo(() => {
if (!holdLongAssistantRepliesEnabled()) return null
const messageId = lastVisibleMessageId()
return isAssistantTextMessage(messageId) ? messageId : null
})
function toggleHoldLongAssistantReplies() {
updatePreferences({ holdLongAssistantReplies: !holdLongAssistantRepliesEnabled() })
}
function isAssistantTextMessage(messageId: string | null | undefined) {
if (!messageId) return false
const resolvedStore = store()
const record = resolvedStore.getMessage(messageId)
if (!record || record.role !== "assistant") return false
const { orderedParts } = buildRecordDisplayData(props.instanceId, record)
return orderedParts.some((part) => {
if ((part as any)?.type !== "text") return false
if (partHasRenderableText(part)) return true
return typeof (part as { text?: unknown }).text === "string"
})
}
createEffect(() => { createEffect(() => {
const api = listApi() const api = listApi()
if (!api) return if (!api) return
@@ -1044,6 +1079,12 @@ export default function MessageSection(props: MessageSectionProps) {
initialAutoScroll={initialAutoScroll} initialAutoScroll={initialAutoScroll}
resetKey={() => props.sessionId} resetKey={() => props.sessionId}
followToken={followToken} followToken={followToken}
autoPinHoldTargetKey={autoPinHoldTargetKey}
autoPinHoldTopThresholdPx={STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX}
resolveAutoPinHoldElement={(itemWrapper, key) => {
const candidates = Array.from(itemWrapper.querySelectorAll<HTMLElement>(`.message-item-base[data-message-id="${key}"][data-message-role="assistant"]`))
return candidates[candidates.length - 1] ?? null
}}
onScroll={() => { onScroll={() => {
clearQuoteSelection() clearQuoteSelection()
scrollCache.persist(streamElement()) scrollCache.persist(streamElement())
@@ -1074,6 +1115,52 @@ export default function MessageSection(props: MessageSectionProps) {
scrollToBottomAriaLabel={() => t("messageSection.scroll.toLatestAriaLabel")} scrollToBottomAriaLabel={() => t("messageSection.scroll.toLatestAriaLabel")}
registerApi={(api) => setListApi(api)} registerApi={(api) => setListApi(api)}
registerState={(state) => setListState(state)} registerState={(state) => setListState(state)}
renderControls={(state, api) => (
<div class="message-scroll-button-wrapper">
<button
type="button"
class="message-scroll-button"
data-active={holdLongAssistantRepliesEnabled() ? "true" : "false"}
onClick={toggleHoldLongAssistantReplies}
aria-label={
holdLongAssistantRepliesEnabled()
? t("messageSection.scroll.disableHoldAriaLabel")
: t("messageSection.scroll.enableHoldAriaLabel")
}
title={
holdLongAssistantRepliesEnabled()
? t("messageSection.scroll.disableHoldAriaLabel")
: t("messageSection.scroll.enableHoldAriaLabel")
}
>
<Pause class="message-scroll-icon message-scroll-icon--toggle w-4 h-4" aria-hidden="true" />
</button>
<Show when={state.showScrollTopButton()}>
<button
type="button"
class="message-scroll-button"
onClick={() => api.scrollToTop()}
aria-label={t("messageSection.scroll.toFirstAriaLabel")}
>
<span class="message-scroll-icon" aria-hidden="true">
</span>
</button>
</Show>
<Show when={state.showScrollBottomButton()}>
<button
type="button"
class="message-scroll-button"
onClick={() => api.scrollToBottom()}
aria-label={t("messageSection.scroll.toLatestAriaLabel")}
>
<span class="message-scroll-icon" aria-hidden="true">
</span>
</button>
</Show>
</div>
)}
renderBeforeItems={() => ( renderBeforeItems={() => (
<> <>
<Show when={!props.loading && visibleMessageIds().length === 0}> <Show when={!props.loading && visibleMessageIds().length === 0}>

View File

@@ -1,4 +1,6 @@
import { For, Show, createEffect, createMemo, createSignal, onCleanup, on, untrack, type Component, type Accessor } from "solid-js" import { For, Show, createEffect, createMemo, createSignal, onCleanup, on, untrack, type Component, type Accessor } from "solid-js"
import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
import { Portal } from "solid-js/web"
import MessagePreview from "./message-preview" import MessagePreview from "./message-preview"
import { messageStoreBus } from "../stores/message-v2/bus" import { messageStoreBus } from "../stores/message-v2/bus"
import type { ClientPart } from "../types/message" import type { ClientPart } from "../types/message"
@@ -54,6 +56,7 @@ const MAX_TOOLTIP_LENGTH = 220
const LONG_PRESS_MS = 500 const LONG_PRESS_MS = 500
const JITTER_THRESHOLD = 10 const JITTER_THRESHOLD = 10
const ABSOLUTE_TOKEN_CAP = 10000 const ABSOLUTE_TOKEN_CAP = 10000
const TIMELINE_VIRTUALIZER_BUFFER_PX = 240
type ToolCallPart = Extract<ClientPart, { type: "tool" }> type ToolCallPart = Extract<ClientPart, { type: "tool" }>
@@ -66,6 +69,13 @@ interface PendingSegment {
hasPrimaryText: boolean hasPrimaryText: boolean
} }
interface TimelineSegmentState {
deleteHovered: boolean
deleteSelected: boolean
hasActivePermission: boolean
hidden: boolean
}
function truncateText(value: string): string { function truncateText(value: string): string {
if (value.length <= MAX_TOOLTIP_LENGTH) { if (value.length <= MAX_TOOLTIP_LENGTH) {
return value return value
@@ -351,6 +361,13 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
} }
} }
const clearHoverPreview = () => {
clearHoverTimer()
clearCloseTimer()
setHoveredSegment(null)
setHoverAnchorRect(null)
}
const scheduleClose = () => { const scheduleClose = () => {
if (typeof window === "undefined") return if (typeof window === "undefined") return
clearHoverTimer() clearHoverTimer()
@@ -358,8 +375,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
// Small delay so the pointer can travel from the segment to the tooltip. // Small delay so the pointer can travel from the segment to the tooltip.
closeTimer = window.setTimeout(() => { closeTimer = window.setTimeout(() => {
closeTimer = null closeTimer = null
setHoveredSegment(null) clearHoverPreview()
setHoverAnchorRect(null)
}, 160) }, 160)
} }
@@ -399,8 +415,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
}) })
onCleanup(() => { onCleanup(() => {
clearHoverTimer() clearHoverPreview()
clearCloseTimer()
}) })
// --- Selection & histogram rib state --- // --- Selection & histogram rib state ---
@@ -418,6 +433,8 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
// on activation, resize, or expansion — NOT on every scroll frame. // on activation, resize, or expansion — NOT on every scroll frame.
const [badgeOffsets, setBadgeOffsets] = createSignal<Record<string, { layoutTop: number; height: number }>>({}) const [badgeOffsets, setBadgeOffsets] = createSignal<Record<string, { layoutTop: number; height: number }>>({})
const [windowWidth, setWindowWidth] = createSignal(typeof window !== "undefined" ? window.innerWidth : 1200) const [windowWidth, setWindowWidth] = createSignal(typeof window !== "undefined" ? window.innerWidth : 1200)
const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
const [virtualizerHandle, setVirtualizerHandle] = createSignal<VirtualizerHandle | undefined>()
let scrollContainerRef: HTMLDivElement | undefined let scrollContainerRef: HTMLDivElement | undefined
let xrayOverlayRef: HTMLDivElement | undefined let xrayOverlayRef: HTMLDivElement | undefined
@@ -449,6 +466,12 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
} }
const handleScroll = () => { const handleScroll = () => {
if (renderVirtualizedTimeline()) {
if (hoveredSegment()) {
clearHoverPreview()
}
return
}
if (!isSelectionActive()) return if (!isSelectionActive()) return
if (!scrollContainerRef || !xrayOverlayRef) return if (!scrollContainerRef || !xrayOverlayRef) return
xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollContainerRef.scrollTop}px`) xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollContainerRef.scrollTop}px`)
@@ -477,6 +500,12 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
} }
}) })
const renderVirtualizedTimeline = createMemo(() => !isSelectionActive())
createEffect(on(renderVirtualizedTimeline, () => {
clearHoverPreview()
}))
const maxRibWidth = createMemo(() => Math.round(windowWidth() * 0.5)) const maxRibWidth = createMemo(() => Math.round(windowWidth() * 0.5))
// Compute fresh char counts from the store. segment.totalChars can be stale for // Compute fresh char counts from the store. segment.totalChars can be stale for
@@ -579,7 +608,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
wasLongPress = true wasLongPress = true
// Scroll anchoring: preserve visual position of the pressed badge. // Scroll anchoring: preserve visual position of the pressed badge.
const btn = buttonRefs.get(segment.id) const btn = renderVirtualizedTimeline() ? null : buttonRefs.get(segment.id)
let anchorOffset: number | null = null let anchorOffset: number | null = null
if (btn && scrollContainerRef) { if (btn && scrollContainerRef) {
anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop
@@ -631,9 +660,17 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
createEffect(on(() => props.activeSegmentId, (activeId) => { createEffect(on(() => props.activeSegmentId, (activeId) => {
if (!activeId) return if (!activeId) return
const element = buttonRefs.get(activeId)
if (!element) return
const timer = typeof window !== "undefined" ? window.setTimeout(() => { const timer = typeof window !== "undefined" ? window.setTimeout(() => {
if (renderVirtualizedTimeline()) {
const index = segmentIndexById().get(activeId)
if (index !== undefined) {
virtualizerHandle()?.scrollToIndex(index, { align: "nearest", smooth: true })
}
return
}
const element = buttonRefs.get(activeId)
if (!element) return
element.scrollIntoView({ block: "nearest", behavior: "smooth" }) element.scrollIntoView({ block: "nearest", behavior: "smooth" })
}, 120) : null }, 120) : null
onCleanup(() => { onCleanup(() => {
@@ -684,60 +721,239 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
return map return map
}) })
const segmentIndexById = createMemo(() => {
const map = new Map<string, number>()
for (let i = 0; i < props.segments.length; i++) map.set(props.segments[i].id, i)
return map
})
const segmentStates = createMemo(() => {
const hover = deleteHover()
const selectedMessages = props.selectedMessageIds?.()
const expandedMessages = props.expandedMessageIds?.()
const resolvedStore = store()
const indexMap = messageIdToSessionIndex()
const selectionActive = isSelectionActive()
const result = new Map<string, TimelineSegmentState>()
for (const segment of props.segments) {
let deleteHovered = false
if (hover.kind === "message") {
deleteHovered = hover.messageId === segment.messageId
} else if (hover.kind === "deleteUpTo") {
const targetIndex = indexMap.get(hover.messageId)
const segmentIndex = indexMap.get(segment.messageId)
deleteHovered = targetIndex !== undefined && segmentIndex !== undefined && segmentIndex >= targetIndex
}
const deleteSelected = selectedMessages?.has(segment.messageId) ?? false
let hasActivePermission = false
if (segment.type === "tool") {
const partIds = segment.toolPartIds ?? []
for (const partId of partIds) {
const permissionState = resolvedStore.getPermissionState(segment.messageId, partId)
if (permissionState?.active) {
hasActivePermission = true
break
}
}
}
const hidden = segment.type === "tool" && !(
showTools()
|| expandedMessages?.has(segment.messageId)
|| selectionActive
|| props.activeSegmentId === segment.id
|| hasActivePermission
|| deleteHovered
|| deleteSelected
)
result.set(segment.id, {
deleteHovered,
deleteSelected,
hasActivePermission,
hidden,
})
}
return result
})
const segmentStateFor = (segmentId: string): TimelineSegmentState => {
return segmentStates().get(segmentId) ?? {
deleteHovered: false,
deleteSelected: false,
hasActivePermission: false,
hidden: false,
}
}
const segmentSpacerHeights = createMemo(() => {
const states = segmentStates()
const result = new Map<string, string>()
let previousVisible: TimelineSegment | null = null
for (let index = 0; index < props.segments.length; index += 1) {
const segment = props.segments[index]
const state = states.get(segment.id)
if (state?.hidden) {
result.set(segment.id, "0")
continue
}
if (!previousVisible) {
result.set(segment.id, "0")
previousVisible = segment
continue
}
const previousRaw = index > 0 ? props.segments[index - 1] : null
const startsVisibleToolGroup = segment.type === "tool"
&& (previousVisible.type !== "tool" || previousVisible.messageId !== segment.messageId)
const startsCollapsedToolGroup = segment.type === "assistant"
&& previousVisible.messageId !== segment.messageId
&& messagesWithTools().has(segment.messageId)
&& previousRaw?.type === "tool"
&& previousRaw.messageId === segment.messageId
const followsVisibleGroupParent = (segment.type === "user" || segment.type === "compaction")
&& previousVisible.type === "assistant"
&& messagesWithTools().has(previousVisible.messageId)
const gapUnits = 1 + (startsVisibleToolGroup || startsCollapsedToolGroup || followsVisibleGroupParent ? 1 : 0)
result.set(
segment.id,
gapUnits === 1
? "var(--message-timeline-segment-gap)"
: "calc(var(--message-timeline-segment-gap) * 2)",
)
previousVisible = segment
}
return result
})
return ( return (
<div class="message-timeline-container"> <div class="message-timeline-container">
<div <div
ref={scrollContainerRef} ref={(element) => {
scrollContainerRef = element
setScrollElement(element)
}}
class={`message-timeline${isSelectionActive() ? " message-timeline--selection-active" : ""}`} class={`message-timeline${isSelectionActive() ? " message-timeline--selection-active" : ""}`}
role="navigation" role="navigation"
aria-label={t("messageTimeline.ariaLabel")} aria-label={t("messageTimeline.ariaLabel")}
onScroll={handleScroll} onScroll={handleScroll}
> >
<For each={props.segments}> <Show
{(segment, segIndex) => { when={renderVirtualizedTimeline()}
onCleanup(() => buttonRefs.delete(segment.id)) fallback={(
<For each={props.segments}>
{(segment, segIndex) => {
onCleanup(() => buttonRefs.delete(segment.id))
const isActive = () => props.activeSegmentId === segment.id
const isSelected = () => props.selectedIds?.().has(segment.id)
const state = () => segmentStateFor(segment.id)
const isDeleteHovered = () => state().deleteHovered
const isDeleteSelected = () => state().deleteSelected
const hasActivePermission = () => state().hasActivePermission
const isHidden = () => state().hidden
const groupRole = (): "child" | "parent" | "none" => {
if (segment.type === "tool") return "child"
if (segment.type === "assistant" && messagesWithTools().has(segment.messageId)) return "parent"
return "none"
}
const shortLabelContent = () => {
if (segment.type === "tool") {
if (hasActivePermission()) {
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
}
return segment.shortLabel ?? getToolIcon("tool")
}
if (segment.type === "compaction") {
return <FoldVertical class="message-timeline-icon" aria-hidden="true" />
}
if (segment.type === "user") {
return <UserIcon class="message-timeline-icon" aria-hidden="true" />
}
return <BotIcon class="message-timeline-icon" aria-hidden="true" />
}
return (
<div class="message-timeline-item">
<div aria-hidden="true" class="message-timeline-item-spacer" style={{ height: segmentSpacerHeights().get(segment.id) ?? "0" }} />
<button
ref={(el) => registerButtonRef(segment.id, el)}
type="button"
data-variant={segment.variant}
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""} ${isSelected() ? "message-timeline-segment-selected" : ""} ${isDeleteSelected() ? "message-timeline-segment-delete-selected" : ""} ${groupRole() !== "none" ? `message-timeline-group-${groupRole()}` : ""}`}
data-delete-hover={isDeleteHovered() || isDeleteSelected() || isSelected() ? "true" : undefined}
aria-current={isActive() ? "true" : undefined}
aria-hidden={isHidden() ? "true" : undefined}
onClick={(event) => {
if (wasLongPress) {
wasLongPress = false
return
}
const btn = buttonRefs.get(segment.id)
const stableBtn = renderVirtualizedTimeline() ? null : btn
let anchorOffset: number | null = null
if (stableBtn && scrollContainerRef) {
anchorOffset = stableBtn.offsetTop - scrollContainerRef.scrollTop
}
const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0
if (event.shiftKey) {
props.onSelectRange?.(segment.id)
} else if (event.ctrlKey || event.metaKey) {
props.onToggleSelection?.(segment.id)
} else if (isMultiSelectActive) {
props.onSegmentClick?.(segment)
} else {
props.onSegmentClick?.(segment)
}
if (anchorOffset !== null && stableBtn && scrollContainerRef) {
const desired = stableBtn.offsetTop - anchorOffset
if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
scrollContainerRef.scrollTop = desired
}
}
}}
onPointerDown={(e) => handlePointerDown(segment, e)}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onPointerMove={handlePointerMove}
onContextMenu={handleContextMenu}
onMouseEnter={(event) => handleMouseEnter(segment, event)}
onMouseLeave={handleMouseLeave}
>
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span>
</button>
</div>
)
}}
</For>
)}
>
<Virtualizer ref={setVirtualizerHandle} data={props.segments} scrollRef={scrollElement()} bufferSize={TIMELINE_VIRTUALIZER_BUFFER_PX}>
{(segment, index) => {
const segIndex = () => index()
const isActive = () => props.activeSegmentId === segment.id const isActive = () => props.activeSegmentId === segment.id
const isSelected = () => props.selectedIds?.().has(segment.id) const isSelected = () => props.selectedIds?.().has(segment.id)
const state = () => segmentStateFor(segment.id)
const isDeleteHovered = () => { const isDeleteHovered = () => state().deleteHovered
const hover = deleteHover() as DeleteHoverState const isDeleteSelected = () => state().deleteSelected
if (hover.kind === "message") { const hasActivePermission = () => state().hasActivePermission
return hover.messageId === segment.messageId const isHidden = () => state().hidden
}
if (hover.kind === "deleteUpTo") {
const indexMap = messageIdToSessionIndex()
const targetIndex = indexMap.get(hover.messageId)
if (targetIndex === undefined) return false
const segmentIndex = indexMap.get(segment.messageId)
if (segmentIndex === undefined) return false
return segmentIndex >= targetIndex
}
return false
}
const isDeleteSelected = () => {
const selected = props.selectedMessageIds?.()
if (!selected) return false
return selected.has(segment.messageId)
}
const hasActivePermission = () => {
if (segment.type !== "tool") return false
const partIds = segment.toolPartIds ?? []
if (partIds.length === 0) return false
for (const partId of partIds) {
const permissionState = store().getPermissionState(segment.messageId, partId)
if (permissionState?.active) return true
}
return false
}
const isExpanded = () => props.expandedMessageIds?.().has(segment.messageId) ?? false
const isHidden = () =>
segment.type === "tool" &&
!(showTools() || isExpanded() || isSelectionActive() || isActive() || hasActivePermission() || isDeleteHovered() || isDeleteSelected())
// Group visual indicators: tools belong to the same message as their // Group visual indicators: tools belong to the same message as their
// assistant. Uses messageId for correctness (not positional adjacency). // assistant. Uses messageId for correctness (not positional adjacency).
@@ -746,18 +962,10 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
if (segment.type === "assistant" && messagesWithTools().has(segment.messageId)) return "parent" if (segment.type === "assistant" && messagesWithTools().has(segment.messageId)) return "parent"
return "none" return "none"
} }
const isGroupStart = () => {
if (segment.type !== "tool") return false
const idx = segIndex()
const prev = idx > 0 ? props.segments[idx - 1] : null
// First tool in the message's run: either nothing before, or previous
// segment is from a different message or is not a tool.
return !prev || prev.type !== "tool" || prev.messageId !== segment.messageId
}
const shortLabelContent = () => { const shortLabelContent = () => {
if (segment.type === "tool") { if (segment.type === "tool") {
if (hasActivePermission()) { if (hasActivePermission()) {
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" /> return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
} }
return segment.shortLabel ?? getToolIcon("tool") return segment.shortLabel ?? getToolIcon("tool")
@@ -767,95 +975,92 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
} }
if (segment.type === "user") { if (segment.type === "user") {
return <UserIcon class="message-timeline-icon" aria-hidden="true" /> return <UserIcon class="message-timeline-icon" aria-hidden="true" />
} }
return <BotIcon class="message-timeline-icon" aria-hidden="true" /> return <BotIcon class="message-timeline-icon" aria-hidden="true" />
} }
return ( return (
<button <div class="message-timeline-item">
ref={(el) => registerButtonRef(segment.id, el)} <div aria-hidden="true" class="message-timeline-item-spacer" style={{ height: segmentSpacerHeights().get(segment.id) ?? "0" }} />
type="button" <button
data-variant={segment.variant} type="button"
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""} ${isSelected() ? "message-timeline-segment-selected" : ""} ${isDeleteSelected() ? "message-timeline-segment-delete-selected" : ""} ${groupRole() !== "none" ? `message-timeline-group-${groupRole()}` : ""} ${isGroupStart() ? "message-timeline-group-start" : ""}`} data-variant={segment.variant}
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""} ${isSelected() ? "message-timeline-segment-selected" : ""} ${isDeleteSelected() ? "message-timeline-segment-delete-selected" : ""} ${groupRole() !== "none" ? `message-timeline-group-${groupRole()}` : ""}`}
data-delete-hover={isDeleteHovered() || isDeleteSelected() || isSelected() ? "true" : undefined} data-delete-hover={isDeleteHovered() || isDeleteSelected() || isSelected() ? "true" : undefined}
aria-current={isActive() ? "true" : undefined}
aria-current={isActive() ? "true" : undefined} aria-hidden={isHidden() ? "true" : undefined}
aria-hidden={isHidden() ? "true" : undefined} onClick={(event) => {
onClick={(event) => { if (wasLongPress) {
if (wasLongPress) { wasLongPress = false
wasLongPress = false return
return
}
// Capture scroll anchor before selection changes may toggle
// tool segment visibility, which shifts timeline layout.
const btn = buttonRefs.get(segment.id)
let anchorOffset: number | null = null
if (btn && scrollContainerRef) {
anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop
}
const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0
if (event.shiftKey) {
props.onSelectRange?.(segment.id)
} else if (event.ctrlKey || event.metaKey) {
props.onToggleSelection?.(segment.id)
} else if (isMultiSelectActive) {
// In selection mode, plain click scrolls to the message
// instead of clearing. Selection is cleared by clicking
// anywhere inside the chat container or pressing Esc.
props.onSegmentClick?.(segment)
} else {
props.onSegmentClick?.(segment)
}
// Restore scroll anchor: keep the clicked badge at the same
// visual position after hidden tools appear or disappear.
if (anchorOffset !== null && btn && scrollContainerRef) {
const desired = btn.offsetTop - anchorOffset
if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
scrollContainerRef.scrollTop = desired
} }
}
}} const btn = buttonRefs.get(segment.id)
onPointerDown={(e) => handlePointerDown(segment, e)} const stableBtn = renderVirtualizedTimeline() ? null : btn
onPointerUp={handlePointerUp} let anchorOffset: number | null = null
onPointerCancel={handlePointerUp} if (stableBtn && scrollContainerRef) {
onPointerMove={handlePointerMove} anchorOffset = stableBtn.offsetTop - scrollContainerRef.scrollTop
onContextMenu={handleContextMenu} }
onMouseEnter={(event) => handleMouseEnter(segment, event)}
onMouseLeave={handleMouseLeave} const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0
>
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span> if (event.shiftKey) {
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span> props.onSelectRange?.(segment.id)
</button> } else if (event.ctrlKey || event.metaKey) {
) props.onToggleSelection?.(segment.id)
}} } else if (isMultiSelectActive) {
</For> props.onSegmentClick?.(segment)
} else {
props.onSegmentClick?.(segment)
}
if (anchorOffset !== null && stableBtn && scrollContainerRef) {
const desired = stableBtn.offsetTop - anchorOffset
if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
scrollContainerRef.scrollTop = desired
}
}
}}
onPointerDown={(e) => handlePointerDown(segment, e)}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onPointerMove={handlePointerMove}
onContextMenu={handleContextMenu}
onMouseEnter={(event) => handleMouseEnter(segment, event)}
onMouseLeave={handleMouseLeave}
>
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span>
</button>
</div>
)
}}
</Virtualizer>
</Show>
<Show when={previewData()}> <Show when={previewData()}>
{(data) => { {(data) => {
onCleanup(() => setTooltipElement(null)) onCleanup(() => setTooltipElement(null))
return ( return (
<div <Portal>
ref={(element) => setTooltipElement(element)} <div
class="message-timeline-tooltip" ref={(element) => setTooltipElement(element)}
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }} class="message-timeline-tooltip"
onMouseEnter={() => clearCloseTimer()} style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
onMouseLeave={() => scheduleClose()} onMouseEnter={() => clearCloseTimer()}
> onMouseLeave={() => scheduleClose()}
<MessagePreview >
messageId={data().messageId} <MessagePreview
instanceId={props.instanceId} messageId={data().messageId}
sessionId={props.sessionId} instanceId={props.instanceId}
store={store} sessionId={props.sessionId}
deleteHover={props.deleteHover} store={store}
onDeleteHoverChange={props.onDeleteHoverChange} deleteHover={props.deleteHover}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo} onDeleteHoverChange={props.onDeleteHoverChange}
selectedMessageIds={props.selectedMessageIds} onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
/> selectedMessageIds={props.selectedMessageIds}
</div> />
</div>
</Portal>
) )
}} }}
</Show> </Show>

View File

@@ -176,7 +176,7 @@ export function usePromptVoiceInput(options: UsePromptVoiceInputOptions) {
const before = current.slice(0, start) const before = current.slice(0, start)
const after = current.slice(end) const after = current.slice(end)
const prefix = "" const prefix = ""
const suffix = after.length > 0 && !after.startsWith("\n") ? "\n" : "" const suffix = after.length > 0 ? (/^\s/.test(after) ? "" : " ") : " "
const nextValue = `${before}${prefix}${text}${suffix}${after}` const nextValue = `${before}${prefix}${text}${suffix}${after}`
const cursor = before.length + prefix.length + text.length + suffix.length const cursor = before.length + prefix.length + text.length + suffix.length

View File

@@ -79,11 +79,17 @@ export const SessionView: Component<SessionViewProps> = (props) => {
requestAnimationFrame(() => scrollToBottomHandle?.()) requestAnimationFrame(() => scrollToBottomHandle?.())
}) })
} }
createEffect(() => { createEffect(
if (!props.isActive) return on(
if (!shouldScrollToBottomOnActivate()) return () => props.isActive,
scheduleScrollToBottom() (isActive, wasActive) => {
}) if (!isActive) return
if (wasActive === true) return
if (!shouldScrollToBottomOnActivate()) return
scheduleScrollToBottom()
},
),
)
createEffect( createEffect(
on( on(
@@ -332,16 +338,11 @@ export const SessionView: Component<SessionViewProps> = (props) => {
loading={messagesLoading()} loading={messagesLoading()}
onRevert={handleRevert} onRevert={handleRevert}
onDeleteMessagesUpTo={handleDeleteMessagesUpTo} onDeleteMessagesUpTo={handleDeleteMessagesUpTo}
onFork={handleFork} onFork={handleFork}
isActive={props.isActive} isActive={props.isActive}
registerScrollToBottom={(fn) => { registerScrollToBottom={(fn) => {
scrollToBottomHandle = fn scrollToBottomHandle = fn
if (props.isActive) { }}
if (shouldScrollToBottomOnActivate()) {
scheduleScrollToBottom()
}
}
}}

View File

@@ -2,6 +2,8 @@ import { Show, createEffect, createMemo, createSignal, onCleanup, type Accessor,
import { Virtualizer, type VirtualizerHandle } from "virtua/solid" import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48 const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48
const DEFAULT_HOLD_TARGET_TOP_THRESHOLD_PX = 8
const DEFAULT_HOLD_TARGET_TOP_OVERSHOOT_PX = 128
const USER_SCROLL_INTENT_WINDOW_MS = 600 const USER_SCROLL_INTENT_WINDOW_MS = 600
const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"]) const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
@@ -85,6 +87,28 @@ export interface VirtualFollowListProps<T> {
*/ */
followToken?: Accessor<string | number> followToken?: Accessor<string | number>
/**
* Optional item key whose geometry can temporarily hold auto-follow when the
* rendered item grows taller than the viewport and reaches the top edge.
*/
autoPinHoldTargetKey?: Accessor<string | null>
/**
* Optional resolver for the specific element inside an item wrapper that
* should be measured for hold-target geometry.
*/
resolveAutoPinHoldElement?: (itemWrapper: HTMLDivElement, key: string) => HTMLElement | null | undefined
/**
* Top-edge threshold for the hold target in pixels.
*/
autoPinHoldTopThresholdPx?: number
/**
* Temporarily suppress automatic bottom pinning while keeping follow mode enabled.
*/
suspendAutoPinToBottom?: Accessor<boolean>
/** /**
* Optional hooks to render content inside the scroll container. * Optional hooks to render content inside the scroll container.
* Useful for empty/loading states that should scroll with the list. * Useful for empty/loading states that should scroll with the list.
@@ -130,13 +154,19 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
const scrollToBottomOnActivate = () => (props.scrollToBottomOnActivate ? props.scrollToBottomOnActivate() : true) const scrollToBottomOnActivate = () => (props.scrollToBottomOnActivate ? props.scrollToBottomOnActivate() : true)
const initialScrollToBottom = () => (props.initialScrollToBottom ? props.initialScrollToBottom() : true) const initialScrollToBottom = () => (props.initialScrollToBottom ? props.initialScrollToBottom() : true)
const initialAutoScroll = () => (props.initialAutoScroll ? props.initialAutoScroll() : true) const initialAutoScroll = () => (props.initialAutoScroll ? props.initialAutoScroll() : true)
const externalSuspendAutoPinToBottom = () => (props.suspendAutoPinToBottom ? props.suspendAutoPinToBottom() : false)
const holdTargetKey = () => (props.autoPinHoldTargetKey ? props.autoPinHoldTargetKey() : null)
const holdTargetTopThresholdPx = () => props.autoPinHoldTopThresholdPx ?? DEFAULT_HOLD_TARGET_TOP_THRESHOLD_PX
const [autoScroll, setAutoScroll] = createSignal(Boolean(initialAutoScroll())) const [autoScroll, setAutoScroll] = createSignal(Boolean(initialAutoScroll()))
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false) const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false) const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
const [activeKey, setActiveKey] = createSignal<string | null>(null) const [activeKey, setActiveKey] = createSignal<string | null>(null)
const [heldItemCount, setHeldItemCount] = createSignal<number | null>(null)
const effectiveSuspendAutoPinToBottom = () => externalSuspendAutoPinToBottom() || heldItemCount() !== null
const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0)) const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0))
const itemElements = new Map<string, HTMLDivElement>()
let userScrollIntentUntil = 0 let userScrollIntentUntil = 0
let lastUserScrollIntentDirection: "up" | "down" | null = null let lastUserScrollIntentDirection: "up" | "down" | null = null
@@ -220,6 +250,9 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
// Sync autoScroll state based on scroll position if it was a user scroll // Sync autoScroll state based on scroll position if it was a user scroll
if (hasUserScrollIntent()) { if (hasUserScrollIntent()) {
if (atBottom && heldItemCount() !== null) {
setHeldItemCount(null)
}
if (atBottom && !autoScroll()) { if (atBottom && !autoScroll()) {
setAutoScroll(true) setAutoScroll(true)
} else if (!atBottom && autoScroll()) { } else if (!atBottom && autoScroll()) {
@@ -253,6 +286,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
} }
} }
updateScrollButtons() updateScrollButtons()
updateAutoPinHold()
props.onScroll?.() props.onScroll?.()
// Find active key (roughly the first visible item) // Find active key (roughly the first visible item)
@@ -270,6 +304,68 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
} }
} }
function registerItemElement(key: string, element: HTMLDivElement | null | undefined) {
if (!element) {
itemElements.delete(key)
return
}
itemElements.set(key, element)
}
function getAnchorIdForKey(key: string) {
return props.getAnchorId ? props.getAnchorId(key) : key
}
function updateAutoPinHold() {
const element = scrollElement()
const itemCount = props.items().length
const heldCount = heldItemCount()
if (!element) return
if (heldCount !== null) {
if (itemCount > heldCount) {
setHeldItemCount(null)
if (autoScroll()) {
requestAnimationFrame(() => {
if (!autoScroll()) return
scrollToBottom(false)
})
}
return
}
if (itemCount < heldCount) {
setHeldItemCount(null)
return
}
return
}
if (!autoScroll()) return
if (externalSuspendAutoPinToBottom()) return
const targetKey = holdTargetKey()
if (!targetKey) return
const itemWrapper = itemElements.get(targetKey)
if (!itemWrapper) return
const target = props.resolveAutoPinHoldElement?.(itemWrapper, targetKey) ?? itemWrapper
const containerRect = element.getBoundingClientRect()
const targetRect = target.getBoundingClientRect()
const relativeTop = targetRect.top - containerRect.top
const exceedsViewport = targetRect.height > element.clientHeight
if (
exceedsViewport &&
relativeTop <= holdTargetTopThresholdPx() &&
relativeTop >= holdTargetTopThresholdPx() - DEFAULT_HOLD_TARGET_TOP_OVERSHOOT_PX
) {
setHeldItemCount(itemCount)
}
}
const api: VirtualFollowListApi = { const api: VirtualFollowListApi = {
scrollToTop: (opts) => scrollToTop(opts?.immediate ?? true), scrollToTop: (opts) => scrollToTop(opts?.immediate ?? true),
scrollToBottom: (opts) => scrollToBottom(opts?.immediate ?? true, { suppressAutoAnchor: opts?.suppressAutoAnchor }), scrollToBottom: (opts) => scrollToBottom(opts?.immediate ?? true, { suppressAutoAnchor: opts?.suppressAutoAnchor }),
@@ -281,7 +377,9 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
virtuaHandle()?.scrollToIndex(index, { align: opts?.block ?? "start", smooth: opts?.behavior === "smooth" }) virtuaHandle()?.scrollToIndex(index, { align: opts?.block ?? "start", smooth: opts?.behavior === "smooth" })
}, },
notifyContentRendered: () => { notifyContentRendered: () => {
if (autoScroll()) { updateAutoPinHold()
if (heldItemCount() !== null) return
if (autoScroll() && !effectiveSuspendAutoPinToBottom()) {
scrollToBottom(true) scrollToBottom(true)
} }
}, },
@@ -294,9 +392,15 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
createEffect(() => props.registerApi?.(api)) createEffect(() => props.registerApi?.(api))
createEffect(() => props.registerState?.(state)) createEffect(() => props.registerState?.(state))
createEffect(on(() => props.resetKey?.(), () => {
itemElements.clear()
setHeldItemCount(null)
}))
// Handle autoScroll (Follow) on items change // Handle autoScroll (Follow) on items change
createEffect(on(() => props.items().length, (len, prevLen) => { createEffect(on(() => props.items().length, (len, prevLen) => {
if (len > (prevLen ?? 0) && autoScroll() && !suppressAutoScrollOnce) { updateAutoPinHold()
if (len > (prevLen ?? 0) && autoScroll() && !effectiveSuspendAutoPinToBottom() && !suppressAutoScrollOnce) {
requestAnimationFrame(() => scrollToBottom(true)) requestAnimationFrame(() => scrollToBottom(true))
} }
suppressAutoScrollOnce = false suppressAutoScrollOnce = false
@@ -304,11 +408,16 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
// Handle followToken change // Handle followToken change
createEffect(on(() => props.followToken?.(), () => { createEffect(on(() => props.followToken?.(), () => {
if (autoScroll()) { updateAutoPinHold()
if (autoScroll() && !effectiveSuspendAutoPinToBottom()) {
scrollToBottom(true) scrollToBottom(true)
} }
}, { defer: true })) }, { defer: true }))
createEffect(on(() => holdTargetKey(), () => {
updateAutoPinHold()
}, { defer: true }))
// Reset state on resetKey change // Reset state on resetKey change
createEffect(on(() => props.resetKey?.(), (nextKey) => { createEffect(on(() => props.resetKey?.(), (nextKey) => {
if (nextKey === lastResetKey) return if (nextKey === lastResetKey) return
@@ -331,6 +440,13 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
} }
}) })
createEffect(() => {
if (typeof window === "undefined") return
const handleResize = () => updateAutoPinHold()
window.addEventListener("resize", handleResize)
onCleanup(() => window.removeEventListener("resize", handleResize))
})
return ( return (
<div class="virtual-follow-list-shell" ref={shellElement => { <div class="virtual-follow-list-shell" ref={shellElement => {
setShellElement(shellElement) setShellElement(shellElement)
@@ -356,7 +472,15 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
bufferSize={props.overscanPx ?? 400} bufferSize={props.overscanPx ?? 400}
onScroll={handleScroll} onScroll={handleScroll}
> >
{(item, index) => props.renderItem(item, index())} {(item, index) => {
const key = props.getKey(item, index())
const anchorId = getAnchorIdForKey(key)
return (
<div id={anchorId} data-virtual-follow-key={key} ref={(element) => registerItemElement(key, element)}>
{props.renderItem(item, index())}
</div>
)
}}
</Virtualizer> </Virtualizer>
</div> </div>

View File

@@ -18,6 +18,8 @@ export const messagingMessages = {
"messageSection.loading.messages": "Loading messages...", "messageSection.loading.messages": "Loading messages...",
"messageSection.scroll.toFirstAriaLabel": "Scroll to first message", "messageSection.scroll.toFirstAriaLabel": "Scroll to first message",
"messageSection.scroll.toLatestAriaLabel": "Scroll to latest message", "messageSection.scroll.toLatestAriaLabel": "Scroll to latest message",
"messageSection.scroll.enableHoldAriaLabel": "Enable hold for long assistant replies",
"messageSection.scroll.disableHoldAriaLabel": "Disable hold for long assistant replies",
"messageSection.quote.addAsQuote": "Add as quote", "messageSection.quote.addAsQuote": "Add as quote",
"messageSection.quote.addAsCode": "Add as code", "messageSection.quote.addAsCode": "Add as code",
"messageSection.quote.copy": "Copy", "messageSection.quote.copy": "Copy",

View File

@@ -18,6 +18,8 @@ export const messagingMessages = {
"messageSection.loading.messages": "Cargando mensajes...", "messageSection.loading.messages": "Cargando mensajes...",
"messageSection.scroll.toFirstAriaLabel": "Desplazarse al primer mensaje", "messageSection.scroll.toFirstAriaLabel": "Desplazarse al primer mensaje",
"messageSection.scroll.toLatestAriaLabel": "Desplazarse al último mensaje", "messageSection.scroll.toLatestAriaLabel": "Desplazarse al último mensaje",
"messageSection.scroll.enableHoldAriaLabel": "Activar pausa para respuestas largas del asistente",
"messageSection.scroll.disableHoldAriaLabel": "Desactivar pausa para respuestas largas del asistente",
"messageSection.quote.addAsQuote": "Añadir como cita", "messageSection.quote.addAsQuote": "Añadir como cita",
"messageSection.quote.addAsCode": "Añadir como código", "messageSection.quote.addAsCode": "Añadir como código",
"messageSection.quote.copy": "Copiar", "messageSection.quote.copy": "Copiar",

View File

@@ -18,6 +18,8 @@ export const messagingMessages = {
"messageSection.loading.messages": "Chargement des messages...", "messageSection.loading.messages": "Chargement des messages...",
"messageSection.scroll.toFirstAriaLabel": "Aller au premier message", "messageSection.scroll.toFirstAriaLabel": "Aller au premier message",
"messageSection.scroll.toLatestAriaLabel": "Aller au dernier message", "messageSection.scroll.toLatestAriaLabel": "Aller au dernier message",
"messageSection.scroll.enableHoldAriaLabel": "Activer le maintien pour les longues réponses de l'assistant",
"messageSection.scroll.disableHoldAriaLabel": "Désactiver le maintien pour les longues réponses de l'assistant",
"messageSection.quote.addAsQuote": "Ajouter en citation", "messageSection.quote.addAsQuote": "Ajouter en citation",
"messageSection.quote.addAsCode": "Ajouter en code", "messageSection.quote.addAsCode": "Ajouter en code",
"messageSection.quote.copy": "Copier", "messageSection.quote.copy": "Copier",

View File

@@ -18,6 +18,8 @@ export const messagingMessages = {
"messageSection.loading.messages": "טוען הודעות...", "messageSection.loading.messages": "טוען הודעות...",
"messageSection.scroll.toFirstAriaLabel": "גלול להודעה הראשונה", "messageSection.scroll.toFirstAriaLabel": "גלול להודעה הראשונה",
"messageSection.scroll.toLatestAriaLabel": "גלול להודעה האחרונה", "messageSection.scroll.toLatestAriaLabel": "גלול להודעה האחרונה",
"messageSection.scroll.enableHoldAriaLabel": "הפעל עצירה לתגובות עוזר ארוכות",
"messageSection.scroll.disableHoldAriaLabel": "כבה עצירה לתגובות עוזר ארוכות",
"messageSection.quote.addAsQuote": "הוסף כציטוט", "messageSection.quote.addAsQuote": "הוסף כציטוט",
"messageSection.quote.addAsCode": "הוסף כקוד", "messageSection.quote.addAsCode": "הוסף כקוד",
"messageSection.quote.copy": "העתק", "messageSection.quote.copy": "העתק",

View File

@@ -18,6 +18,8 @@ export const messagingMessages = {
"messageSection.loading.messages": "メッセージを読み込み中...", "messageSection.loading.messages": "メッセージを読み込み中...",
"messageSection.scroll.toFirstAriaLabel": "最初のメッセージへスクロール", "messageSection.scroll.toFirstAriaLabel": "最初のメッセージへスクロール",
"messageSection.scroll.toLatestAriaLabel": "最新のメッセージへスクロール", "messageSection.scroll.toLatestAriaLabel": "最新のメッセージへスクロール",
"messageSection.scroll.enableHoldAriaLabel": "長いアシスタント返信の保持を有効にする",
"messageSection.scroll.disableHoldAriaLabel": "長いアシスタント返信の保持を無効にする",
"messageSection.quote.addAsQuote": "引用として追加", "messageSection.quote.addAsQuote": "引用として追加",
"messageSection.quote.addAsCode": "コードとして追加", "messageSection.quote.addAsCode": "コードとして追加",
"messageSection.quote.copy": "コピー", "messageSection.quote.copy": "コピー",

View File

@@ -18,6 +18,8 @@ export const messagingMessages = {
"messageSection.loading.messages": "Загрузка сообщений…", "messageSection.loading.messages": "Загрузка сообщений…",
"messageSection.scroll.toFirstAriaLabel": "Прокрутить к первому сообщению", "messageSection.scroll.toFirstAriaLabel": "Прокрутить к первому сообщению",
"messageSection.scroll.toLatestAriaLabel": "Прокрутить к последнему сообщению", "messageSection.scroll.toLatestAriaLabel": "Прокрутить к последнему сообщению",
"messageSection.scroll.enableHoldAriaLabel": "Включить удержание для длинных ответов ассистента",
"messageSection.scroll.disableHoldAriaLabel": "Выключить удержание для длинных ответов ассистента",
"messageSection.quote.addAsQuote": "Добавить как цитату", "messageSection.quote.addAsQuote": "Добавить как цитату",
"messageSection.quote.addAsCode": "Добавить как код", "messageSection.quote.addAsCode": "Добавить как код",
"messageSection.quote.copy": "Копировать", "messageSection.quote.copy": "Копировать",

View File

@@ -18,6 +18,8 @@ export const messagingMessages = {
"messageSection.loading.messages": "正在加载消息...", "messageSection.loading.messages": "正在加载消息...",
"messageSection.scroll.toFirstAriaLabel": "滚动到第一条消息", "messageSection.scroll.toFirstAriaLabel": "滚动到第一条消息",
"messageSection.scroll.toLatestAriaLabel": "滚动到最新消息", "messageSection.scroll.toLatestAriaLabel": "滚动到最新消息",
"messageSection.scroll.enableHoldAriaLabel": "启用长助手回复保持",
"messageSection.scroll.disableHoldAriaLabel": "禁用长助手回复保持",
"messageSection.quote.addAsQuote": "作为引用添加", "messageSection.quote.addAsQuote": "作为引用添加",
"messageSection.quote.addAsCode": "作为代码添加", "messageSection.quote.addAsCode": "作为代码添加",
"messageSection.quote.copy": "复制", "messageSection.quote.copy": "复制",

View File

@@ -55,6 +55,7 @@ export interface UiSettings {
showKeyboardShortcutHints: boolean showKeyboardShortcutHints: boolean
thinkingBlocksExpansion: ExpansionPreference thinkingBlocksExpansion: ExpansionPreference
showTimelineTools: boolean showTimelineTools: boolean
holdLongAssistantReplies: boolean
promptSubmitOnEnter: boolean promptSubmitOnEnter: boolean
showPromptVoiceInput: boolean showPromptVoiceInput: boolean
locale?: string locale?: string
@@ -133,6 +134,7 @@ const defaultUiSettings: UiSettings = {
showKeyboardShortcutHints: true, showKeyboardShortcutHints: true,
thinkingBlocksExpansion: "expanded", thinkingBlocksExpansion: "expanded",
showTimelineTools: true, showTimelineTools: true,
holdLongAssistantReplies: true,
promptSubmitOnEnter: false, promptSubmitOnEnter: false,
showPromptVoiceInput: true, showPromptVoiceInput: true,
diffViewMode: "split", diffViewMode: "split",
@@ -166,6 +168,7 @@ function normalizeUiSettings(input?: Partial<UiSettings> | null): UiSettings {
sanitized.showKeyboardShortcutHints ?? defaultUiSettings.showKeyboardShortcutHints, sanitized.showKeyboardShortcutHints ?? defaultUiSettings.showKeyboardShortcutHints,
thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultUiSettings.thinkingBlocksExpansion, thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultUiSettings.thinkingBlocksExpansion,
showTimelineTools: sanitized.showTimelineTools ?? defaultUiSettings.showTimelineTools, showTimelineTools: sanitized.showTimelineTools ?? defaultUiSettings.showTimelineTools,
holdLongAssistantReplies: sanitized.holdLongAssistantReplies ?? defaultUiSettings.holdLongAssistantReplies,
promptSubmitOnEnter: sanitized.promptSubmitOnEnter ?? defaultUiSettings.promptSubmitOnEnter, promptSubmitOnEnter: sanitized.promptSubmitOnEnter ?? defaultUiSettings.promptSubmitOnEnter,
showPromptVoiceInput: sanitized.showPromptVoiceInput ?? defaultUiSettings.showPromptVoiceInput, showPromptVoiceInput: sanitized.showPromptVoiceInput ?? defaultUiSettings.showPromptVoiceInput,
locale: sanitized.locale ?? defaultUiSettings.locale, locale: sanitized.locale ?? defaultUiSettings.locale,

View File

@@ -242,6 +242,10 @@
color: var(--accent-primary); color: var(--accent-primary);
} }
.message-scroll-button[data-active="false"] .message-scroll-icon--toggle {
color: var(--text-secondary);
}
.message-quote-popover { .message-quote-popover {
position: absolute; position: absolute;
z-index: 5; z-index: 5;

View File

@@ -66,10 +66,11 @@
} }
.message-timeline { .message-timeline {
--message-timeline-segment-gap: 0.35rem;
flex: 1 1 auto; flex: 1 1 auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.35rem; gap: 0;
padding: 0.25rem; padding: 0.25rem;
overflow-y: auto; overflow-y: auto;
overflow-x: visible; overflow-x: visible;
@@ -114,6 +115,17 @@
-webkit-touch-callout: none; -webkit-touch-callout: none;
} }
.message-timeline-item {
display: flex;
flex-direction: column;
width: 100%;
}
.message-timeline-item-spacer {
flex: none;
width: 100%;
}
.message-timeline-segment[data-delete-hover="true"]::before { .message-timeline-segment[data-delete-hover="true"]::before {
content: ""; content: "";
position: absolute; position: absolute;
@@ -319,18 +331,7 @@
border-inline-start: 3px solid color-mix(in oklab, var(--accent-primary) 35%, transparent); border-inline-start: 3px solid color-mix(in oklab, var(--accent-primary) 35%, transparent);
} }
/* Extra spacing before the first tool in a group to separate from the /* Spacing is rendered by the measured item wrapper so virtua can account for it. */
preceding user/assistant badge. */
.message-timeline-group-start {
margin-top: 0.35rem;
}
/* Subtle extra spacing after the group parent (assistant) to separate
from the next user badge below. Uses adjacent sibling targeting. */
.message-timeline-group-parent + .message-timeline-user,
.message-timeline-group-parent + .message-timeline-compaction {
margin-top: 0.35rem;
}
.message-timeline-container { .message-timeline-container {
position: relative; position: relative;