From 0296ab2cee5b03b9bb5fc692b97b81b5dd9e4a0d Mon Sep 17 00:00:00 2001 From: Aaron Iker Date: Mon, 19 Jan 2026 09:14:18 +0100 Subject: [PATCH] feat: message nav animation --- packages/ui/src/components/message-nav.css | 258 ++++++++---- packages/ui/src/components/message-nav.tsx | 468 +++++++++++++++++---- 2 files changed, 561 insertions(+), 165 deletions(-) diff --git a/packages/ui/src/components/message-nav.css b/packages/ui/src/components/message-nav.css index 465bd66fe5..91a239a81f 100644 --- a/packages/ui/src/components/message-nav.css +++ b/packages/ui/src/components/message-nav.css @@ -1,118 +1,202 @@ +@property --reveal-ready { + syntax: ""; + 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; } } diff --git a/packages/ui/src/components/message-nav.tsx b/packages/ui/src/components/message-nav.tsx index 4bd6a242e3..3ab4e48930 100644 --- a/packages/ui/src/components/message-nav.tsx +++ b/packages/ui/src/components/message-nav.tsx @@ -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 = () => ( - - ) +const CharacterSpans = (props: { text: string }) => { + const characters = createMemo(() => props.text?.split("") ?? []) return ( - - - - {content()} - - -
- -
-
-
-
-
- {content()} -
+ + {(char, index) => ( + + {char() === " " ? "\u00A0" : char()} + + )} + + ) +} + +const setupRevealForTitle = (titleEl: HTMLElement) => { + const innerEl = titleEl.querySelector("[data-slot='message-nav-item-title-inner']") + if (!innerEl) return + + const spans = innerEl.querySelectorAll("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() + const innerRefs = new Map() + const resetTrackers = new Map void>() + let navRef: HTMLElement | undefined + let listRef: HTMLUListElement | undefined + + const [portalTarget, setPortalTarget] = createSignal(null) + const [originalText, setOriginalText] = createSignal>({}) + + 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 = {} + + 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 ( + ) }