From 0beb8a0bf6776e2928cfffbca53022d9ead5784a Mon Sep 17 00:00:00 2001 From: Aaron Iker Date: Mon, 19 Jan 2026 20:46:14 +0100 Subject: [PATCH] feat: layout, scroll sidebar --- packages/app/src/pages/layout.tsx | 5 +- packages/ui/src/components/message-nav.css | 243 +++++++---------- packages/ui/src/components/message-nav.tsx | 273 +++++-------------- packages/ui/src/components/scroll-fade.css | 7 + packages/ui/src/components/scroll-reveal.tsx | 139 ++++++++++ 5 files changed, 309 insertions(+), 358 deletions(-) create mode 100644 packages/ui/src/components/scroll-reveal.tsx diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 0e18c20180..dd42b338ef 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -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()} + + {props.value()} + } > diff --git a/packages/ui/src/components/message-nav.css b/packages/ui/src/components/message-nav.css index 01ab252954..a310450fff 100644 --- a/packages/ui/src/components/message-nav.css +++ b/packages/ui/src/components/message-nav.css @@ -1,52 +1,14 @@ -@property --reveal-ready { - syntax: ""; - 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; - } -} +} \ No newline at end of file diff --git a/packages/ui/src/components/message-nav.tsx b/packages/ui/src/components/message-nav.tsx index 589d401f1a..042b9e8b23 100644 --- a/packages/ui/src/components/message-nav.tsx +++ b/packages/ui/src/components/message-nav.tsx @@ -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(null) + const content = () => ( +
    + + {(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 ( +
  • + + +
    +
    +
    + + + + + +
  • + ) + }} +
    +
+ ) return ( - + + + + {content()} + + +
+ +
+
+
+
+
+ {content()} +
) -} +} \ No newline at end of file diff --git a/packages/ui/src/components/scroll-fade.css b/packages/ui/src/components/scroll-fade.css index f379df70c4..248af495de 100644 --- a/packages/ui/src/components/scroll-fade.css +++ b/packages/ui/src/components/scroll-fade.css @@ -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; diff --git a/packages/ui/src/components/scroll-reveal.tsx b/packages/ui/src/components/scroll-reveal.tsx new file mode 100644 index 0000000000..743aa58f48 --- /dev/null +++ b/packages/ui/src/components/scroll-reveal.tsx @@ -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 { + /** 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 | undefined + let scrollAnimationState: ScrollAnimationState | null = null + + const handleMouseEnter: JSX.EventHandler = () => { + 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 = () => { + if (hoverTimeout) { + clearTimeout(hoverTimeout) + hoverTimeout = undefined + } + stopScrollAnimation(scrollAnimationState, containerRef) + scrollAnimationState = null + } + + onCleanup(() => { + if (hoverTimeout) { + clearTimeout(hoverTimeout) + } + stopScrollAnimation(scrollAnimationState, containerRef) + }) + + return ( + { + containerRef = el + local.ref?.(el) + }} + fadeStartSize={8} + fadeEndSize={16} + direction="horizontal" + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} + {...others} + > + {local.children} + + ) +}