feat: message nav animation

pull/8743/head
Aaron Iker 2026-01-19 09:14:18 +01:00
parent b6b3867325
commit 0296ab2cee
2 changed files with 561 additions and 165 deletions

View File

@ -1,118 +1,202 @@
@property --reveal-ready {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
[data-component="message-nav"] {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
padding-left: 0;
list-style: none;
position: relative;
z-index: 10;
height: 100%;
max-height: calc(100vh - 6rem);
overflow-y: auto;
overflow-x: hidden;
padding: 8px 0 0 8px;
}
&[data-size="normal"] {
width: 240px;
gap: 4px;
[data-slot="message-nav-list"] {
--is-expanded: 0;
--reveal-ready: 0;
--message-nav-item-height: 11px;
--message-nav-item-width: 24px;
--message-nav-expanded-width: 240px;
--message-nav-line-width: 16px;
list-style: none;
padding: 4px;
width: var(--message-nav-item-width);
box-sizing: border-box;
display: grid;
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);
&:hover {
--is-expanded: 1;
--reveal-ready: 1;
--message-nav-item-height: 25px;
--message-nav-item-width: var(--message-nav-expanded-width);
background-color: var(--surface-raised-stronger-non-alpha);
box-shadow:
0 0 0 1px var(--border-weak-base, rgba(17, 0, 0, 0.12)),
0 1px 2px -1px rgba(19, 16, 16, 0.04),
0 1px 2px 0 rgba(19, 16, 16, 0.06),
0 1px 3px 0 rgba(19, 16, 16, 0.08);
}
}
[data-slot="message-nav-item"] {
display: flex;
align-items: center;
align-self: stretch;
justify-content: flex-end;
[data-component="message-nav"][data-size="normal"] & {
justify-content: flex-start;
}
width: 100%;
height: 100%;
}
[data-slot="message-nav-tick-button"] {
[data-slot="message-nav-item-button"] {
--line-width: var(--message-nav-line-width);
--line-expanded-width: calc(var(--message-nav-expanded-width) - 16px);
appearance: none;
border: none;
background: none;
padding: 0 4px;
margin: 0;
cursor: pointer;
position: relative;
font-family: inherit;
display: flex;
align-items: center;
justify-content: flex-start;
height: 12px;
width: 24px;
border: none;
background: none;
padding: 0;
gap: 8px;
box-sizing: border-box;
width: calc(var(--message-nav-item-width) - 8px);
height: 100%;
border-radius: var(--radius-xs);
transition: background-color 0.15s ease;
&[data-active] [data-slot="message-nav-tick-line"] {
background-color: var(--icon-strong-base);
&::before {
content: "";
display: block;
flex-shrink: 0;
/* Width expands first, then scaleX shrinks it to the right */
width: 100%;
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 */
background-color: var(--icon-weak-base);
transition-property: width, background-color, transform;
transition-duration: 300ms, 300ms, 400ms;
transition-timing-function: cubic-bezier(0.32, 0, 0.15, 1);
}
&:hover {
background-color: var(--surface-base-hover);
}
&[data-active="true"] {
--line-width: 24px;
&::before {
background-color: var(--icon-strong-base);
}
[data-slot="message-nav-item-title-inner"] {
color: var(--text-strong);
}
}
}
[data-slot="message-nav-tick-line"] {
height: 1px;
width: 16px;
background-color: var(--icon-base);
transition:
width 0.2s,
background-color 0.2s;
}
[data-slot="message-nav-tick-button"]:hover [data-slot="message-nav-tick-line"] {
width: 100%;
background-color: var(--icon-strong-base);
}
[data-slot="message-nav-message-button"] {
[data-slot="message-nav-item-title"] {
position: relative;
display: flex;
align-items: center;
align-self: stretch;
width: 100%;
column-gap: 12px;
cursor: default;
border: none;
background: none;
padding: 4px 12px;
border-radius: var(--radius-sm);
}
[data-slot="message-nav-title-preview"] {
font-size: 14px; /* text-14-regular */
color: var(--text-weak);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
text-align: left;
flex: 1;
opacity: var(--reveal-ready);
transition: opacity 200ms cubic-bezier(0.32, 0, 0.15, 1);
scrollbar-width: none;
-ms-overflow-style: none;
&[data-active] {
color: var(--text-strong);
&::-webkit-scrollbar {
display: none;
}
}
[data-slot="message-nav-item"]:hover [data-slot="message-nav-message-button"] {
background-color: var(--surface-base);
}
[data-slot="message-nav-item"]:active [data-slot="message-nav-message-button"] {
background-color: var(--surface-base-active);
}
[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));
[data-slot="message-nav-item"]:active [data-slot="message-nav-title-preview"] {
perspective: 1000px;
font-size: 12px;
transform-style: preserve-3d;
line-height: 25px;
padding-right: 4px;
color: var(--text-base);
}
[data-slot="message-nav-tooltip"] {
z-index: 1000;
}
[data-slot="message-nav-tooltip-content"] {
display: flex;
padding: 4px 4px 6px 4px;
justify-content: center;
white-space: nowrap;
display: inline-flex;
align-items: center;
border-radius: var(--radius-md);
background: var(--surface-raised-stronger-non-alpha);
max-height: calc(100vh - 6rem);
overflow-y: auto;
transition: transform 0.4s cubic-bezier(0.32, 0, 0.15, 1);
}
/* border/shadow-xs/base */
box-shadow:
0 0 0 1px var(--border-weak-base, rgba(17, 0, 0, 0.12)),
0 1px 2px -1px rgba(19, 16, 16, 0.04),
0 1px 2px 0 rgba(19, 16, 16, 0.06),
0 1px 3px 0 rgba(19, 16, 16, 0.08);
/* 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);
* {
margin: 0 !important;
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%;
}
[data-slot="message-nav-item-diff-changes"] {
font-family: var(--font-family-mono);
font-feature-settings: var(--font-family-mono--font-feature-settings);
font-size: 11px;
font-style: normal;
font-weight: 500;
margin-left: auto;
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);
}
[data-slot="message-nav-item-additions"] {
color: var(--text-diff-add-base);
display: inline-block;
vertical-align: top;
&::before {
content: "+";
font: inherit;
display: inline-block;
}
}
[data-slot="message-nav-item-deletions"] {
color: var(--text-diff-delete-base);
display: inline-block;
vertical-align: top;
&::before {
content: "-";
display: inline-block;
font: inherit;
}
}

View File

@ -1,84 +1,396 @@
import { Tooltip } from "@kobalte/core/tooltip"
import { type ComponentProps, For, Match, Show, Switch, splitProps } from "solid-js"
import {
type ComponentProps,
For,
Index,
Show,
createMemo,
createSignal,
onCleanup,
onMount,
splitProps,
} from "solid-js"
import { Portal } from "solid-js/web"
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { ScrollFade } from "./scroll-fade"
import "./message-nav.css"
export function MessageNav(
props: ComponentProps<"ul"> & {
messages: UserMessage[]
current?: UserMessage
size: "normal" | "compact"
onMessageSelect: (message: UserMessage) => void
},
) {
const [local, others] = splitProps(props, ["messages", "current", "size", "onMessageSelect"])
const content = () => (
<ul data-component="message-nav" data-size={local.size} {...others}>
<For each={local.messages}>
{(message) => {
const handleClick = () => local.onMessageSelect(message)
return (
<li data-slot="message-nav-item">
<Switch>
<Match when={local.size === "compact"}>
<div data-slot="message-nav-tick-button" data-active={message.id === local.current?.id || undefined}>
<div data-slot="message-nav-tick-line" />
</div>
</Match>
<Match when={local.size === "normal"}>
<button data-slot="message-nav-message-button" onClick={handleClick} type="button">
<div
data-slot="message-nav-title-preview"
data-active={message.id === local.current?.id || undefined}
>
<Show when={message.summary?.title} fallback="New message">
{message.summary?.title}
</Show>
</div>
<Show when={(message.summary?.diffs.reduce((acc, diff) => acc + diff.additions, 0) ?? 0) > 0}>
<span data-slot="message-nav-diff-changes message-nav-diff-additions">
{message.summary?.diffs.reduce((acc, diff) => acc + diff.additions, 0)}
</span>
</Show>
<Show when={(message.summary?.diffs.reduce((acc, diff) => acc + diff.deletions, 0) ?? 0) > 0}>
<span data-slot="message-nav-diff-changes message-nav-diff-deletions">
{message.summary?.diffs.reduce((acc, diff) => acc + diff.deletions, 0)}
</span>
</Show>
<Show
when={
(message.summary?.diffs?.reduce((acc, diff) => acc + diff.additions, 0) ?? 0) <= 0 &&
(message.summary?.diffs?.reduce((acc, diff) => acc + diff.deletions, 0) ?? 0) <= 0
}
>
<span data-slot="message-nav-diff-changes message-nav-diff-neutral">0</span>
</Show>
</button>
</Match>
</Switch>
</li>
)
}}
</For>
</ul>
)
const CharacterSpans = (props: { text: string }) => {
const characters = createMemo(() => props.text?.split("") ?? [])
return (
<Switch>
<Match when={local.size === "compact"}>
<Tooltip openDelay={0} closeDelay={300} placement="right-start" gutter={-40} shift={-10} overlap>
<Tooltip.Trigger as="div">{content()}</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content data-slot="message-nav-tooltip">
<div data-slot="message-nav-tooltip-content">
<MessageNav {...props} size="normal" class="" />
</div>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip>
</Match>
<Match when={local.size === "normal"}>{content()}</Match>
</Switch>
<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
size: "normal" | "compact"
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
running: boolean
}
const startScrollAnimation = (
containerEl: HTMLElement,
): ScrollAnimationState | null => {
containerEl.offsetHeight
const extraWidth = containerEl.scrollWidth - containerEl.clientWidth
if (extraWidth <= 0) return null
const scrollDuration = (extraWidth / SCROLL_SPEED) * 1000
const totalDuration = PAUSE_DURATION + scrollDuration + PAUSE_DURATION + scrollDuration + PAUSE_DURATION
const state: ScrollAnimationState = {
rafId: null,
startTime: performance.now(),
running: true,
}
const animate = (currentTime: number) => {
if (!state.running) return
const elapsed = currentTime - state.startTime
const progress = (elapsed % totalDuration) / totalDuration
const pausePercent = PAUSE_DURATION / totalDuration
const scrollPercent = scrollDuration / totalDuration
const pauseEnd1 = pausePercent
const scrollEnd1 = pauseEnd1 + scrollPercent
const pauseEnd2 = scrollEnd1 + pausePercent
const scrollEnd2 = pauseEnd2 + scrollPercent
let scrollPos = 0
if (progress < pauseEnd1) {
scrollPos = 0
} else if (progress < scrollEnd1) {
const scrollProgress = (progress - pauseEnd1) / scrollPercent
scrollPos = scrollProgress * extraWidth
} else if (progress < pauseEnd2) {
scrollPos = extraWidth
} else if (progress < scrollEnd2) {
const scrollProgress = (progress - pauseEnd2) / scrollPercent
scrollPos = extraWidth * (1 - scrollProgress)
} else {
scrollPos = 0
}
containerEl.scrollLeft = scrollPos
state.rafId = requestAnimationFrame(animate)
}
state.rafId = requestAnimationFrame(animate)
return state
}
const stopScrollAnimation = (state: ScrollAnimationState | null, containerEl?: HTMLElement) => {
if (state) {
state.running = false
if (state.rafId !== null) {
cancelAnimationFrame(state.rafId)
}
}
if (containerEl) {
containerEl.scrollLeft = 0
}
}
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) {
setPortalTarget(navRef)
}
})
return (
<nav ref={(el) => (navRef = el)} data-component="message-nav" data-size={local.size} {...others}>
<Show when={portalTarget()}>
<Portal mount={portalTarget()!}>
<ul
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) => {
let titleRef: HTMLElement | undefined
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)
const additions = createMemo(
() => message.summary?.diffs.reduce((acc, diff) => acc + diff.additions, 0) ?? 0,
)
const deletions = createMemo(
() => message.summary?.diffs.reduce((acc, diff) => acc + diff.deletions, 0) ?? 0,
)
const title = createMemo(() => message.summary?.title ?? "New message")
const handleTitleMouseEnter = () => {
hoverTimeout = setTimeout(() => {
if (!titleRef) return
titleRef.offsetHeight
const isScrollable = titleRef.scrollWidth > titleRef.clientWidth + 1
if (isScrollable) {
stopScrollAnimation(scrollAnimationState, titleRef)
scrollAnimationState = startScrollAnimation(titleRef)
}
}, 500)
}
const handleTitleMouseLeave = () => {
if (hoverTimeout) {
clearTimeout(hoverTimeout)
hoverTimeout = undefined
}
stopScrollAnimation(scrollAnimationState, titleRef)
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 (
<li data-slot="message-nav-item" style={{ "--item-index": index() }}>
<button
data-slot="message-nav-item-button"
data-active={message.id === local.current?.id || undefined}
type="button"
onClick={handleClick}
>
<ScrollFade
ref={(el) => (titleRef = el)}
direction="horizontal"
fadeStartSize={12}
fadeEndSize={12}
trackTransformSelector="[data-slot='message-nav-item-title-inner']"
data-slot="message-nav-item-title"
onMouseEnter={handleTitleMouseEnter}
onMouseLeave={handleTitleMouseLeave}
>
<span ref={(el) => (innerRef = el)} data-slot="message-nav-item-title-inner">
<CharacterSpans text={title()} />
</span>
</ScrollFade>
<span data-slot="message-nav-item-diff-changes">
<Show when={additions() > 0}>
<span data-slot="message-nav-item-additions">{additions()}</span>
</Show>
<Show when={deletions() > 0}>
<span data-slot="message-nav-item-deletions">{deletions()}</span>
</Show>
</span>
</button>
</li>
)
}}
</For>
</ul>
</Portal>
</Show>
</nav>
)
}