feat: message nav
parent
97fd10be5d
commit
8abcd13e9d
|
|
@ -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"] {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue