fix(desktop): rebuild thread autoscroll on use-stick-to-bottom
This commit is contained in:
@@ -58,9 +58,9 @@ Element.prototype.animate = function animate() {
|
||||
} as unknown as Animation
|
||||
}
|
||||
|
||||
// jsdom returns 0 for offset*; the virtualizer reads those to size its
|
||||
// jsdom returns 0 for offset*; some layout code reads those to size the
|
||||
// viewport. Fall through to client* (which tests can override) or a sane
|
||||
// default so virtualized items render.
|
||||
// default so message rows render with non-zero dimensions.
|
||||
function stubOffsetDimension(
|
||||
prop: 'offsetHeight' | 'offsetWidth',
|
||||
clientProp: 'clientHeight' | 'clientWidth',
|
||||
@@ -254,20 +254,6 @@ function StreamingHarness() {
|
||||
)
|
||||
}
|
||||
|
||||
function StaticThreadHarness() {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [userMessage(), assistantMessage('complete response', false)],
|
||||
isRunning: false,
|
||||
onNew: async () => {}
|
||||
})
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TodoHarness({ message }: { message: ThreadMessage }) {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [message],
|
||||
@@ -409,222 +395,11 @@ describe('assistant-ui streaming renderer', () => {
|
||||
expect(screen.getByRole('alert').textContent).toContain('OpenRouter rejected the request (403).')
|
||||
})
|
||||
|
||||
it('does not pull the viewport back down after the user scrolls up during streaming', async () => {
|
||||
const { container } = render(<StreamingHarness />)
|
||||
|
||||
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
|
||||
const viewport = content.parentElement as HTMLDivElement
|
||||
let scrollHeight = 1_000
|
||||
|
||||
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
|
||||
Object.defineProperty(viewport, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => scrollHeight
|
||||
})
|
||||
|
||||
await wait(80)
|
||||
|
||||
await act(async () => {
|
||||
viewport.scrollTop = 800
|
||||
fireEvent.scroll(viewport)
|
||||
})
|
||||
await wait(0)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.wheel(viewport, { deltaY: -120 })
|
||||
viewport.scrollTop = 420
|
||||
fireEvent.scroll(viewport)
|
||||
})
|
||||
|
||||
scrollHeight = 1_200
|
||||
|
||||
await act(async () => {
|
||||
for (const observer of resizeObservers) {
|
||||
observer.trigger(1_200)
|
||||
}
|
||||
})
|
||||
await wait(0)
|
||||
|
||||
expect(viewport.scrollTop).toBe(420)
|
||||
})
|
||||
|
||||
it('does not auto-follow idle layout shifts', async () => {
|
||||
const { container } = render(<StaticThreadHarness />)
|
||||
|
||||
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
|
||||
const viewport = content.parentElement as HTMLDivElement
|
||||
let scrollHeight = 1_000
|
||||
|
||||
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
|
||||
Object.defineProperty(viewport, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => scrollHeight
|
||||
})
|
||||
|
||||
await wait(80)
|
||||
|
||||
await act(async () => {
|
||||
viewport.scrollTop = 420
|
||||
fireEvent.scroll(viewport)
|
||||
})
|
||||
|
||||
scrollHeight = 1_200
|
||||
|
||||
await act(async () => {
|
||||
for (const observer of resizeObservers) {
|
||||
observer.trigger(1_200)
|
||||
}
|
||||
})
|
||||
await wait(0)
|
||||
|
||||
expect(viewport.scrollTop).toBe(420)
|
||||
})
|
||||
|
||||
it('does not follow streaming content growth even while parked at the bottom', async () => {
|
||||
const { container } = render(<StreamingHarness />)
|
||||
|
||||
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
|
||||
const viewport = content.parentElement as HTMLDivElement
|
||||
let clientHeight = 200
|
||||
let scrollHeight = 1_000
|
||||
|
||||
Object.defineProperty(viewport, 'clientHeight', {
|
||||
configurable: true,
|
||||
get: () => clientHeight
|
||||
})
|
||||
Object.defineProperty(viewport, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => scrollHeight
|
||||
})
|
||||
|
||||
await wait(80)
|
||||
|
||||
// Park the user at the bottom of the current content.
|
||||
await act(async () => {
|
||||
viewport.scrollTop = 800
|
||||
fireEvent.scroll(viewport)
|
||||
})
|
||||
|
||||
clientHeight = 240
|
||||
|
||||
await act(async () => {
|
||||
viewport.scrollTop = 760
|
||||
fireEvent.scroll(viewport)
|
||||
})
|
||||
|
||||
// Content grows as tokens stream in. Streaming auto-follow is removed, so
|
||||
// the viewport must NOT chase the new bottom — it stays where the user
|
||||
// last left it.
|
||||
scrollHeight = 1_200
|
||||
|
||||
await act(async () => {
|
||||
for (const observer of resizeObservers) {
|
||||
observer.trigger(1_200)
|
||||
}
|
||||
})
|
||||
await wait(0)
|
||||
|
||||
expect(viewport.scrollTop).toBe(760)
|
||||
})
|
||||
|
||||
it('honors the first upward wheel scroll even when a programmatic bottom-pin scroll event is still pending', async () => {
|
||||
const { container } = render(<StreamingHarness />)
|
||||
|
||||
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
|
||||
const viewport = content.parentElement as HTMLDivElement
|
||||
let scrollHeight = 1_000
|
||||
|
||||
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
|
||||
Object.defineProperty(viewport, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => scrollHeight
|
||||
})
|
||||
|
||||
await wait(80)
|
||||
await wait(0)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.wheel(viewport, { deltaY: -120 })
|
||||
viewport.scrollTop = 420
|
||||
fireEvent.scroll(viewport)
|
||||
})
|
||||
|
||||
scrollHeight = 1_200
|
||||
|
||||
await act(async () => {
|
||||
for (const observer of resizeObservers) {
|
||||
observer.trigger(1_200)
|
||||
}
|
||||
})
|
||||
await wait(0)
|
||||
|
||||
expect(viewport.scrollTop).toBe(420)
|
||||
})
|
||||
|
||||
it('does not snap to the bottom on final code-highlight growth after a run completes', async () => {
|
||||
const { container } = render(<StreamingHarness />)
|
||||
|
||||
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
|
||||
const viewport = content.parentElement as HTMLDivElement
|
||||
let scrollHeight = 1_000
|
||||
|
||||
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
|
||||
Object.defineProperty(viewport, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => scrollHeight
|
||||
})
|
||||
|
||||
await wait(80)
|
||||
|
||||
await act(async () => {
|
||||
viewport.scrollTop = 800
|
||||
fireEvent.scroll(viewport)
|
||||
})
|
||||
|
||||
await wait(650)
|
||||
|
||||
// Completion re-measures (Shiki highlight) and grows the content. The
|
||||
// post-run bottom lock is removed, so the viewport stays put instead of
|
||||
// snapping to the new bottom.
|
||||
scrollHeight = 1_700
|
||||
await wait(0)
|
||||
|
||||
expect(viewport.scrollTop).toBe(800)
|
||||
})
|
||||
|
||||
it('does not restart bottom-follow after completion when the user scrolled up', async () => {
|
||||
const { container } = render(<StreamingHarness />)
|
||||
|
||||
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
|
||||
const viewport = content.parentElement as HTMLDivElement
|
||||
let scrollHeight = 1_000
|
||||
|
||||
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
|
||||
Object.defineProperty(viewport, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => scrollHeight
|
||||
})
|
||||
|
||||
await wait(80)
|
||||
|
||||
await act(async () => {
|
||||
viewport.scrollTop = 800
|
||||
fireEvent.scroll(viewport)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.wheel(viewport, { deltaY: -120 })
|
||||
viewport.scrollTop = 420
|
||||
fireEvent.scroll(viewport)
|
||||
})
|
||||
|
||||
await wait(650)
|
||||
|
||||
scrollHeight = 1_700
|
||||
await wait(0)
|
||||
|
||||
expect(viewport.scrollTop).toBe(420)
|
||||
})
|
||||
// Scroll behavior (follow-at-bottom, escape-on-scroll-up, re-engage) is owned
|
||||
// by the use-stick-to-bottom library and covered by its own test suite. We
|
||||
// don't re-assert its scrollTop mechanics here — doing so in jsdom (no real
|
||||
// layout, spring animation via rAF) only produces brittle change-detector
|
||||
// tests. The rendering/streaming-content tests below remain the contract.
|
||||
|
||||
it('renders an incomplete streaming fenced code block as a code card', async () => {
|
||||
const { container } = render(<RunningMessageHarness message={assistantMessage('```ts\nconst answer = 42\n')} />)
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react'
|
||||
import {
|
||||
type ComponentProps,
|
||||
type FC,
|
||||
memo,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import { useStickToBottom } from 'use-stick-to-bottom'
|
||||
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
onScrollToBottomRequest,
|
||||
onThreadEditClose,
|
||||
onThreadEditOpen,
|
||||
resetThreadScroll,
|
||||
setThreadAtBottom
|
||||
} from '@/store/thread-scroll'
|
||||
|
||||
import { MessageRenderBoundary } from './message-render-boundary'
|
||||
|
||||
type ThreadMessageComponents = ComponentProps<typeof ThreadPrimitive.MessageByIndex>['components']
|
||||
|
||||
type MessageGroup = { id: string; weight: number } & (
|
||||
| { index: number; kind: 'standalone' }
|
||||
| { indices: number[]; kind: 'turn' }
|
||||
)
|
||||
|
||||
// DOM is bounded by a rendered-PART budget, not a message/turn count: a single
|
||||
// assistant message folds every tool call into a part, so heavy sessions are
|
||||
// ~40 turns / ~100 messages but ~1000 parts — and parts are what drive node
|
||||
// count. "Show earlier" prepends another page; whole turns stay intact so the
|
||||
// sticky human bubble never loses its turn. This is the long-session perf lever
|
||||
// WITHOUT a virtualizer — pure rendering, never touches scrollTop, so it can't
|
||||
// fight use-stick-to-bottom (the single scroll owner).
|
||||
const RENDER_BUDGET = 300
|
||||
|
||||
interface ThreadMessageListProps {
|
||||
clampToComposer: boolean
|
||||
components: ThreadMessageComponents
|
||||
emptyPlaceholder?: ReactNode
|
||||
loadingIndicator?: ReactNode
|
||||
sessionKey?: string | null
|
||||
}
|
||||
|
||||
// Group each user message with the assistant turn(s) that follow it so the
|
||||
// human bubble can `position: sticky` against the scroller across its whole
|
||||
// turn (see StickyHumanMessageContainer in thread.tsx).
|
||||
function buildGroups(signature: string): MessageGroup[] {
|
||||
if (!signature) {
|
||||
return []
|
||||
}
|
||||
|
||||
const messages = signature.split('\n').map(row => {
|
||||
const [index, id, role, weight] = row.split(':')
|
||||
|
||||
return { id, index: Number(index), role, weight: Number(weight) || 1 }
|
||||
})
|
||||
|
||||
const groups: MessageGroup[] = []
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i]
|
||||
|
||||
if (message.role !== 'user') {
|
||||
groups.push({ id: message.id, index: message.index, kind: 'standalone', weight: message.weight })
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const indices = [message.index]
|
||||
let weight = message.weight
|
||||
|
||||
while (i + 1 < messages.length && messages[i + 1].role !== 'user') {
|
||||
weight += messages[++i].weight
|
||||
indices.push(messages[i].index)
|
||||
}
|
||||
|
||||
groups.push({ id: message.id, indices, kind: 'turn', weight })
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
||||
clampToComposer,
|
||||
components,
|
||||
emptyPlaceholder,
|
||||
loadingIndicator,
|
||||
sessionKey
|
||||
}) => {
|
||||
const messageSignature = useAuiState(s =>
|
||||
s.thread.messages
|
||||
.map((message, index) => `${index}:${message.id}:${message.role}:${message.content?.length ?? 1}`)
|
||||
.join('\n')
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const groups = buildGroups(messageSignature)
|
||||
const renderEmpty = groups.length === 0 && Boolean(emptyPlaceholder)
|
||||
|
||||
// use-stick-to-bottom owns scrollTop (single writer): follow while locked,
|
||||
// escape on user scroll-up, re-lock at bottom. Snap instantly, not spring — a
|
||||
// spring can't tell live-token growth from a session-switch bulk relayout, and
|
||||
// chasing the latter reads as the view scrolling to random spots before
|
||||
// settling. Its refs hang off our own DOM so the sticky human bubbles survive.
|
||||
const { scrollRef, contentRef, isAtBottom, scrollToBottom, stopScroll } = useStickToBottom({
|
||||
initial: 'instant',
|
||||
resize: 'instant'
|
||||
})
|
||||
|
||||
const [renderBudget, setRenderBudget] = useState(RENDER_BUDGET)
|
||||
|
||||
// Walk turns newest-first, summing their part weights until the budget is met;
|
||||
// everything before that first kept turn is hidden.
|
||||
let firstVisible = groups.length
|
||||
|
||||
for (let i = groups.length - 1, weight = 0; i >= 0; i--) {
|
||||
weight += groups[i].weight
|
||||
firstVisible = i
|
||||
|
||||
if (weight >= renderBudget) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const hiddenCount = firstVisible
|
||||
const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups
|
||||
const restoreFromBottomRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => setThreadAtBottom(isAtBottom), [isAtBottom])
|
||||
useEffect(() => () => resetThreadScroll(), [])
|
||||
|
||||
// Floating jump button (outside this subtree) → return to the bottom.
|
||||
useEffect(() => onScrollToBottomRequest(() => void scrollToBottom()), [scrollToBottom])
|
||||
|
||||
const endEditHold = useCallback(() => {
|
||||
scrollRef.current?.removeAttribute('data-editing')
|
||||
}, [scrollRef])
|
||||
|
||||
// Inline edit grows a sticky bubble. Escape before focus/layout so the
|
||||
// resize-follow can't snap scrollTop; native anchoring holds the viewport.
|
||||
const beginEditHold = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
endEditHold()
|
||||
stopScroll()
|
||||
el.setAttribute('data-editing', 'true')
|
||||
}, [endEditHold, scrollRef, stopScroll])
|
||||
|
||||
useEffect(() => onThreadEditOpen(beginEditHold), [beginEditHold])
|
||||
useEffect(() => onThreadEditClose(endEditHold), [endEditHold])
|
||||
useEffect(() => () => endEditHold(), [endEditHold])
|
||||
// New run → snap to the latest turn.
|
||||
useAuiEvent('thread.runStart', () => void scrollToBottom())
|
||||
|
||||
// Reset the cap and pin to bottom on mount + every session switch (messages
|
||||
// swap in place on a long-lived runtime, so sessionKey is the only signal).
|
||||
// The swap is multi-step and lays out over many frames; letting the library
|
||||
// follow re-pins every frame to a moving target — visible as ~10 scroll jumps.
|
||||
// Instead: quiet it, glue to the true bottom until the height holds steady,
|
||||
// then hand back locked. Live streaming afterward uses the normal resize follow.
|
||||
useLayoutEffect(() => {
|
||||
setRenderBudget(RENDER_BUDGET)
|
||||
|
||||
const el = scrollRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
stopScroll()
|
||||
el.scrollTop = el.scrollHeight
|
||||
|
||||
let frame = 0
|
||||
let stableFrames = 0
|
||||
let lastHeight = el.scrollHeight
|
||||
|
||||
const settle = () => {
|
||||
const node = scrollRef.current
|
||||
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
|
||||
const height = node.scrollHeight
|
||||
|
||||
stableFrames = height === lastHeight ? stableFrames + 1 : 0
|
||||
lastHeight = height
|
||||
node.scrollTop = height
|
||||
|
||||
// ~5 steady frames ≈ layout has settled; the frame cap bounds slow loads.
|
||||
if (stableFrames >= 5 || ++frame > 90) {
|
||||
void scrollToBottom('instant')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(settle)
|
||||
}
|
||||
|
||||
let rafId = requestAnimationFrame(settle)
|
||||
|
||||
return () => cancelAnimationFrame(rafId)
|
||||
}, [scrollRef, scrollToBottom, sessionKey, stopScroll])
|
||||
|
||||
// Prepend an older page while preserving the on-screen position. The user is
|
||||
// scrolled up (reading history) so the stick-to-bottom lock is escaped and
|
||||
// won't fight this manual restore.
|
||||
const showEarlier = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
|
||||
restoreFromBottomRef.current = el ? el.scrollHeight - el.scrollTop : null
|
||||
setRenderBudget(budget => budget + RENDER_BUDGET)
|
||||
}, [scrollRef])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current
|
||||
|
||||
if (el && restoreFromBottomRef.current != null) {
|
||||
el.scrollTop = el.scrollHeight - restoreFromBottomRef.current
|
||||
restoreFromBottomRef.current = null
|
||||
}
|
||||
}, [scrollRef, renderBudget])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative min-h-0 max-w-full overflow-hidden contain-[layout_paint]"
|
||||
style={{ height: clampToComposer ? 'var(--thread-viewport-height)' : '100%' }}
|
||||
>
|
||||
<div
|
||||
className="size-full overflow-x-hidden overflow-y-auto overscroll-contain"
|
||||
data-following={isAtBottom ? 'true' : 'false'}
|
||||
data-slot="aui_thread-viewport"
|
||||
ref={scrollRef as React.RefCallback<HTMLDivElement>}
|
||||
>
|
||||
{renderEmpty ? (
|
||||
<div
|
||||
className="mx-auto grid h-full w-full max-w-(--composer-width) grid-rows-[minmax(0,1fr)_auto] min-w-0 gap-(--conversation-turn-gap) px-6 py-8"
|
||||
data-slot="aui_thread-content"
|
||||
>
|
||||
{emptyPlaceholder}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'mx-auto flex w-full max-w-(--composer-width) min-w-0 flex-col px-6 pt-[calc(var(--titlebar-height)+1.5rem)]'
|
||||
)}
|
||||
data-slot="aui_thread-content"
|
||||
ref={contentRef as React.RefCallback<HTMLDivElement>}
|
||||
>
|
||||
{hiddenCount > 0 && (
|
||||
<button
|
||||
className="mx-auto mb-(--conversation-turn-gap) rounded-full border border-border/65 bg-(--composer-fill) px-3 py-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={showEarlier}
|
||||
type="button"
|
||||
>
|
||||
{t.assistant.thread.showEarlier}
|
||||
</button>
|
||||
)}
|
||||
{visibleGroups.map(group => (
|
||||
<div
|
||||
className="flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap)"
|
||||
key={group.id}
|
||||
>
|
||||
<MessageRenderBoundary resetKey={messageSignature}>
|
||||
{group.kind === 'turn' ? (
|
||||
<div
|
||||
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
|
||||
data-slot="aui_turn-pair"
|
||||
>
|
||||
{group.indices.map(index => (
|
||||
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ThreadPrimitive.MessageByIndex components={components} index={group.index} />
|
||||
)}
|
||||
</MessageRenderBoundary>
|
||||
</div>
|
||||
))}
|
||||
{loadingIndicator}
|
||||
{clampToComposer && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="shrink-0"
|
||||
data-slot="aui_composer-clearance"
|
||||
style={{ height: 'var(--thread-last-message-clearance)' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ThreadMessageList = memo(ThreadMessageListInner)
|
||||
@@ -1,481 +0,0 @@
|
||||
import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react'
|
||||
import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual'
|
||||
import {
|
||||
type ComponentProps,
|
||||
type FC,
|
||||
memo,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef
|
||||
} from 'react'
|
||||
|
||||
import { setMutableRef } from '@/lib/mutable-ref'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
onScrollToBottomRequest,
|
||||
resetThreadScroll,
|
||||
setThreadJumpButtonVisible,
|
||||
setThreadScrolledUp
|
||||
} from '@/store/thread-scroll'
|
||||
|
||||
import { MessageRenderBoundary } from './message-render-boundary'
|
||||
|
||||
const ESTIMATED_ITEM_HEIGHT = 220
|
||||
const OVERSCAN = 4
|
||||
const AT_BOTTOM_THRESHOLD = 4
|
||||
// Reveal the floating jump button only once scrolled meaningfully away — above
|
||||
// AT_BOTTOM_THRESHOLD so a sub-pixel settle never flashes it.
|
||||
const JUMP_BUTTON_THRESHOLD = 10
|
||||
|
||||
type ThreadMessageComponents = ComponentProps<typeof ThreadPrimitive.MessageByIndex>['components']
|
||||
|
||||
type MessageGroup = { id: string; index: number; kind: 'standalone' } | { id: string; indices: number[]; kind: 'turn' }
|
||||
|
||||
interface VirtualizedThreadProps {
|
||||
clampToComposer: boolean
|
||||
components: ThreadMessageComponents
|
||||
emptyPlaceholder?: ReactNode
|
||||
loadingIndicator?: ReactNode
|
||||
sessionKey?: string | null
|
||||
}
|
||||
|
||||
function buildGroups(signature: string): MessageGroup[] {
|
||||
if (!signature) {
|
||||
return []
|
||||
}
|
||||
|
||||
const messages = signature.split('\n').map(row => {
|
||||
const [index, id, role] = row.split(':')
|
||||
|
||||
return { id, index: Number(index), role }
|
||||
})
|
||||
|
||||
const groups: MessageGroup[] = []
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i]
|
||||
|
||||
if (message.role !== 'user') {
|
||||
groups.push({ id: message.id, index: message.index, kind: 'standalone' })
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const indices = [message.index]
|
||||
|
||||
while (i + 1 < messages.length && messages[i + 1].role !== 'user') {
|
||||
indices.push(messages[++i].index)
|
||||
}
|
||||
|
||||
groups.push({ id: message.id, indices, kind: 'turn' })
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
const VirtualizedThreadInner: FC<VirtualizedThreadProps> = ({
|
||||
clampToComposer,
|
||||
components,
|
||||
emptyPlaceholder,
|
||||
loadingIndicator,
|
||||
sessionKey
|
||||
}) => {
|
||||
const messageSignature = useAuiState(s =>
|
||||
s.thread.messages.map((message, index) => `${index}:${message.id}:${message.role}`).join('\n')
|
||||
)
|
||||
|
||||
const isRunning = useAuiState(s => s.thread.isRunning)
|
||||
|
||||
const groups = useMemo(() => buildGroups(messageSignature), [messageSignature])
|
||||
const renderEmpty = groups.length === 0 && Boolean(emptyPlaceholder)
|
||||
const scrollerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
// Shared ref so scrollToFn can check whether the user is parked at the
|
||||
// bottom without needing a ref from inside useThreadScrollAnchor.
|
||||
const stickyBottomRef = useRef(true)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: groups.length,
|
||||
estimateSize: () => ESTIMATED_ITEM_HEIGHT,
|
||||
getItemKey: index => groups[index]?.id ?? index,
|
||||
getScrollElement: () => scrollerRef.current,
|
||||
// Seed the rect so the initial range mounts something before
|
||||
// `observeElementRect` reports the real layout (it overrides this).
|
||||
initialRect: { height: 600, width: 800 },
|
||||
overscan: OVERSCAN,
|
||||
// When the virtualizer adjusts scroll due to item measurement changes,
|
||||
// skip the adjustment if the user is at the bottom. Our ResizeObserver +
|
||||
// pinToBottom loop handles scroll anchoring; letting the virtualizer also
|
||||
// adjust creates a feedback loop where the two fight each other,
|
||||
// producing visible rubber-banding (the view snaps to the composer
|
||||
// then jumps back up).
|
||||
scrollToFn: (offset, _options, instance) => {
|
||||
const el = instance.scrollElement
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
if (stickyBottomRef.current) {
|
||||
const maxScroll = el.scrollHeight - el.clientHeight
|
||||
const distFromBottom = maxScroll - el.scrollTop
|
||||
|
||||
if (distFromBottom <= AT_BOTTOM_THRESHOLD && offset < maxScroll) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
;(el as HTMLElement).scrollTo(0, offset)
|
||||
}
|
||||
})
|
||||
|
||||
useThreadScrollAnchor({
|
||||
enabled: !renderEmpty,
|
||||
groupCount: groups.length,
|
||||
isRunning,
|
||||
scrollerRef,
|
||||
sessionKey: sessionKey ?? null,
|
||||
stickyBottomRef,
|
||||
virtualizer
|
||||
})
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems()
|
||||
const totalSize = virtualizer.getTotalSize()
|
||||
const paddingTop = virtualItems[0]?.start ?? 0
|
||||
const paddingBottom = Math.max(0, totalSize - (virtualItems.at(-1)?.end ?? 0))
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative min-h-0 max-w-full overflow-hidden contain-[layout_paint]"
|
||||
style={{ height: clampToComposer ? 'var(--thread-viewport-height)' : '100%' }}
|
||||
>
|
||||
<div
|
||||
className="size-full overflow-x-hidden overflow-y-auto overscroll-contain"
|
||||
data-slot="aui_thread-viewport"
|
||||
ref={scrollerRef}
|
||||
>
|
||||
{renderEmpty ? (
|
||||
<div
|
||||
className="mx-auto grid h-full w-full max-w-(--composer-width) grid-rows-[minmax(0,1fr)_auto] min-w-0 gap-(--conversation-turn-gap) px-6 py-8"
|
||||
data-slot="aui_thread-content"
|
||||
>
|
||||
{emptyPlaceholder}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'mx-auto flex w-full max-w-(--composer-width) min-w-0 flex-col px-6 pt-[calc(var(--titlebar-height)+1.5rem)]'
|
||||
)}
|
||||
data-slot="aui_thread-content"
|
||||
>
|
||||
{/* Natural-flow virtualization: mounted items render as normal
|
||||
flex siblings so `position: sticky` on the human bubble
|
||||
resolves against the scroller without transform interference.
|
||||
Padding spacers reserve scroll space for unmounted items. */}
|
||||
<div style={{ paddingBottom: `${paddingBottom}px`, paddingTop: `${paddingTop}px` }}>
|
||||
{virtualItems.map(virtualItem => {
|
||||
const group = groups[virtualItem.index]
|
||||
|
||||
if (!group) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap)"
|
||||
data-index={virtualItem.index}
|
||||
key={virtualItem.key}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<MessageRenderBoundary resetKey={messageSignature}>
|
||||
{group.kind === 'turn' ? (
|
||||
<div
|
||||
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
|
||||
data-slot="aui_turn-pair"
|
||||
>
|
||||
{group.indices.map(index => (
|
||||
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ThreadPrimitive.MessageByIndex components={components} index={group.index} />
|
||||
)}
|
||||
</MessageRenderBoundary>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{loadingIndicator}
|
||||
{clampToComposer && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="shrink-0"
|
||||
data-slot="aui_composer-clearance"
|
||||
style={{ height: 'var(--thread-last-message-clearance)' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const VirtualizedThread = memo(VirtualizedThreadInner)
|
||||
|
||||
function scrollElementToBottom(el: HTMLDivElement) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
|
||||
interface ScrollAnchorOptions {
|
||||
enabled: boolean
|
||||
groupCount: number
|
||||
isRunning: boolean
|
||||
scrollerRef: React.RefObject<HTMLDivElement | null>
|
||||
sessionKey: string | null
|
||||
stickyBottomRef: React.MutableRefObject<boolean>
|
||||
virtualizer: Virtualizer<HTMLDivElement, Element>
|
||||
}
|
||||
|
||||
function useThreadScrollAnchor({
|
||||
enabled,
|
||||
groupCount,
|
||||
isRunning,
|
||||
scrollerRef,
|
||||
sessionKey,
|
||||
stickyBottomRef,
|
||||
virtualizer
|
||||
}: ScrollAnchorOptions) {
|
||||
// `stickyBottomRef` = parked at bottom, content growth should follow. Cleared on
|
||||
// user-driven upward scroll; re-armed when they reach bottom again.
|
||||
// This is a shared ref — scrollToFn reads it to prevent the virtualizer's
|
||||
// measurement adjustments from fighting our pinToBottom.
|
||||
const lastTopRef = useRef(0)
|
||||
const lastHeightRef = useRef(0)
|
||||
const lastClientHeightRef = useRef(0)
|
||||
// Counter that tracks how many scroll events we expect to be ours rather
|
||||
// than the user's. `pinToBottom` writes `el.scrollTop`, which fires an
|
||||
// async `scroll` event; without this guard the on-scroll handler can race
|
||||
// with the programmatic write (because content also grew, the *resulting*
|
||||
// scrollTop can be lower than `lastTopRef` from the previous frame) and
|
||||
// misread the programmatic pin as the user scrolling up — which disarms
|
||||
// sticky-bottom and the user's just-submitted message slides above the
|
||||
// fold. See `apps/desktop/scripts/measure-jump.mjs` for the repro
|
||||
// (distFromBottom 0 → 49 within one frame, sticking forever).
|
||||
const programmaticScrollPendingRef = useRef(0)
|
||||
const prevSessionKeyRef = useRef(sessionKey)
|
||||
const prevGroupCountRef = useRef(0)
|
||||
|
||||
const pinToBottom = useCallback(() => {
|
||||
const el = scrollerRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
// Already parked at the bottom: writing `scrollTop` is a no-op and the
|
||||
// browser fires NO scroll event, so arming the programmatic gate here would
|
||||
// leave it permanently set. Repeated pins (streaming heartbeats, the
|
||||
// post-run lock loop) then accumulate the gate, and the next genuine user
|
||||
// scroll-up is misread as one of our programmatic scrolls — re-arming
|
||||
// sticky-bottom and yanking the viewport back down. Refresh trackers, bail.
|
||||
const distFromBottom = el.scrollHeight - (el.scrollTop + el.clientHeight)
|
||||
|
||||
if (distFromBottom <= AT_BOTTOM_THRESHOLD) {
|
||||
lastTopRef.current = el.scrollTop
|
||||
lastHeightRef.current = el.scrollHeight
|
||||
lastClientHeightRef.current = el.clientHeight
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Hold the disarm gate across the scroll event the next line will fire.
|
||||
// Set to 1 rather than incrementing: coalesced writes within a frame fire a
|
||||
// single scroll event, so a counter > 1 can never drain and would swallow a
|
||||
// later real user scroll.
|
||||
programmaticScrollPendingRef.current = 1
|
||||
scrollElementToBottom(el)
|
||||
lastTopRef.current = el.scrollTop
|
||||
lastHeightRef.current = el.scrollHeight
|
||||
lastClientHeightRef.current = el.clientHeight
|
||||
}, [scrollerRef])
|
||||
|
||||
const jumpToBottom = useCallback(() => {
|
||||
setMutableRef(stickyBottomRef, true)
|
||||
|
||||
if (groupCount > 0) {
|
||||
virtualizer.scrollToIndex(groupCount - 1, { align: 'end', behavior: 'auto' })
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (stickyBottomRef.current) {
|
||||
pinToBottom()
|
||||
}
|
||||
})
|
||||
}, [groupCount, pinToBottom, stickyBottomRef, virtualizer])
|
||||
|
||||
useEffect(() => () => resetThreadScroll(), [])
|
||||
|
||||
// Track at-bottom state, dim composer when scrolled up, disarm on user
|
||||
// scroll/wheel/touch.
|
||||
useEffect(() => {
|
||||
const el = scrollerRef.current
|
||||
|
||||
if (!el) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const disarm = () => {
|
||||
setMutableRef(stickyBottomRef, false)
|
||||
programmaticScrollPendingRef.current = 0
|
||||
}
|
||||
|
||||
// Dim the composer the instant we leave the bottom; reveal the jump button
|
||||
// only once scrolled meaningfully away.
|
||||
const publishScrollDistance = (dist: number) => {
|
||||
setThreadScrolledUp(dist > AT_BOTTOM_THRESHOLD)
|
||||
setThreadJumpButtonVisible(dist > JUMP_BUTTON_THRESHOLD)
|
||||
}
|
||||
|
||||
const onScroll = () => {
|
||||
const top = el.scrollTop
|
||||
|
||||
// If this scroll event is the consequence of `pinToBottom` writing
|
||||
// `el.scrollTop`, treat it as ours: don't disarm. The RO + rAF pin
|
||||
// loop will re-pin on the next frame if the browser clamped us
|
||||
// short of bottom (because content grew in the same frame).
|
||||
// Without this guard the post-pin scrollTop gets misread as the
|
||||
// user scrolling up, disarming sticky-bottom permanently and
|
||||
// leaving the just-submitted message below the fold.
|
||||
if (programmaticScrollPendingRef.current > 0) {
|
||||
programmaticScrollPendingRef.current -= 1
|
||||
lastTopRef.current = top
|
||||
lastHeightRef.current = el.scrollHeight
|
||||
lastClientHeightRef.current = el.clientHeight
|
||||
// Always re-arm — sticky-bottom should hold through clamp races.
|
||||
setMutableRef(stickyBottomRef, true)
|
||||
publishScrollDistance(el.scrollHeight - (top + el.clientHeight))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Disarm on ANY upward movement (even 1px), but only while content +
|
||||
// viewport height are stable — virtualizer measurement, streaming
|
||||
// markdown, and composer/window resize all shift scrollTop as a layout
|
||||
// side effect. Wheel-up and touchmove disarm immediately too (below).
|
||||
const heightGrew = el.scrollHeight > lastHeightRef.current
|
||||
const clientHeightChanged = Math.abs(el.clientHeight - lastClientHeightRef.current) > 1
|
||||
|
||||
if (!heightGrew && !clientHeightChanged && top < lastTopRef.current) {
|
||||
setMutableRef(stickyBottomRef, false)
|
||||
}
|
||||
|
||||
lastTopRef.current = top
|
||||
lastHeightRef.current = el.scrollHeight
|
||||
lastClientHeightRef.current = el.clientHeight
|
||||
|
||||
const distFromBottom = el.scrollHeight - (top + el.clientHeight)
|
||||
|
||||
// Re-arm follow only once genuinely back at the bottom.
|
||||
if (distFromBottom <= AT_BOTTOM_THRESHOLD) {
|
||||
setMutableRef(stickyBottomRef, true)
|
||||
}
|
||||
|
||||
publishScrollDistance(distFromBottom)
|
||||
}
|
||||
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
if (event.deltaY < 0) {
|
||||
disarm()
|
||||
}
|
||||
}
|
||||
|
||||
el.addEventListener('scroll', onScroll, { passive: true })
|
||||
el.addEventListener('wheel', onWheel, { passive: true })
|
||||
el.addEventListener('touchmove', disarm, { passive: true })
|
||||
|
||||
return () => {
|
||||
el.removeEventListener('scroll', onScroll)
|
||||
el.removeEventListener('wheel', onWheel)
|
||||
el.removeEventListener('touchmove', disarm)
|
||||
}
|
||||
}, [scrollerRef, stickyBottomRef])
|
||||
|
||||
// No streaming auto-follow: chasing content growth while parked at the bottom
|
||||
// rubber-bands (the tail and the virtualizer's own measurement adjustments
|
||||
// fight for scrollTop). The one-time new-turn jump below already lands a fresh
|
||||
// message in view; from there the viewport stays put unless the user jumps.
|
||||
|
||||
// The floating jump button asks us to return to the bottom; same re-arm + pin
|
||||
// path as a new turn.
|
||||
useEffect(() => onScrollToBottomRequest(jumpToBottom), [jumpToBottom])
|
||||
|
||||
// Jump to bottom on session change OR when an empty thread first gets
|
||||
// content. Both share the same intent and the same effect.
|
||||
useEffect(() => {
|
||||
const sessionChanged = prevSessionKeyRef.current !== sessionKey
|
||||
const becameNonEmpty = prevGroupCountRef.current === 0 && groupCount > 0
|
||||
|
||||
prevSessionKeyRef.current = sessionKey
|
||||
prevGroupCountRef.current = groupCount
|
||||
|
||||
if (enabled && (sessionChanged || becameNonEmpty)) {
|
||||
jumpToBottom()
|
||||
}
|
||||
}, [enabled, groupCount, jumpToBottom, sessionKey])
|
||||
|
||||
// Pre-paint pin: when groupCount increases while armed (a new turn arriving
|
||||
// from the user submit or assistant turn start), pin BEFORE the browser
|
||||
// commits the layout to screen. Using useLayoutEffect rather than useEffect
|
||||
// so this runs synchronously after React commits the DOM mutation but before
|
||||
// the browser paints. Without this, there's a ~50ms visual window where the
|
||||
// new message sits below the fold.
|
||||
//
|
||||
// We pin TWICE in this critical path — once synchronously, then once on
|
||||
// the next rAF. The second pin catches the case where React mounts the
|
||||
// new message in the second commit (after our layout effect ran), which
|
||||
// grows scrollHeight again; without the rAF pin the user briefly sees a
|
||||
// ~15 px gap below the new message. This fires once per user submit / new
|
||||
// turn arrival — it is NOT streaming-token follow (that path is removed
|
||||
// above), so a turn that streams a long response after this initial jump
|
||||
// will not chase the bottom.
|
||||
const prevGroupCountForLayoutRef = useRef(groupCount)
|
||||
useLayoutEffect(() => {
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (groupCount > prevGroupCountForLayoutRef.current && stickyBottomRef.current) {
|
||||
// Defer to rAF so that browser scroll/wheel events from the current
|
||||
// frame are processed first. Without this deferral, a trackpad
|
||||
// scroll-up during streaming can race with this effect: the wheel
|
||||
// event hasn't fired yet so stickyBottomRef is still true, and the
|
||||
// immediate pinToBottom() would snap the viewport back to bottom
|
||||
// against the user's intent.
|
||||
requestAnimationFrame(() => {
|
||||
if (stickyBottomRef.current) {
|
||||
pinToBottom()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
prevGroupCountForLayoutRef.current = groupCount
|
||||
}, [enabled, groupCount, pinToBottom, stickyBottomRef])
|
||||
|
||||
// Intentionally NO post-run bottom lock. Earlier builds kept pinning to
|
||||
// the bottom for POST_RUN_BOTTOM_LOCK_MS after `isRunning` flipped false to
|
||||
// chase final Shiki re-highlight measurement. With streaming follow gone,
|
||||
// re-pinning at completion would yank the viewport back to the bottom even
|
||||
// though the user is reading earlier content — the opposite of what's
|
||||
// wanted. The one-time submit / new-turn jump already covers landing a
|
||||
// fresh message in view.
|
||||
const prevIsRunningForLayoutRef = useRef(isRunning)
|
||||
useLayoutEffect(() => {
|
||||
prevIsRunningForLayoutRef.current = isRunning
|
||||
}, [isRunning])
|
||||
|
||||
useAuiEvent('thread.runStart', jumpToBottom)
|
||||
}
|
||||
@@ -63,7 +63,7 @@ import { uploadComposerAttachment } from '@/app/session/hooks/use-prompt-actions
|
||||
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
|
||||
import { DirectiveContent, hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
||||
import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-text'
|
||||
import { VirtualizedThread } from '@/components/assistant-ui/thread-virtualizer'
|
||||
import { ThreadMessageList } from '@/components/assistant-ui/thread-list'
|
||||
import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool-fallback'
|
||||
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
|
||||
import { UserMessageText } from '@/components/assistant-ui/user-message-text'
|
||||
@@ -100,6 +100,7 @@ import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $connection } from '@/store/session'
|
||||
import { notifyThreadEditClose, notifyThreadEditOpen } from '@/store/thread-scroll'
|
||||
import { $voicePlayback } from '@/store/voice-playback'
|
||||
|
||||
type ThreadLoadingState = 'response' | 'session'
|
||||
@@ -202,7 +203,7 @@ export const Thread: FC<{
|
||||
return (
|
||||
<GeneratedImageProvider>
|
||||
<div className="relative grid h-full min-h-0 max-w-full grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent contain-[layout_paint]">
|
||||
<VirtualizedThread
|
||||
<ThreadMessageList
|
||||
clampToComposer={clampToComposer}
|
||||
components={messageComponents}
|
||||
emptyPlaceholder={emptyPlaceholder}
|
||||
@@ -956,7 +957,10 @@ const UserMessage: FC<{
|
||||
// backtick `code` and ``` fenced ``` blocks, with directive chips
|
||||
// (`@file:` etc.) still resolved inside the plain-text spans.
|
||||
<div className="sticky-human-clamp" data-clamped={bodyClamped ? 'true' : undefined}>
|
||||
<div ref={clampInnerRef}>
|
||||
{/* Match the edit composer's collapsed line box (min-h-[1.25rem]) so
|
||||
clicking to edit can't grow the bubble by a sub-pixel and reflow the
|
||||
turn 1px. */}
|
||||
<div className="min-h-[1.25rem]" ref={clampInnerRef}>
|
||||
<UserMessageText className="wrap-anywhere" text={messageText} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -986,6 +990,7 @@ const UserMessage: FC<{
|
||||
aria-label={copy.editMessage}
|
||||
className={bubbleClassName}
|
||||
onClick={() => triggerHaptic('selection')}
|
||||
onPointerDown={() => notifyThreadEditOpen()}
|
||||
title={copy.editMessage}
|
||||
type="button"
|
||||
>
|
||||
@@ -1175,6 +1180,8 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
const at = useAtCompletions({ cwd, gateway, sessionId })
|
||||
const slash = useSlashCompletions({ gateway })
|
||||
|
||||
useEffect(() => () => notifyThreadEditClose(), [])
|
||||
|
||||
const focusEditor = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
|
||||
@@ -1700,7 +1707,6 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
aria-label={copy.editMessage}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
autoFocus
|
||||
className={cn(
|
||||
'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 outline-none',
|
||||
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
|
||||
|
||||
Reference in New Issue
Block a user