fix(app): make the turn navigator easier to hit and land on the right turn (#57572)

This commit is contained in:
Kit Langton
2026-10-20 21:24:19 -07:00
committed by GitHub
Unverified
parent d8bbf79ba5
commit 1e581ee373
6 changed files with 327 additions and 126 deletions
@@ -217,6 +217,52 @@ test.describe("timeline history", () => {
expect(loads.length).toBeGreaterThan(1)
})
test("keeps the turn rail in timeline order, with one preview of each turn's own reply", async ({ page }) => {
// Other clients mint IDs that do not sort by time, like the iOS app's uppercase UUIDs.
const turnID = (index: number) =>
[5, 30].includes(index) ? `msg_F${index}_ios_user` : `msg_0${String(index).padStart(4, "0")}_turn_a_user`
const messages = Array.from({ length: 40 }, (_, index) => [
userMessage([userText(`Prompt number ${index}`, { id: `prt_${index}_prompt` })], {
id: turnID(index),
created: 1690000000000 + index * 10_000,
}),
assistantMessage([{ id: `prt_${index}_reply`, type: "text", text: `Response number ${index}` }], {
id: `msg_0${String(index).padStart(4, "0")}_turn_b_assistant`,
parentID: turnID(index),
created: 1690000001000 + index * 10_000,
}),
]).flat()
await setupTimeline(page, { sessionMessages: messages, pageMessages: pageMessagesFrom({ [sessionID]: messages }) })
const navigator = page.locator('[data-component="session-turn-navigator"]')
const turns = navigator.locator("[data-turn]")
const preview = navigator.locator('[data-slot="session-turn-navigator-preview"]')
await expect(turns).toHaveCount(40)
// An unloaded turn previews its prompt, then its own reply, once each.
await turns.nth(5).hover()
await expect(preview.locator("p")).toHaveText(["Prompt number 5", "Response number 5"])
await turns.nth(5).click()
await expect(page.locator(`#message-${turnID(5)}`)).toBeInViewport()
// Scrolling closes the preview; moving on opens a single one for the turn under the pointer.
await turns.nth(20).hover()
await expect(preview).toBeVisible()
await page.mouse.wheel(0, -300)
await expect(preview).toBeHidden()
await turns.nth(30).hover()
await expect(preview.locator("p")).toHaveText(["Prompt number 30", "Response number 30"])
await expect(page.locator('[data-slot="session-turn-navigator-preview"]')).toHaveCount(1)
// The last tick is the latest turn.
await turns.nth(39).hover()
await expect(preview.locator("p")).toHaveText(["Prompt number 39", "Response number 39"])
await turns.nth(39).click()
await expect(page.locator(`#message-${turnID(39)}`)).toBeInViewport()
await expect(page.locator(`#message-${turnID(5)}`)).not.toBeInViewport()
})
test("mounts every part once and in order while paging to the start", async ({ page }) => {
test.setTimeout(240_000)
const errors = trackPageErrors(page)
@@ -303,6 +349,8 @@ test.describe("timeline history", () => {
const timeline = await setupTimeline(page, {
sessionMessages: messages,
sessionStatus: { [sessionID]: { type: "busy" } },
// The turn rail reads the transcript through its own index, which would land in the exact page log below.
settings: { showTurnNavigator: false },
viewport: { width: 646, height: 1385 },
beforeMessagesResponse: (request) => (request.before ? history.promise : Promise.resolve()),
onMessages: (request) => {
+4 -3
View File
@@ -1424,8 +1424,9 @@ function mockHandlers(
const token = ctx.query.cursor
const type = ctx.query.type
// Filter before pagination, matching the server's message index without paging the transcript.
if (type) {
// Filter before pagination, matching the server's message index without paging the transcript. A `before`
// anchor reads back from a message the same way.
if (type || ctx.query.before) {
const [order, offset] = token?.startsWith("typed:")
? [token.split(":")[1], Number(token.split(":")[2])]
: [ctx.query.order ?? "desc", 0]
@@ -1439,7 +1440,7 @@ function mockHandlers(
)
: transcript.length
const typed = transcript.slice(0, end).filter((item) => item.type === type)
const typed = transcript.slice(0, end).filter((item) => !type || item.type === type)
const ordered = order === "asc" ? typed : typed.toReversed()
const limit = ctx.query.limit ?? 50
@@ -3,7 +3,7 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useLocation } from "@solidjs/router"
import { createEffect, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { frameLoop } from "@opencode/ui/motion"
import { frameLoop, prefersReducedMotion } from "@opencode/ui/motion"
import { useLayout } from "@/shell/state/layout"
import { useData } from "@/runtime/server/current"
import type { SessionModel } from "../model"
@@ -224,8 +224,12 @@ export function createSessionTimelineInteraction(session: SessionModel) {
const message = loaded()
if (!message || !owner.current()) return
const behavior = prefersReducedMotion() ? "auto" : "smooth"
// The latest turn is the bottom of the timeline, reached the way the jump-to-latest button reaches it.
if (message.id === visibleUserMessages().at(-1)?.id) return resume(behavior)
unpin()
scrollToMessage(message, "auto")
scrollToMessage(message, behavior)
}
// A gesture inside a nested scrollable region scrolls that region, not the timeline.
@@ -476,6 +476,7 @@ function MessageTimelineView(
sessionID={id}
messages={turnMessages}
complete={() => !data.session.message.more(id)}
historyLoading={() => data.session.message.loading(id)}
assistantMessagesByParent={projection.assistantMessagesByParent}
revertMessageID={() => props.session.data.info()?.revert?.messageID}
visibleUserMessageIDs={virtualized.visibleUserMessageIDs}
@@ -1,27 +1,39 @@
import type { SessionMessageAssistant, SessionMessageInfo, SessionMessageUser } from "@opencode/client/promise"
import { createEffect, createMemo, createSelector, For, onCleanup, onMount, Show, type Accessor } from "solid-js"
import { frameLoop, prefersReducedMotion, Spring } from "@opencode/ui/motion"
import { createEffect, createMemo, createSelector, For, onCleanup, Show, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { readPromptPresentation } from "@/composer/comment-note"
import { useServerSDK } from "@/runtime/server/client"
const turnPageLimit = 200
// Previews clamp to a few lines; keep only enough text to fill them.
// Previews fade out after a few lines; keep only enough text to fill them.
const previewLength = 400
// Keep index loading clear of session entry and tab switch rendering.
// Keep index loading clear of session entry, tab switch rendering and the timeline's own history requests.
const railDelay = 1000
// The rail lists at most this many of the newest turns.
const indexLimit = 1000
// Resting on an unloaded turn fetches its reply; sweeping across the rail fetches nothing.
const replyHoverDelay = 150
const previewHoverDelay = 100
const hoverRadius = 6
// Messages read back from the next turn to find an unloaded turn's reply.
const replyScanLimit = 20
const tickWidths = { default: [7, 12], active: [12, 20], hovered: [14, 22] }
// Ticks within this many pixels of the pointer swell towards it, like a dock.
const waveRadius = 48
const tickSpanWidth = Math.max(...Object.values(tickWidths).flat())
const tickWidths = { default: 7, active: 12, peak: 22 }
// The prompt's first line sits level with its tick: card padding, bubble padding, then half a 16px line.
const previewAnchor = 20
// The preview at its tallest, with both texts clamped. Clamping to it keeps the card still while a reply loads.
const previewMaxHeight = 142
type TurnNavigatorProps = {
sessionID: string
@@ -29,6 +41,8 @@ type TurnNavigatorProps = {
messages: Accessor<SessionMessageInfo[]>
/** Whether the loaded messages reach the start of the session, making the index redundant. */
complete: Accessor<boolean>
/** Whether the timeline is loading its own history; the index waits until it has been idle for a while. */
historyLoading: Accessor<boolean>
assistantMessagesByParent: Accessor<Map<string, SessionMessageAssistant[]>>
revertMessageID: Accessor<string | undefined>
visibleUserMessageIDs: Accessor<ReadonlySet<string>>
@@ -46,24 +60,28 @@ export function TurnNavigator(props: TurnNavigatorProps) {
const [state, setState] = createStore<{ engaged: boolean; index?: Map<string, string> }>({ engaged: false })
let root: HTMLDivElement | undefined
const loadIndex = async (cursor?: string): Promise<[string, string][]> => {
const page = await sdk.api.message.list({
sessionID: props.sessionID,
type: "user",
limit: turnPageLimit,
...(cursor ? { cursor } : { order: "asc" as const }),
})
const abort = new AbortController()
onCleanup(() => abort.abort())
// Newest first, so a capped index still meets the loaded tail.
const loadIndex = async (cursor?: string, count = 0): Promise<[string, string][]> => {
const page = await sdk.api.message.list(
{ sessionID: props.sessionID, type: "user", limit: turnPageLimit, cursor },
{ signal: abort.signal },
)
const turns = page.data.flatMap((message): [string, string][] =>
message.type === "user" ? [[message.id, promptPreview(message)]] : [],
)
if (page.data.length < turnPageLimit || !page.cursor.next) return turns
if (page.data.length < turnPageLimit || !page.cursor.next || count + turns.length >= indexLimit) return turns
return [...turns, ...(await loadIndex(page.cursor.next))]
return [...turns, ...(await loadIndex(page.cursor.next, count + turns.length))]
}
onMount(() => {
createEffect(() => {
if (state.engaged || props.historyLoading()) return
const timer = setTimeout(() => {
// Touch-only devices never load the rail.
if (!matchMedia("(hover: hover) and (pointer: fine)").matches) return
@@ -74,7 +92,7 @@ export function TurnNavigator(props: TurnNavigatorProps) {
// On failure the rail lists the loaded turns.
void loadIndex()
.catch(() => [])
.then((turns) => setState("index", new Map(turns)))
.then((turns) => setState("index", new Map(turns.toReversed())))
}, railDelay)
onCleanup(() => clearTimeout(timer))
@@ -85,7 +103,7 @@ export function TurnNavigator(props: TurnNavigatorProps) {
ref={root}
data-component="session-turn-navigator"
aria-hidden="true"
class="absolute start-0 top-[calc(var(--session-timeline-header-height,48px)+8px)] bottom-[calc(var(--session-timeline-inset,0px)+80px)] z-[50] flex w-4 flex-col justify-center pointer-events-none"
class="absolute start-0 top-[calc(var(--session-timeline-header-height,48px)+8px)] bottom-[calc(var(--session-timeline-inset,0px)+80px)] z-[50] flex w-8 flex-col justify-center pointer-events-none"
>
<Show when={state.engaged && (props.complete() || state.index)}>
<TurnRail {...props} index={state.index} root={root!} />
@@ -98,46 +116,69 @@ function TurnRail(props: TurnNavigatorProps & { index?: Map<string, string>; roo
const sdk = useServerSDK()
const [state, setState] = createStore<{
hover?: { id: string; top: number; position: number }
showPreview: boolean
hover?: string
// The previewed turn, kept while the preview fades out.
shown?: string
open: boolean
// Fetched replies by user message ID; null while loading or when there is no reply text.
replies: Record<string, string | null>
}>({ showPreview: false, replies: {} })
}>({ open: false, replies: {} })
const loaded = createMemo(
() =>
new Map(props.messages().flatMap((message) => (message.type === "user" ? [[message.id, message] as const] : []))),
)
const turns = createMemo(() => {
const revert = props.revertMessageID()
// Timeline order, as the server lists it. IDs from other clients do not sort chronologically, so neither the order
// nor the reverted boundary may compare them. Streaming replaces the loaded messages on every delta, so the list keeps
// its identity until the turns themselves change.
const turns = createMemo(
() => {
const tail = [...loaded().keys()]
// Turns paged in from beyond a capped index are older than all of it.
const join = tail.findIndex((id) => props.index?.has(id))
const ids = [...new Set([...tail.slice(0, Math.max(0, join)), ...(props.index?.keys() ?? []), ...tail])]
const revert = props.revertMessageID()
const reverted = revert ? ids.indexOf(revert) : -1
return [...new Set([...(props.index?.keys() ?? []), ...loaded().keys()])]
.filter((id) => !revert || id < revert)
.toSorted()
})
return reverted === -1 ? ids : ids.slice(0, reverted)
},
[],
{
equals: (previous, next) => previous.length === next.length && previous.every((id, index) => id === next[index]),
},
)
// The turn's latest reply is the newest assistant message before the next turn.
const abort = new AbortController()
onCleanup(() => abort.abort())
// The turn's latest reply is the newest assistant text between its prompt and the next turn.
const loadReply = async (id: string) => {
setState("replies", id, null)
const list = turns()
const next = list[list.indexOf(id) + 1] ?? props.revertMessageID()
const page = await sdk.api.message.list({
sessionID: props.sessionID,
type: "assistant",
limit: 1,
before: next,
})
const page = await sdk.api.message.list(
{
sessionID: props.sessionID,
limit: replyScanLimit,
before: list[list.indexOf(id) + 1] ?? props.revertMessageID(),
},
{ signal: abort.signal },
)
const message = page.data[0]
const end = page.data.findIndex((message) => message.type === "user")
if (message?.type !== "assistant" || message.id < id) return
setState("replies", id, replyText([message]) ?? null)
if (end !== -1 && page.data[end]!.id !== id) return
const replies = (end === -1 ? page.data : page.data.slice(0, end)).filter(
(message): message is SessionMessageAssistant => message.type === "assistant",
)
setState("replies", id, replyText(replies.toReversed()) ?? null)
}
createEffect(() => {
const id = state.hover?.id
const id = state.hover
if (!id || loaded().has(id) || state.replies[id] !== undefined) return
const timer = setTimeout(() => void loadReply(id).catch(() => undefined), replyHoverDelay)
@@ -145,131 +186,225 @@ function TurnRail(props: TurnNavigatorProps & { index?: Map<string, string>; roo
})
const active = createSelector(props.visibleUserMessageIDs, (id: string, visible) => visible.has(id))
const hovered = createSelector(() => state.hover?.id)
const hovered = createSelector(() => state.hover)
const hovering = createMemo(() => !!state.hover)
// Only the first preview waits; moving along an engaged rail switches it immediately.
createEffect(() => {
if (!hovering()) {
setState("showPreview", false)
setState("open", false)
return
}
const timer = setTimeout(() => setState("showPreview", true), previewHoverDelay)
const timer = setTimeout(() => setState("open", true), previewHoverDelay)
onCleanup(() => clearTimeout(timer))
})
const tickScale = (id: string, proximity: number) => {
const widths = hovered(id) ? tickWidths.hovered : active(id) ? tickWidths.active : tickWidths.default
// A closed preview keeps its last content for the fade-out without following a streaming reply.
const preview = createMemo((previous?: { prompt?: string; response?: string }) => {
const id = state.shown
return (widths[0] + (widths[1] - widths[0]) * (hovered(id) ? 1 : proximity)) / tickSpanWidth
}
const preview = createMemo(() => {
const hover = state.hover
if (!hover) return
const message = loaded().get(hover.id)
if (!id || !state.open) return previous
const message = loaded().get(id)
return {
top: hover.top,
prompt: message ? promptPreview(message) : props.index?.get(hover.id),
response: message
? replyText(props.assistantMessagesByParent().get(hover.id) ?? [])
: (state.replies[hover.id] ?? undefined),
prompt: message ? promptPreview(message) : props.index?.get(id),
response: message ? replyText(props.assistantMessagesByParent().get(id) ?? []) : (state.replies[id] ?? undefined),
}
})
const turnAt = (event: MouseEvent) =>
event.target instanceof Element ? event.target.closest<HTMLElement>("[data-turn]") : null
let list: HTMLDivElement | undefined
let card: HTMLDivElement | undefined
// Each tick's element and the scale last written to it.
const ticks = new Map<string, { element: HTMLElement; scale: number }>()
// The pointer's clientY over the rail, and the rail's geometry from the last frame's read phase.
let pointer: number | undefined
const hover = (event: PointerEvent) => {
const tick = turnAt(event)
const id = tick?.dataset.turn
const rail = { top: 0, height: 0, root: 0, rootHeight: 0 }
if (!tick || !id) return
const box = tick.getBoundingClientRect()
const position = Number(tick.dataset.index) + (event.clientY - box.top) / box.height - 0.5
if (state.hover?.id === id && Math.abs(state.hover.position - position) < 0.05) return
setState("hover", {
id,
top: box.top + box.height / 2 - props.root.getBoundingClientRect().top,
position,
})
// `position` follows the pointer in tick units and `wave` swells the ticks around it, so one pair drives every tick.
const motion = {
wave: Spring.fromDuration(0, { duration: 0.2, bounce: 0 }),
position: Spring.fromDuration(0, { duration: 0.12, bounce: 0 }),
top: Spring.fromDuration(0, { duration: 0.25, bounce: 0 }),
opacity: Spring.fromDuration(0, { duration: 0.18, bounce: 0 }),
}
let frame: (() => void) | undefined
// Reduced motion drops travel but keeps the preview's fade.
const step = (spring: Spring, dt: number) =>
spring === motion.opacity || !prefersReducedMotion() ? spring.step(dt) : spring.snap(spring.target).value
const paint = (dt: number) => {
const ids = turns()
const pitch = rail.height / Math.max(1, ids.length)
if (pointer !== undefined && pitch > 0) {
const hit = railHit(pointer - rail.top, rail.height, ids.length)
const id = ids[hit.index]
const tick = rail.top - rail.root + (hit.index + 0.5) * pitch
// Entering the rail starts the wave under the pointer rather than sweeping in from where it left.
if (motion.wave.value === 0) motion.position.snap(hit.position)
motion.position.set(hit.position)
motion.wave.set(1)
motion.top.set(Math.max(0, Math.min(rail.rootHeight - previewMaxHeight, tick - previewAnchor)))
// Store writes re-render, so they wait until the frame's style writes are done.
if (id && id !== state.hover) queueMicrotask(() => pointer !== undefined && setState({ hover: id, shown: id }))
}
const position = step(motion.position, dt)
const wave = step(motion.wave, dt)
ids.forEach((id, index) => {
const distance = Math.abs(index - position) * pitch
const swell = distance < waveRadius ? (wave * (1 + Math.cos((distance / waveRadius) * Math.PI))) / 2 : 0
const rest = active(id) ? tickWidths.active : tickWidths.default
const scale = (rest + (tickWidths.peak - rest) * swell) / tickWidths.peak
const tick = ticks.get(id)
if (!tick || tick.scale === scale) return
tick.scale = scale
tick.element.style.transform = `scaleX(${scale})`
})
motion.opacity.set(state.open ? 1 : 0)
// A preview that opens from hidden appears in place; one already showing glides to the next turn.
if (motion.opacity.value === 0) motion.top.snap(motion.top.target)
const opacity = step(motion.opacity, dt)
if (card) {
card.style.transform = `translateY(${step(motion.top, dt)}px)`
card.style.opacity = String(opacity)
card.style.visibility = opacity === 0 ? "hidden" : "visible"
}
return Object.values(motion).some((spring) => !spring.resting)
}
const wake = () => {
frame ??= frameLoop.addMeasured(
() => {
// Only the pointer needs geometry; reading it otherwise would force a layout on every streamed frame.
if (!list || pointer === undefined) return
const box = list.getBoundingClientRect()
const root = props.root.getBoundingClientRect()
rail.top = box.top
rail.height = box.height
rail.root = root.top
rail.rootHeight = root.height
},
(dt) => {
if (paint(dt)) return true
frame = undefined
return false
},
)
}
const leave = () => {
pointer = undefined
motion.wave.set(0)
setState("hover", undefined)
wake()
}
// Resting widths follow the visible turns, and the preview follows its open state.
createEffect(() => {
turns().forEach((id) => active(id))
void state.open
wake()
})
onCleanup(() => frame?.())
return (
<Show when={turns().length > 1}>
<div
// Keep the expanded hit area while visual emphasis follows the pointer locally.
class="flex max-h-full min-h-0 w-4 cursor-pointer flex-col py-1 pointer-events-auto transition-[width] duration-150 hover:w-8 animate-in fade-in [animation-duration:180ms] [animation-timing-function:ease-out] motion-reduce:animate-none motion-reduce:transition-none"
onPointerMove={hover}
onPointerLeave={() => setState("hover", undefined)}
class="flex max-h-full min-h-0 w-full cursor-pointer flex-col py-6 pointer-events-auto animate-in fade-in [animation-duration:180ms] [animation-timing-function:ease-out] motion-reduce:animate-none"
onPointerMove={(event) => {
pointer = event.clientY
wake()
}}
onPointerLeave={leave}
// Scrolling over the rail closes the preview; the next pointer move reopens it on the turn below the pointer.
onWheel={leave}
onClick={(event) => {
const id = turnAt(event)?.dataset.turn
if (!list) return
const box = list.getBoundingClientRect()
const ids = turns()
const id = ids[railHit(event.clientY - box.top, box.height, ids.length).index]
if (id) props.onSelect(id)
}}
>
<For each={turns()}>
{(id, index) => {
const proximity = createMemo(() => {
if (!state.hover) return 0
const distance = Math.abs(index() - state.hover.position)
if (distance >= hoverRadius) return 0
return (1 + Math.cos((distance / hoverRadius) * Math.PI)) / 2
})
return (
<div ref={list} class="flex min-h-0 flex-col">
<For each={turns()}>
{(id, index) => (
<div
data-turn={id}
data-index={index()}
// Active color wins so a clicked or visible turn stays active under the pointer.
// Active state wins so a clicked or visible turn stays active under the pointer. The hovered tick is
// still the brightest: it lights at once and cools off as the pointer moves on.
data-state={active(id) ? "active" : hovered(id) ? "hovered" : "default"}
class="flex h-2.5 min-h-0 w-full shrink items-center ps-2.5 [--tick-color:var(--v2-background-bg-layer-04)] [--tick-opacity:1] [[data-color-scheme=dark]_&]:data-[state=default]:[--tick-opacity:0.7] data-[state=active]:[--tick-color:var(--v2-icon-icon-muted)] [[data-color-scheme=dark]_&]:data-[state=active]:[--tick-color:var(--v2-icon-icon-base)] data-[state=hovered]:[--tick-color:var(--v2-icon-icon-faint)] [[data-color-scheme=dark]_&]:data-[state=hovered]:[--tick-color:var(--v2-icon-icon-muted)]"
class="flex h-2.5 min-h-0 w-full shrink items-center ps-2.5 [--tick-color:var(--v2-background-bg-layer-04)] [--tick-opacity:1] [[data-color-scheme=dark]_&]:data-[state=default]:[--tick-opacity:0.7] data-[state=active]:[--tick-color:var(--v2-icon-icon-muted)]"
>
<span
class="block h-0.5 shrink-0 origin-left bg-(--tick-color) transition-[transform,background-color,opacity] duration-150 ease-out rtl:origin-right motion-reduce:transition-none"
ref={(element) => {
const scale = (active(id) ? tickWidths.active : tickWidths.default) / tickWidths.peak
element.style.transform = `scaleX(${scale})`
ticks.set(id, { element, scale })
onCleanup(() => ticks.delete(id))
}}
class="block h-0.5 shrink-0 origin-left bg-(--tick-color) rtl:origin-right"
style={{
width: `${tickSpanWidth}px`,
transform: `scaleX(${tickScale(id, proximity())})`,
opacity: "var(--tick-opacity)",
width: `${tickWidths.peak}px`,
opacity: hovered(id) ? 1 : "var(--tick-opacity)",
"background-color": hovered(id) ? "var(--v2-icon-icon-base)" : undefined,
transition: hovered(id) ? "none" : "background-color 180ms ease-out, opacity 180ms ease-out",
}}
/>
</div>
)
}}
</For>
)}
</For>
</div>
</div>
<Show when={state.showPreview && preview()}>
{(item) => (
<div
data-slot="session-turn-navigator-preview"
class="pointer-events-none absolute start-11 top-0 w-[320px] max-w-[calc(100vw-4rem)] transition-transform duration-100 ease-out motion-reduce:transition-none"
style={{ transform: `translateY(${item().top}px)` }}
>
<div
class="-translate-y-1/2 rounded-lg bg-v2-background-bg-base px-3 py-2 animate-in fade-in duration-150 ease-out motion-reduce:animate-none"
style={{ "box-shadow": "var(--v2-elevation-raised)" }}
>
<p dir="auto" class="line-clamp-2 text-[13px] font-medium leading-text-compact text-v2-text-text-base">
{item().prompt}
</p>
<div
ref={card}
data-slot="session-turn-navigator-preview"
class="pointer-events-none invisible absolute start-12 top-0 w-[320px] max-w-[calc(100vw-5rem)] rounded-lg bg-v2-background-bg-base p-2 opacity-0"
style={{ "box-shadow": "var(--v2-elevation-raised)" }}
>
<Show when={preview()}>
{(item) => (
<>
<div class="rounded-md bg-v2-background-bg-layer-02 px-2 py-1">
<p
dir="auto"
class="max-h-12 overflow-hidden text-[13px] font-medium leading-text-compact text-v2-text-text-base [mask-image:linear-gradient(to_bottom,#000_32px,transparent_48px)]"
>
{item().prompt}
</p>
</div>
<Show when={item().response}>
{(text) => (
<p dir="auto" class="mt-1 line-clamp-3 text-[13px] leading-text-base text-v2-text-text-muted">
<p
dir="auto"
class="mt-1.5 max-h-16 overflow-hidden px-2 text-[13px] leading-text-compact text-v2-text-text-muted [mask-image:linear-gradient(to_bottom,#000_48px,transparent_64px)]"
>
{text()}
</p>
)}
</Show>
</div>
</div>
)}
</Show>
</>
)}
</Show>
</div>
</Show>
)
}
@@ -289,3 +424,13 @@ function replyText(messages: SessionMessageAssistant[]) {
function previewText(text: string) {
return text.replace(/\s+/g, " ").trim().slice(0, previewLength)
}
/**
* The tick nearest a pointer `offset` px down a rail of `count` evenly spaced ticks `height` px tall, and the pointer's
* position in tick units (0 is the first tick's centre). Offsets beyond either end clamp to that end's tick.
*/
function railHit(offset: number, height: number, count: number) {
const position = Math.min(count - 1, Math.max(0, (offset / height) * count - 0.5))
return { index: Math.round(position), position }
}
@@ -100,12 +100,14 @@ export const useSessionHashScroll = (input: {
cancel()
if (input.currentMessageId() !== message.id) input.setActiveMessage(message)
input.revealMessage?.(message.id)
// A mounted prompt is close enough to glide to; any other is first brought in by the virtualizer.
const mounted = behavior === "smooth" ? document.getElementById(input.anchor(message.id)) : null
if (seek(message.id, behavior)) {
updateHash(message.id)
if (mounted) scrollToElement(mounted, behavior)
return
if (!mounted) {
input.revealMessage?.(message.id)
seek(message.id, behavior)
}
updateHash(message.id)