feat: layout, scroll sidebar

pull/8743/head
Aaron Iker 2026-01-19 20:46:14 +01:00
parent 457c246f10
commit 0beb8a0bf6
5 changed files with 309 additions and 358 deletions

View File

@ -66,6 +66,7 @@ import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { DialogEditProject } from "@/components/dialog-edit-project"
import { Titlebar } from "@/components/titlebar"
import { useServer } from "@/context/server"
import { ScrollReveal } from "@opencode-ai/ui/scroll-reveal"
export default function Layout(props: ParentProps) {
const [store, setStore, , ready] = persisted(
@ -187,7 +188,9 @@ export default function Layout(props: ParentProps) {
onClick={stopPropagation}
onTouchStart={stopPropagation}
>
{props.value()}
<ScrollReveal>
{props.value()}
</ScrollReveal>
</span>
}
>

View File

@ -1,52 +1,14 @@
@property --reveal-ready {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
[data-component="message-nav"] {
flex-shrink: 0;
position: relative;
z-index: 10;
height: 100%;
max-height: calc(100vh - 6rem);
}
[data-slot="message-nav-list"] {
--message-nav-item-height: 11px;
--message-nav-item-width: 40px;
--message-nav-expanded-width: 260px;
display: flex;
flex-direction: column;
align-items: flex-start;
padding-left: 0;
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;
transition-duration: 150ms;
transition-delay: 100ms, 100ms, 0ms, 0ms, 0ms, 0ms;
transition-timing-function: cubic-bezier(0.25, 0, 0.5, 1);
&:hover {
--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;
transition-duration: 200ms, 200ms, 200ms, 300ms, 300ms, 200ms;
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-size="normal"] {
width: 240px;
gap: 4px;
}
&[data-size="compact"] {
@ -54,128 +16,107 @@
}
}
[data-slot="message-nav-item-button"] {
--line-width: var(--message-nav-line-width);
--line-expanded-width: calc(var(--message-nav-expanded-width) - 16px);
[data-slot="message-nav-item"] {
display: flex;
align-items: center;
align-self: stretch;
justify-content: flex-end;
appearance: none;
border: none;
background: none;
padding: 0 4px;
margin: 0;
cursor: pointer;
position: relative;
font-family: inherit;
[data-component="message-nav"][data-size="normal"] & {
justify-content: flex-start;
}
}
[data-slot="message-nav-tick-button"] {
display: flex;
align-items: center;
justify-content: flex-start;
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;
height: 12px;
width: 24px;
border: none;
background: none;
padding: 0;
&::before {
content: "";
display: block;
flex-shrink: 0;
width: 24px;
height: 3px;
position: absolute;
pointer-events: none;
left: 4px;
top: 50%;
opacity: var(--message-nav-line-opacity, 1);
transform: scaleX(var(--message-nav-line-scale, 0.66)) scaleY(.66) translateY(-50%);
transform-origin: 0% 50%;
background-color: var(--icon-weak-base);
transition-property: opacity, background-color, transform;
transition-duration: 200ms;
transition-timing-function: cubic-bezier(0.32, 0, 0.15, 1);
}
&:hover {
background-color: var(--surface-base-hover);
}
&[data-active="true"] {
--message-nav-line-scale: 1;
&::before {
background-color: var(--icon-strong-base);
}
[data-slot="message-nav-item-title-inner"] {
color: var(--text-strong);
}
&[data-active] [data-slot="message-nav-tick-line"] {
background-color: var(--icon-strong-base);
width: 100%;
}
}
[data-slot="message-nav-item-title"] {
position: relative;
[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"] {
display: flex;
align-items: center;
min-width: 0;
flex: 1;
transition: mask-position 200ms cubic-bezier(0.25, 0, 0.5, 1);
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
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-item-title-inner"] {
font-size: 12px;
line-height: 23px;
padding-left: 4px;
color: var(--text-base);
[data-slot="message-nav-title-preview"] {
font-size: 14px; /* text-14-regular */
color: var(--text-weak);
white-space: nowrap;
display: inline-flex;
align-items: center;
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);
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
text-align: left;
&[data-active] {
color: var(--text-strong);
}
}
[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;
[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"]:active [data-slot="message-nav-title-preview"] {
color: var(--text-base);
}
[data-slot="message-nav-tooltip"] {
z-index: 1000;
}
[data-slot="message-nav-tooltip-content"] {
display: flex;
gap: 2px;
flex-shrink: 0;
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);
}
padding: 4px 4px 6px 4px;
justify-content: center;
align-items: center;
border-radius: var(--radius-md);
background: var(--surface-raised-stronger-non-alpha);
max-height: calc(100vh - 6rem);
overflow-y: auto;
[data-slot="message-nav-item-additions"] {
color: var(--text-diff-add-base);
display: inline-block;
vertical-align: top;
/* 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);
&::before {
content: "+";
font: inherit;
display: inline-block;
* {
margin: 0 !important;
}
}
[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,103 +1,10 @@
import {
type ComponentProps,
For,
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 type MessageNavProps = ComponentProps<"nav"> & {
messages: UserMessage[]
current?: UserMessage
size: "normal" | "compact"
onMessageSelect: (message: UserMessage) => void
}
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
}
}
import { UserMessage } from "@opencode-ai/sdk/v2"
import { ComponentProps, For, Match, Show, splitProps, Switch } from "solid-js"
import { DiffChanges } from "./diff-changes"
import { Tooltip } from "@kobalte/core/tooltip"
export function MessageNav(
props: ComponentProps<"nav"> & {
props: ComponentProps<"ul"> & {
messages: UserMessage[]
current?: UserMessage
size: "normal" | "compact"
@ -106,116 +13,70 @@ export function MessageNav(
},
) {
const [local, others] = splitProps(props, ["messages", "current", "size", "onMessageSelect", "getLabel"])
let navRef: HTMLElement | undefined
let listRef: HTMLUListElement | undefined
const [portalTarget, setPortalTarget] = createSignal<HTMLElement | null>(null)
const content = () => (
<ul role="list" data-component="message-nav" data-size={local.size} {...others}>
<For each={local.messages}>
{(message) => {
const handleClick = () => local.onMessageSelect(message)
onMount(() => {
if (navRef) {
setPortalTarget(navRef)
}
})
const handleKeyPress = (event: KeyboardEvent) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
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}
role="button"
tabindex={0}
onClick={handleClick}
onKeyDown={handleKeyPress}
>
<div data-slot="message-nav-tick-line" />
</div>
</Match>
<Match when={local.size === "normal"}>
<button data-slot="message-nav-message-button" onClick={handleClick} onKeyDown={handleKeyPress}>
<DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
<div
data-slot="message-nav-title-preview"
data-active={message.id === local.current?.id || undefined}
>
<Show when={local.getLabel?.(message) ?? message.summary?.title} fallback="New message">
{local.getLabel?.(message) ?? message.summary?.title}
</Show>
</div>
</button>
</Match>
</Switch>
</li>
)
}}
</For>
</ul>
)
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 }}
>
<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
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)
}
}, 300)
}
const handleTitleMouseLeave = () => {
if (hoverTimeout) {
clearTimeout(hoverTimeout)
hoverTimeout = undefined
}
stopScrollAnimation(scrollAnimationState, titleRef)
scrollAnimationState = null
}
onCleanup(() => {
if (hoverTimeout) {
clearTimeout(hoverTimeout)
}
stopScrollAnimation(scrollAnimationState, titleRef)
})
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">
{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>
<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>
)
}
}

View File

@ -1,6 +1,13 @@
[data-component="scroll-fade"] {
overflow: auto;
overscroll-behavior: contain;
scrollbar-width: none;
box-sizing: border-box;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
&[data-direction="horizontal"] {
overflow-x: auto;

View File

@ -0,0 +1,139 @@
import { type JSX, onCleanup, splitProps } from "solid-js"
import { ScrollFade, type ScrollFadeProps } from "./scroll-fade"
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 interface ScrollRevealProps extends Omit<ScrollFadeProps, "direction"> {
/** Delay before scroll animation starts on hover (ms). Default: 300 */
hoverDelay?: number
}
export function ScrollReveal(props: ScrollRevealProps) {
const [local, others] = splitProps(props, ["children", "hoverDelay", "ref"])
const hoverDelay = () => local.hoverDelay ?? 300
let containerRef: HTMLDivElement | undefined
let hoverTimeout: ReturnType<typeof setTimeout> | undefined
let scrollAnimationState: ScrollAnimationState | null = null
const handleMouseEnter: JSX.EventHandler<HTMLDivElement, MouseEvent> = () => {
hoverTimeout = setTimeout(() => {
if (!containerRef) return
containerRef.offsetHeight
const isScrollable = containerRef.scrollWidth > containerRef.clientWidth + 1
if (isScrollable) {
stopScrollAnimation(scrollAnimationState, containerRef)
scrollAnimationState = startScrollAnimation(containerRef)
}
}, hoverDelay())
}
const handleMouseLeave: JSX.EventHandler<HTMLDivElement, MouseEvent> = () => {
if (hoverTimeout) {
clearTimeout(hoverTimeout)
hoverTimeout = undefined
}
stopScrollAnimation(scrollAnimationState, containerRef)
scrollAnimationState = null
}
onCleanup(() => {
if (hoverTimeout) {
clearTimeout(hoverTimeout)
}
stopScrollAnimation(scrollAnimationState, containerRef)
})
return (
<ScrollFade
ref={(el) => {
containerRef = el
local.ref?.(el)
}}
fadeStartSize={8}
fadeEndSize={16}
direction="horizontal"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...others}
>
{local.children}
</ScrollFade>
)
}