diff --git a/packages/ui/src/components/message-nav.css b/packages/ui/src/components/message-nav.css index 91a239a81f..41fa2b779c 100644 --- a/packages/ui/src/components/message-nav.css +++ b/packages/ui/src/components/message-nav.css @@ -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"] { diff --git a/packages/ui/src/components/message-nav.tsx b/packages/ui/src/components/message-nav.tsx index 3ab4e48930..d9d6584f21 100644 --- a/packages/ui/src/components/message-nav.tsx +++ b/packages/ui/src/components/message-nav.tsx @@ -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 ( - - {(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 @@ -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() - 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) { @@ -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} > {(message, index) => { @@ -257,29 +124,6 @@ export const MessageNav = (props: MessageNavProps) => { let hoverTimeout: ReturnType | 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("[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} > (innerRef = el)} data-slot="message-nav-item-title-inner"> - + {title()} diff --git a/packages/ui/src/components/session-message-rail.css b/packages/ui/src/components/session-message-rail.css index 9f248bed25..3585258c3c 100644 --- a/packages/ui/src/components/session-message-rail.css +++ b/packages/ui/src/components/session-message-rail.css @@ -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; } diff --git a/packages/ui/src/components/session-message-rail.tsx b/packages/ui/src/components/session-message-rail.tsx index 1935a4f930..8ab9d3b278 100644 --- a/packages/ui/src/components/session-message-rail.tsx +++ b/packages/ui/src/components/session-message-rail.tsx @@ -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 ( 1}> @@ -24,22 +45,25 @@ export function SessionMessageRail(props: SessionMessageRailProps) { [local.class ?? ""]: !!local.class, }} > -
- -
-
- -
+
(anchorRef = el)} data-slot="session-message-rail-anchor" /> + +
+ +
+
)