feat: message nav

pull/8743/head
Aaron Iker 2026-01-19 15:15:00 +01:00
parent 97fd10be5d
commit 8abcd13e9d
4 changed files with 91 additions and 288 deletions

View File

@ -10,18 +10,12 @@
z-index: 10;
height: 100%;
max-height: calc(100vh - 6rem);
overflow-y: auto;
overflow-x: hidden;
padding: 8px 0 0 8px;
}
[data-slot="message-nav-list"] {
--is-expanded: 0;
--reveal-ready: 0;
--message-nav-item-height: 11px;
--message-nav-item-width: 24px;
--message-nav-item-width: 40px;
--message-nav-expanded-width: 240px;
--message-nav-line-width: 16px;
list-style: none;
padding: 4px;
@ -31,16 +25,23 @@
grid-gap: 4px;
grid-template-rows: repeat(var(--message-nav-items), var(--message-nav-item-height));
border-radius: var(--radius-md);
transition-property: background-color, box-shadow, width, grid-template-rows, padding, --reveal-ready;
transition-duration: 300ms, 300ms, 300ms, 300ms, 300ms, 1s;
transition-delay: 0ms, 0ms, 0ms, 0ms, 0ms, 280ms;
transition-timing-function: cubic-bezier(0.32, 0, 0.15, 1);
transition-property: background-color, box-shadow, width, grid-template-rows, padding;
transition-duration: 200ms, 200ms, 200ms, 300ms, 300ms, 200ms;
transition-timing-function: cubic-bezier(0.25, 0, 0.5, 1);
&:not(:hover) {
transition-delay: 200ms, 200ms, 0ms, 0ms, 0ms, 0ms;
}
&:hover {
--is-expanded: 1;
--reveal-ready: 1;
--message-nav-item-height: 25px;
--message-nav-line-opacity: 0;
--message-nav-item-height: 23px;
--message-nav-item-width: var(--message-nav-expanded-width);
--message-nav-diff-opacity: 1;
--message-nav-diff-delay: 300ms;
--message-nav-title-mask: 0%;
--message-nav-title-transition-duration: 300ms;
--message-nav-title-transition-delay: 100ms;
background-color: var(--surface-raised-stronger-non-alpha);
box-shadow:
@ -51,11 +52,6 @@
}
}
[data-slot="message-nav-item"] {
width: 100%;
height: 100%;
}
[data-slot="message-nav-item-button"] {
--line-width: var(--message-nav-line-width);
--line-expanded-width: calc(var(--message-nav-expanded-width) - 16px);
@ -82,19 +78,18 @@
content: "";
display: block;
flex-shrink: 0;
/* Width expands first, then scaleX shrinks it to the right */
width: 100%;
width: 24px;
height: 3px;
position: absolute;
pointer-events: none;
left: 4px;
top: 50%;
/* scaleX shrinks from 1 to 0, collapsing toward the right (revealing left side) */
transform: scaleX(.66) translateY(-50%) scaleX(calc(1 - var(--reveal-ready)));
transform-origin: 100% 50%; /* Anchor on right, line collapses to the right */
opacity: var(--message-nav-line-opacity, 1);
transform: scaleX(var(--message-nav-line-scale, 0.66)) translateY(-50%);
transform-origin: 0% 50%;
background-color: var(--icon-weak-base);
transition-property: width, background-color, transform;
transition-duration: 300ms, 300ms, 400ms;
transition-property: opacity, background-color, transform;
transition-duration: 200ms;
transition-timing-function: cubic-bezier(0.32, 0, 0.15, 1);
}
@ -103,7 +98,7 @@
}
&[data-active="true"] {
--line-width: 24px;
--message-nav-line-scale: 1;
&::before {
background-color: var(--icon-strong-base);
@ -121,8 +116,7 @@
align-items: center;
min-width: 0;
flex: 1;
opacity: var(--reveal-ready);
transition: opacity 200ms cubic-bezier(0.32, 0, 0.15, 1);
transition: mask-position 200ms cubic-bezier(0.25, 0, 0.5, 1);
scrollbar-width: none;
-ms-overflow-style: none;
@ -132,34 +126,17 @@
}
[data-slot="message-nav-item-title-inner"] {
--factor: 5;
--spacing: 4px;
--avg-width: 12px;
--total-width: 0px;
--virtual-width: var(--total-width);
--ramp-distance: calc(var(--avg-width) * var(--factor));
--reveal-pos: calc(var(--reveal-ready) * var(--virtual-width));
perspective: 1000px;
font-size: 12px;
transform-style: preserve-3d;
line-height: 25px;
line-height: 23px;
padding-right: 4px;
color: var(--text-base);
white-space: nowrap;
display: inline-flex;
align-items: center;
transition: transform 0.4s cubic-bezier(0.32, 0, 0.15, 1);
}
/* Character spans for rotate effect */
[data-slot="message-nav-char"] {
--progress: clamp(0, calc((var(--reveal-pos) - var(--char-left, 0px) - var(--spacing)) / var(--ramp-distance)), 1);
display: inline-block;
transition: transform 300ms cubic-bezier(0.32, 0, 0.15, 1);
transform: rotateX(calc(-90deg * (1 - var(--progress)))) skewY(calc(15deg * (1 - var(--progress))));
transform-origin: 50% 45%;
mask-image: linear-gradient(to right, black, black 30%, transparent 60%, transparent);
mask-size: 300% 100%;
mask-position: var(--message-nav-title-mask, 100%) 0%;
transition: mask-position var(--message-nav-title-transition-duration, 200ms) cubic-bezier(0.25, 0, 0.5, 1) var(--message-nav-title-transition-delay, 0ms);
}
[data-slot="message-nav-item-diff-changes"] {
@ -172,9 +149,9 @@
display: flex;
gap: 2px;
flex-shrink: 0;
opacity: var(--reveal-ready);
transition: opacity 200ms cubic-bezier(0.32, 0, 0.15, 1);
transition-delay: calc(var(--reveal-ready) * 50ms);
opacity: var(--message-nav-diff-opacity, 0);
transition: opacity 200ms cubic-bezier(0.25, 0, 0.5, 1);
transition-delay: var(--message-nav-diff-delay, 0ms);
}
[data-slot="message-nav-item-additions"] {

View File

@ -1,7 +1,6 @@
import {
type ComponentProps,
For,
Index,
Show,
createMemo,
createSignal,
@ -14,68 +13,6 @@ import type { UserMessage } from "@opencode-ai/sdk/v2"
import { ScrollFade } from "./scroll-fade"
import "./message-nav.css"
const CharacterSpans = (props: { text: string }) => {
const characters = createMemo(() => props.text?.split("") ?? [])
return (
<Index each={characters()}>
{(char, index) => (
<span data-slot="message-nav-char" style={{ "--char-index": index }}>
{char() === " " ? "\u00A0" : char()}
</span>
)}
</Index>
)
}
const setupRevealForTitle = (titleEl: HTMLElement) => {
const innerEl = titleEl.querySelector<HTMLElement>("[data-slot='message-nav-item-title-inner']")
if (!innerEl) return
const spans = innerEl.querySelectorAll<HTMLSpanElement>("span")
if (spans.length === 0) return
innerEl.offsetHeight
const totalWidth = innerEl.scrollWidth
const containerWidth = titleEl.clientWidth
const numChars = spans.length
const avgWidth = numChars > 0 ? totalWidth / numChars : 12
innerEl.style.setProperty("--total-width", `${totalWidth}px`)
innerEl.style.setProperty("--avg-width", `${avgWidth}px`)
const liEl = titleEl.closest("[data-slot='message-nav-item']") as HTMLElement | null
if (liEl) {
const extraWidth = Math.max(0, totalWidth - containerWidth)
liEl.style.setProperty("--item-extra-width", `-${extraWidth}px`)
}
const style = getComputedStyle(innerEl)
const factor = parseFloat(style.getPropertyValue("--factor")) || 5
let spacing = parseFloat(style.getPropertyValue("--spacing"))
if (isNaN(spacing)) spacing = 0
let virtualWidth = totalWidth
if (spans.length > 0) {
const lastSpan = spans[spans.length - 1]
lastSpan.offsetHeight
const lastLeft = lastSpan.offsetLeft
const ramp = avgWidth * factor
const neededForLast = lastLeft + spacing + ramp
virtualWidth = Math.max(totalWidth, neededForLast)
}
innerEl.style.setProperty("--virtual-width", `${virtualWidth}px`)
spans.forEach((span) => {
span.offsetHeight
const left = span.offsetLeft
span.style.setProperty("--char-left", `${left}px`)
})
}
export type MessageNavProps = ComponentProps<"nav"> & {
messages: UserMessage[]
current?: UserMessage
@ -83,19 +20,8 @@ export type MessageNavProps = ComponentProps<"nav"> & {
onMessageSelect: (message: UserMessage) => void
}
const createCharacterSpans = (text: string): HTMLSpanElement[] => {
return text.split("").map((char, index) => {
const span = document.createElement("span")
span.setAttribute("data-slot", "message-nav-char")
span.style.setProperty("--char-index", String(index))
span.textContent = char === " " ? "\u00A0" : char
return span
})
}
const SCROLL_SPEED = 60
const PAUSE_DURATION = 800
interface ScrollAnimationState {
rafId: number | null
startTime: number
@ -172,67 +98,10 @@ const stopScrollAnimation = (state: ScrollAnimationState | null, containerEl?: H
export const MessageNav = (props: MessageNavProps) => {
const [local, others] = splitProps(props, ["messages", "current", "size", "onMessageSelect"])
const titleRefs = new Map<string, HTMLElement>()
const innerRefs = new Map<string, HTMLSpanElement>()
const resetTrackers = new Map<string, () => void>()
let navRef: HTMLElement | undefined
let listRef: HTMLUListElement | undefined
const [portalTarget, setPortalTarget] = createSignal<HTMLElement | null>(null)
const [originalText, setOriginalText] = createSignal<Record<string, string>>({})
const handleListMouseEnter = () => {
for (const reset of resetTrackers.values()) {
reset()
}
setTimeout(() => {
for (const titleEl of titleRefs.values()) {
setupRevealForTitle(titleEl)
}
}, 500)
}
const handleListMouseLeave = () => {
for (const [id, innerEl] of innerRefs.entries()) {
const text = originalText()?.[id]
if (!text || !innerEl) continue
const existingSpans = innerEl.querySelectorAll("[data-slot='message-nav-char']")
if (existingSpans.length > 0) continue
innerEl.textContent = ""
const spans = createCharacterSpans(text)
spans.forEach((span) => innerEl.appendChild(span))
const titleEl = titleRefs.get(id)
if (titleEl) {
requestAnimationFrame(() => setupRevealForTitle(titleEl))
}
}
}
const setupAllReveal = () => {
const original: Record<string, string> = {}
for (const [id, titleEl] of titleRefs.entries()) {
const originalText = titleEl.textContent
original[id] = originalText ?? ""
setupRevealForTitle(titleEl)
}
setOriginalText(original)
}
const onTransitionEnd = (id: string, index: number) => {
const text = originalText()?.[id]
const innerEl = innerRefs.get(id)
if (text && innerEl) {
innerEl.textContent = text
}
}
onMount(() => {
if (navRef) {
@ -248,8 +117,6 @@ export const MessageNav = (props: MessageNavProps) => {
ref={(el) => (listRef = el)}
data-slot="message-nav-list"
style={{ "--message-nav-items": local.messages.length }}
onMouseEnter={handleListMouseEnter}
onMouseLeave={handleListMouseLeave}
>
<For each={local.messages}>
{(message, index) => {
@ -257,29 +124,6 @@ export const MessageNav = (props: MessageNavProps) => {
let hoverTimeout: ReturnType<typeof setTimeout> | undefined
let scrollAnimationState: ScrollAnimationState | null = null
let innerRef: HTMLSpanElement | undefined
let revealedCount = 0
let totalSpans = 0
let lastRevealTriggered = false
const handleSpanTransitionEnd = (e: TransitionEvent) => {
if (e.propertyName !== "transform") return
const target = e.target as HTMLElement
if (target.getAttribute("data-slot") !== "message-nav-char") return
revealedCount++
if (revealedCount >= totalSpans && !lastRevealTriggered) {
lastRevealTriggered = true
onTransitionEnd(message.id, index())
}
}
const setupTransitionTracking = () => {
if (!innerRef) return
const spans = innerRef.querySelectorAll<HTMLSpanElement>("[data-slot='message-nav-char']")
totalSpans = spans.length
revealedCount = 0
lastRevealTriggered = false
}
const handleClick = () => local.onMessageSelect(message)
@ -317,39 +161,12 @@ export const MessageNav = (props: MessageNavProps) => {
scrollAnimationState = null
}
onMount(() => {
if (titleRef) {
titleRefs.set(message.id, titleRef)
requestAnimationFrame(() => {
if (titleRef) {
setupRevealForTitle(titleRef)
}
})
}
if (innerRef) {
innerRefs.set(message.id, innerRef)
innerRef.addEventListener("transitionend", handleSpanTransitionEnd)
}
resetTrackers.set(message.id, setupTransitionTracking)
})
onCleanup(() => {
titleRefs.delete(message.id)
innerRefs.delete(message.id)
resetTrackers.delete(message.id)
if (hoverTimeout) {
clearTimeout(hoverTimeout)
}
stopScrollAnimation(scrollAnimationState, titleRef)
if (innerRef) {
innerRef.removeEventListener("transitionend", handleSpanTransitionEnd)
}
})
return (
@ -371,7 +188,7 @@ export const MessageNav = (props: MessageNavProps) => {
onMouseLeave={handleTitleMouseLeave}
>
<span ref={(el) => (innerRef = el)} data-slot="message-nav-item-title-inner">
<CharacterSpans text={title()} />
{title()}
</span>
</ScrollFade>

View File

@ -1,44 +1,29 @@
[data-slot="session-message-rail-anchor"] {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 40px;
height: -webkit-fill-available;
pointer-events: none;
}
[data-slot="session-message-rail-portal"] {
z-index: 100;
margin-left: 12px;
}
[data-component="session-message-rail"] {
display: contents;
position: relative;
}
[data-slot="session-message-rail-compact"],
[data-slot="session-message-rail-full"] {
position: absolute;
left: 1.5rem;
margin-top: 0.625rem;
top: 0;
bottom: 8rem;
overflow-y: auto;
}
[data-slot="session-message-rail-compact"] {
display: flex;
}
[data-slot="session-message-rail-full"] {
display: none;
}
@container (min-width: 88rem) {
[data-slot="session-message-rail-compact"] {
display: none;
}
[data-slot="session-message-rail-full"] {
display: flex;
}
}
[data-component="session-message-rail"] [data-slot="session-message-rail-full"] {
transform: none;
}
[data-component="session-message-rail"][data-wide] [data-slot="session-message-rail-full"] {
[data-component="session-message-rail"][data-wide] {
margin-top: 0.125rem;
left: calc(((100% - min(100%, 50rem)) / 2) - 1.5rem);
transform: translateX(-100%);
}
[data-component="session-message-rail"]:not([data-wide]) [data-slot="session-message-rail-full"] {
[data-component="session-message-rail"]:not([data-wide]) {
margin-top: 0.625rem;
}

View File

@ -1,7 +1,8 @@
import { UserMessage } from "@opencode-ai/sdk/v2"
import { ComponentProps, Show, splitProps } from "solid-js"
import { ComponentProps, Show, splitProps, createSignal, onMount, onCleanup } from "solid-js"
import { MessageNav } from "./message-nav"
import "./session-message-rail.css"
import { Portal } from "solid-js/web"
export interface SessionMessageRailProps extends ComponentProps<"div"> {
messages: UserMessage[]
@ -12,6 +13,26 @@ export interface SessionMessageRailProps extends ComponentProps<"div"> {
export function SessionMessageRail(props: SessionMessageRailProps) {
const [local, others] = splitProps(props, ["messages", "current", "wide", "onMessageSelect", "class", "classList"])
let anchorRef: HTMLDivElement | undefined
const [position, setPosition] = createSignal({ top: 0, left: 0, height: 0 })
const updatePosition = () => {
if (anchorRef) {
const rect = anchorRef.getBoundingClientRect()
setPosition({ top: rect.top, left: rect.left, height: rect.height })
}
}
onMount(() => {
updatePosition()
window.addEventListener("scroll", updatePosition, true)
window.addEventListener("resize", updatePosition)
})
onCleanup(() => {
window.removeEventListener("scroll", updatePosition, true)
window.removeEventListener("resize", updatePosition)
})
return (
<Show when={(local.messages?.length ?? 0) > 1}>
@ -24,22 +45,25 @@ export function SessionMessageRail(props: SessionMessageRailProps) {
[local.class ?? ""]: !!local.class,
}}
>
<div data-slot="session-message-rail-compact">
<MessageNav
messages={local.messages}
current={local.current}
onMessageSelect={local.onMessageSelect}
size="compact"
/>
</div>
<div data-slot="session-message-rail-full">
<MessageNav
messages={local.messages}
current={local.current}
onMessageSelect={local.onMessageSelect}
size={local.wide ? "normal" : "compact"}
/>
</div>
<div ref={(el) => (anchorRef = el)} data-slot="session-message-rail-anchor" />
<Portal mount={document.body}>
<div
data-slot="session-message-rail-portal"
style={{
position: "fixed",
top: `${position().top}px`,
left: `${position().left}px`,
height: `${position().height}px`,
}}
>
<MessageNav
messages={local.messages}
current={local.current}
onMessageSelect={local.onMessageSelect}
size={local.wide ? "normal" : "compact"}
/>
</div>
</Portal>
</div>
</Show>
)