opentui(v5b/item4): hold viewport on tool/thinking expand (no scroll jump)
The transcript scrollbox (stickyScroll+stickyStart=bottom) re-pins to the bottom on any content-height change when the user is at the bottom (@opentui/core ScrollBox: `if (stickyStart && !_hasManualScroll) applyStickyStart`). So expanding a tool/thinking block scrolled the clicked header up off-screen. A ScrollAnchorProvider (transcript owns the scrollbox ref) lets toolPart/reasoningPart wrap their toggle so scrollTop is held constant across the height change (re-asserted over a few frames as layout settles) — the clicked header stays put and the expansion reveals beneath it. 85 pass.
This commit is contained in:
@@ -14,6 +14,7 @@
|
|||||||
import { createMemo, createSignal, Show } from 'solid-js'
|
import { createMemo, createSignal, Show } from 'solid-js'
|
||||||
|
|
||||||
import { Markdown } from './markdown.tsx'
|
import { Markdown } from './markdown.tsx'
|
||||||
|
import { useScrollAnchor } from './scrollAnchor.tsx'
|
||||||
import { useTheme } from './theme.tsx'
|
import { useTheme } from './theme.tsx'
|
||||||
|
|
||||||
const GUTTER = 2
|
const GUTTER = 2
|
||||||
@@ -29,9 +30,11 @@ function reasoningSummary(text: string): { title?: string; body: string } {
|
|||||||
|
|
||||||
export function ReasoningPart(props: { text: string; streaming?: boolean }) {
|
export function ReasoningPart(props: { text: string; streaming?: boolean }) {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
|
const anchor = useScrollAnchor()
|
||||||
const [override, setOverride] = createSignal<boolean | undefined>(undefined)
|
const [override, setOverride] = createSignal<boolean | undefined>(undefined)
|
||||||
// live → expanded so you see it think; settled → collapsed. Click overrides.
|
// live → expanded so you see it think; settled → collapsed. Click overrides.
|
||||||
const expanded = () => override() ?? !!props.streaming
|
const expanded = () => override() ?? !!props.streaming
|
||||||
|
const toggle = () => anchor(() => setOverride(e => !(e ?? !!props.streaming)))
|
||||||
const summary = createMemo(() => reasoningSummary(props.text))
|
const summary = createMemo(() => reasoningSummary(props.text))
|
||||||
const label = () => (props.streaming ? 'Thinking' : 'Thought')
|
const label = () => (props.streaming ? 'Thinking' : 'Thought')
|
||||||
|
|
||||||
@@ -40,7 +43,7 @@ export function ReasoningPart(props: { text: string; streaming?: boolean }) {
|
|||||||
<box style={{ flexDirection: 'column', flexShrink: 0 }}>
|
<box style={{ flexDirection: 'column', flexShrink: 0 }}>
|
||||||
<box
|
<box
|
||||||
style={{ flexDirection: 'row', flexShrink: 0 }}
|
style={{ flexDirection: 'row', flexShrink: 0 }}
|
||||||
onMouseDown={() => setOverride(e => !(e ?? !!props.streaming))}
|
onMouseDown={toggle}
|
||||||
>
|
>
|
||||||
<box style={{ flexShrink: 0, width: GUTTER }}>
|
<box style={{ flexShrink: 0, width: GUTTER }}>
|
||||||
<text selectable={false}>
|
<text selectable={false}>
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Scroll anchoring for collapse/expand toggles (item #4). The transcript
|
||||||
|
* <scrollbox> has stickyScroll+stickyStart="bottom": on a content-height change
|
||||||
|
* it re-pins to the bottom whenever the user hasn't manually scrolled away
|
||||||
|
* (@opentui/core ScrollBox: `if (stickyStart && !_hasManualScroll) applyStickyStart`).
|
||||||
|
* So expanding a tool/thinking block while at the bottom yanks the viewport to the
|
||||||
|
* NEW bottom — scrolling the header you just clicked up off-screen.
|
||||||
|
*
|
||||||
|
* Fix: keep scrollTop constant across the toggle. The clicked element's document
|
||||||
|
* position is unchanged (content grows BELOW it), so holding scrollTop keeps that
|
||||||
|
* header at the same screen row and simply reveals the expansion beneath it. We
|
||||||
|
* re-assert the saved offset over a few frames because the content height (and the
|
||||||
|
* sticky re-pin) only settle on the next render pass.
|
||||||
|
*/
|
||||||
|
import { type Accessor, createContext, type JSX, useContext } from 'solid-js'
|
||||||
|
|
||||||
|
import type { ScrollBoxRenderable } from '@opentui/core'
|
||||||
|
|
||||||
|
type AnchorFn = (toggle: () => void) => void
|
||||||
|
|
||||||
|
const Ctx = createContext<AnchorFn>()
|
||||||
|
|
||||||
|
export function ScrollAnchorProvider(props: { scroll: Accessor<ScrollBoxRenderable | undefined>; children: JSX.Element }) {
|
||||||
|
const around: AnchorFn = toggle => {
|
||||||
|
const sb = props.scroll()
|
||||||
|
if (!sb) {
|
||||||
|
toggle()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const prev = sb.scrollTop
|
||||||
|
toggle()
|
||||||
|
// Re-assert across the next few frames: the layout + sticky re-pin land on
|
||||||
|
// subsequent render passes, so a single sync restore wouldn't hold.
|
||||||
|
let n = 0
|
||||||
|
const hold = () => {
|
||||||
|
try {
|
||||||
|
sb.scrollTo(prev)
|
||||||
|
} catch {
|
||||||
|
/* renderable torn down */
|
||||||
|
}
|
||||||
|
if (++n < 4) setTimeout(hold, 16)
|
||||||
|
}
|
||||||
|
setTimeout(hold, 0)
|
||||||
|
}
|
||||||
|
return <Ctx.Provider value={around}>{props.children}</Ctx.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrap a collapse/expand toggle so the viewport stays put (no-op outside a provider). */
|
||||||
|
export function useScrollAnchor(): AnchorFn {
|
||||||
|
return useContext(Ctx) ?? (toggle => toggle())
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import { useDimensions } from './dimensions.tsx'
|
|||||||
import { createMemo, createSignal, For, Show } from 'solid-js'
|
import { createMemo, createSignal, For, Show } from 'solid-js'
|
||||||
|
|
||||||
import { collapseToolOutput, truncate } from '../logic/toolOutput.ts'
|
import { collapseToolOutput, truncate } from '../logic/toolOutput.ts'
|
||||||
|
import { useScrollAnchor } from './scrollAnchor.tsx'
|
||||||
import { useTheme } from './theme.tsx'
|
import { useTheme } from './theme.tsx'
|
||||||
|
|
||||||
const GUTTER = 2
|
const GUTTER = 2
|
||||||
@@ -37,7 +38,9 @@ function fmtDuration(s: number): string {
|
|||||||
export function ToolPart(props: { part: ToolPartState }) {
|
export function ToolPart(props: { part: ToolPartState }) {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const dims = useDimensions()
|
const dims = useDimensions()
|
||||||
|
const anchor = useScrollAnchor()
|
||||||
const [expanded, setExpanded] = createSignal(false)
|
const [expanded, setExpanded] = createSignal(false)
|
||||||
|
const toggle = () => anchor(() => setExpanded(e => !e))
|
||||||
|
|
||||||
const bodyWidth = () => Math.max(20, dims().width - GUTTER - 4)
|
const bodyWidth = () => Math.max(20, dims().width - GUTTER - 4)
|
||||||
const result = () => (props.part.resultText ?? '').replace(/\s+$/, '')
|
const result = () => (props.part.resultText ?? '').replace(/\s+$/, '')
|
||||||
@@ -91,7 +94,7 @@ export function ToolPart(props: { part: ToolPartState }) {
|
|||||||
// margins — so a tool appearing mid-stream doesn't shift the layout (item 5).
|
// margins — so a tool appearing mid-stream doesn't shift the layout (item 5).
|
||||||
<box style={{ flexDirection: 'column', flexShrink: 0 }}>
|
<box style={{ flexDirection: 'column', flexShrink: 0 }}>
|
||||||
{/* header — clickable to toggle when there's expandable output/args */}
|
{/* header — clickable to toggle when there's expandable output/args */}
|
||||||
<box style={{ flexDirection: 'row', flexShrink: 0 }} onMouseDown={() => collapsible() && setExpanded(e => !e)}>
|
<box style={{ flexDirection: 'row', flexShrink: 0 }} onMouseDown={() => collapsible() && toggle()}>
|
||||||
<box style={{ flexShrink: 0, width: GUTTER }}>
|
<box style={{ flexShrink: 0, width: GUTTER }}>
|
||||||
<text selectable={false}>
|
<text selectable={false}>
|
||||||
<span style={{ fg: headColor() }}>{headGlyph()}</span>
|
<span style={{ fg: headColor() }}>{headGlyph()}</span>
|
||||||
|
|||||||
@@ -10,22 +10,30 @@
|
|||||||
* viewport/content children; setting it there breaks content-height
|
* viewport/content children; setting it there breaks content-height
|
||||||
* measurement → phantom scroll offset that clips the top + leaves a gap),
|
* measurement → phantom scroll offset that clips the top + leaves a gap),
|
||||||
* - `stickyScroll` + `stickyStart="bottom"` to pin the latest line.
|
* - `stickyScroll` + `stickyStart="bottom"` to pin the latest line.
|
||||||
|
*
|
||||||
|
* A `ScrollAnchorProvider` gives collapse/expand toggles (tool/thinking) a handle
|
||||||
|
* to hold the viewport in place so expanding doesn't yank to the bottom (#4).
|
||||||
*/
|
*/
|
||||||
import { For, Show } from 'solid-js'
|
import type { ScrollBoxRenderable } from '@opentui/core'
|
||||||
|
import { createSignal, For, Show } from 'solid-js'
|
||||||
|
|
||||||
import type { SessionStore } from '../logic/store.ts'
|
import type { SessionStore } from '../logic/store.ts'
|
||||||
import { HomeHint } from './homeHint.tsx'
|
import { HomeHint } from './homeHint.tsx'
|
||||||
import { MessageLine } from './messageLine.tsx'
|
import { MessageLine } from './messageLine.tsx'
|
||||||
|
import { ScrollAnchorProvider } from './scrollAnchor.tsx'
|
||||||
|
|
||||||
export function Transcript(props: { store: SessionStore }) {
|
export function Transcript(props: { store: SessionStore }) {
|
||||||
|
const [scroll, setScroll] = createSignal<ScrollBoxRenderable | undefined>()
|
||||||
return (
|
return (
|
||||||
<box style={{ flexGrow: 1, minHeight: 0, marginTop: 1 }}>
|
<box style={{ flexGrow: 1, minHeight: 0, marginTop: 1 }}>
|
||||||
<scrollbox style={{ flexGrow: 1, minHeight: 0 }} stickyScroll stickyStart="bottom">
|
<scrollbox ref={setScroll} style={{ flexGrow: 1, minHeight: 0 }} stickyScroll stickyStart="bottom">
|
||||||
{/* empty-transcript home screen (item 12); replaced by messages on the first turn */}
|
<ScrollAnchorProvider scroll={scroll}>
|
||||||
<Show when={props.store.state.messages.length === 0}>
|
{/* empty-transcript home screen (item 12); replaced by messages on the first turn */}
|
||||||
<HomeHint catalog={props.store.state.catalog} />
|
<Show when={props.store.state.messages.length === 0}>
|
||||||
</Show>
|
<HomeHint catalog={props.store.state.catalog} />
|
||||||
<For each={props.store.state.messages}>{message => <MessageLine message={message} />}</For>
|
</Show>
|
||||||
|
<For each={props.store.state.messages}>{message => <MessageLine message={message} />}</For>
|
||||||
|
</ScrollAnchorProvider>
|
||||||
</scrollbox>
|
</scrollbox>
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user