@@ -1653,7 +1657,7 @@ export default function Layout(props: ParentProps) {
size="large"
onClick={(e: MouseEvent) => {
loadMore()
- ;(e.currentTarget as HTMLButtonElement).blur()
+ ; (e.currentTarget as HTMLButtonElement).blur()
}}
>
Load more
@@ -1721,7 +1725,7 @@ export default function Layout(props: ParentProps) {
return (
// @ts-ignore
-
+
-
-
+
0}
fallback={
@@ -260,7 +267,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void })
)}
-
+
)
}
diff --git a/packages/ui/src/components/message-nav.css b/packages/ui/src/components/message-nav.css
index b1454ad4259..a3a19d3c1b5 100644
--- a/packages/ui/src/components/message-nav.css
+++ b/packages/ui/src/components/message-nav.css
@@ -2,13 +2,14 @@
flex-shrink: 0;
display: flex;
flex-direction: column;
+ gap: 12px;
align-items: flex-start;
- padding-left: 0;
+ padding: 0;
list-style: none;
&[data-size="normal"] {
width: 240px;
- gap: 4px;
+ gap: 8px;
}
&[data-size="compact"] {
@@ -36,6 +37,7 @@
border: none;
background: none;
padding: 0;
+ margin: 0;
&[data-active] [data-slot="message-nav-tick-line"] {
background-color: var(--icon-strong-base);
@@ -54,7 +56,8 @@
[data-slot="message-nav-tick-button"]:hover [data-slot="message-nav-tick-line"] {
width: 100%;
- background-color: var(--icon-strong-base);
+ color: var(--text-strong);
+ box-sizing: border-box;
}
[data-slot="message-nav-message-button"] {
@@ -62,11 +65,12 @@
align-items: center;
align-self: stretch;
width: 100%;
+ color: inherit;
column-gap: 12px;
cursor: default;
border: none;
background: none;
- padding: 4px 12px;
+ padding: 0;
border-radius: var(--radius-sm);
}
@@ -79,16 +83,21 @@
min-width: 0;
text-align: left;
+ &:not(:hover) {
+ color: var(--text-weak);
+ }
+
+ &:hover,
&[data-active] {
color: var(--text-strong);
}
}
[data-slot="message-nav-item"]:hover [data-slot="message-nav-message-button"] {
- background-color: var(--surface-base);
+ color: var(--text-strong);
}
[data-slot="message-nav-item"]:active [data-slot="message-nav-message-button"] {
- background-color: var(--surface-base-active);
+ color: var(--text-base);
}
[data-slot="message-nav-item"]:active [data-slot="message-nav-title-preview"] {
@@ -101,7 +110,7 @@
[data-slot="message-nav-tooltip-content"] {
display: flex;
- padding: 4px 4px 6px 4px;
+ padding: 4px;
justify-content: center;
align-items: center;
border-radius: var(--radius-md);
@@ -119,4 +128,4 @@
* {
margin: 0 !important;
}
-}
+}
\ 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 0dd7c42b020..5612e489fa8 100644
--- a/packages/ui/src/components/message-nav.tsx
+++ b/packages/ui/src/components/message-nav.tsx
@@ -2,6 +2,7 @@ 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"
+import { ScrollReveal } from "./scroll-reveal"
export function MessageNav(
props: ComponentProps<"ul"> & {
@@ -43,14 +44,16 @@ export function MessageNav(
@@ -79,4 +82,4 @@ export function MessageNav(
{content()}
)
-}
+}
\ No newline at end of file
diff --git a/packages/ui/src/components/popover.css b/packages/ui/src/components/popover.css
index b49542afd9b..a18ddc3b3b5 100644
--- a/packages/ui/src/components/popover.css
+++ b/packages/ui/src/components/popover.css
@@ -15,6 +15,14 @@
transform-origin: var(--kb-popover-content-transform-origin);
+ [data-origin-top-right] {
+ transform-origin: top right;
+ }
+
+ [data-origin-top-left] {
+ transform-origin: top left;
+ }
+
&:focus-within {
outline: none;
}
diff --git a/packages/ui/src/components/scroll-fade.css b/packages/ui/src/components/scroll-fade.css
new file mode 100644
index 00000000000..12347fd7ec6
--- /dev/null
+++ b/packages/ui/src/components/scroll-fade.css
@@ -0,0 +1,123 @@
+[data-component="scroll-fade"] {
+ overflow: auto;
+ overscroll-behavior: contain;
+ scrollbar-width: none;
+ box-sizing: border-box;
+ color: inherit;
+ font: inherit;
+ -ms-overflow-style: none;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+
+ &[data-direction="horizontal"] {
+ overflow-x: auto;
+ overflow-y: hidden;
+
+ /* Both fades */
+ &[data-fade-start][data-fade-end] {
+ mask-image: linear-gradient(
+ to right,
+ transparent,
+ black var(--scroll-fade-start),
+ black calc(100% - var(--scroll-fade-end)),
+ transparent
+ );
+ -webkit-mask-image: linear-gradient(
+ to right,
+ transparent,
+ black var(--scroll-fade-start),
+ black calc(100% - var(--scroll-fade-end)),
+ transparent
+ );
+ }
+
+ /* Only start fade */
+ &[data-fade-start]:not([data-fade-end]) {
+ mask-image: linear-gradient(
+ to right,
+ transparent,
+ black var(--scroll-fade-start),
+ black 100%
+ );
+ -webkit-mask-image: linear-gradient(
+ to right,
+ transparent,
+ black var(--scroll-fade-start),
+ black 100%
+ );
+ }
+
+ /* Only end fade */
+ &:not([data-fade-start])[data-fade-end] {
+ mask-image: linear-gradient(
+ to right,
+ black 0%,
+ black calc(100% - var(--scroll-fade-end)),
+ transparent
+ );
+ -webkit-mask-image: linear-gradient(
+ to right,
+ black 0%,
+ black calc(100% - var(--scroll-fade-end)),
+ transparent
+ );
+ }
+ }
+
+ &[data-direction="vertical"] {
+ overflow-y: auto;
+ overflow-x: hidden;
+
+ /* Both fades */
+ &[data-fade-start][data-fade-end] {
+ mask-image: linear-gradient(
+ to bottom,
+ transparent,
+ black var(--scroll-fade-start),
+ black calc(100% - var(--scroll-fade-end)),
+ transparent
+ );
+ -webkit-mask-image: linear-gradient(
+ to bottom,
+ transparent,
+ black var(--scroll-fade-start),
+ black calc(100% - var(--scroll-fade-end)),
+ transparent
+ );
+ }
+
+ /* Only start fade */
+ &[data-fade-start]:not([data-fade-end]) {
+ mask-image: linear-gradient(
+ to bottom,
+ transparent,
+ black var(--scroll-fade-start),
+ black 100%
+ );
+ -webkit-mask-image: linear-gradient(
+ to bottom,
+ transparent,
+ black var(--scroll-fade-start),
+ black 100%
+ );
+ }
+
+ /* Only end fade */
+ &:not([data-fade-start])[data-fade-end] {
+ mask-image: linear-gradient(
+ to bottom,
+ black 0%,
+ black calc(100% - var(--scroll-fade-end)),
+ transparent
+ );
+ -webkit-mask-image: linear-gradient(
+ to bottom,
+ black 0%,
+ black calc(100% - var(--scroll-fade-end)),
+ transparent
+ );
+ }
+ }
+}
diff --git a/packages/ui/src/components/scroll-fade.tsx b/packages/ui/src/components/scroll-fade.tsx
new file mode 100644
index 00000000000..6d06fdfe9d6
--- /dev/null
+++ b/packages/ui/src/components/scroll-fade.tsx
@@ -0,0 +1,183 @@
+import {
+ type JSX,
+ createEffect,
+ createSignal,
+ onCleanup,
+ onMount,
+ splitProps,
+} from "solid-js"
+import "./scroll-fade.css"
+
+export interface ScrollFadeProps extends JSX.HTMLAttributes
{
+ direction?: "horizontal" | "vertical"
+ fadeStartSize?: number
+ fadeEndSize?: number
+ trackTransformSelector?: string
+ ref?: (el: HTMLDivElement) => void
+}
+
+export function ScrollFade(props: ScrollFadeProps) {
+ const [local, others] = splitProps(props, [
+ "children",
+ "direction",
+ "fadeStartSize",
+ "fadeEndSize",
+ "trackTransformSelector",
+ "class",
+ "style",
+ "ref",
+ ])
+
+ const direction = () => local.direction ?? "vertical"
+ const fadeStartSize = () => local.fadeStartSize ?? 20
+ const fadeEndSize = () => local.fadeEndSize ?? 20
+
+ const getTransformOffset = (element: Element): number => {
+ const style = getComputedStyle(element)
+ const transform = style.transform
+ if (!transform || transform === "none") return 0
+
+ const match = transform.match(/matrix(?:3d)?\(([^)]+)\)/)
+ if (!match) return 0
+
+ const values = match[1].split(",").map((v) => parseFloat(v.trim()))
+ const isHorizontal = direction() === "horizontal"
+
+ if (transform.startsWith("matrix3d")) {
+ return isHorizontal ? -(values[12] || 0) : -(values[13] || 0)
+ } else {
+ return isHorizontal ? -(values[4] || 0) : -(values[5] || 0)
+ }
+ }
+
+ let containerRef: HTMLDivElement | undefined
+
+ const [fadeStart, setFadeStart] = createSignal(0)
+ const [fadeEnd, setFadeEnd] = createSignal(0)
+ const [isScrollable, setIsScrollable] = createSignal(false)
+
+ let lastScrollPos = 0
+ let lastTransformPos = 0
+ let lastScrollSize = 0
+ let lastClientSize = 0
+
+ const updateFade = () => {
+ if (!containerRef) return
+
+ const isHorizontal = direction() === "horizontal"
+ const scrollPos = isHorizontal ? containerRef.scrollLeft : containerRef.scrollTop
+ const scrollSize = isHorizontal ? containerRef.scrollWidth : containerRef.scrollHeight
+ const clientSize = isHorizontal ? containerRef.clientWidth : containerRef.clientHeight
+
+ let transformPos = 0
+ if (local.trackTransformSelector) {
+ const transformElement = containerRef.querySelector(local.trackTransformSelector)
+ if (transformElement) {
+ transformPos = getTransformOffset(transformElement)
+ }
+ }
+
+ const effectiveScrollPos = Math.max(scrollPos, transformPos)
+
+ if (
+ effectiveScrollPos === lastScrollPos &&
+ transformPos === lastTransformPos &&
+ scrollSize === lastScrollSize &&
+ clientSize === lastClientSize
+ ) {
+ return
+ }
+
+ lastScrollPos = effectiveScrollPos
+ lastTransformPos = transformPos
+ lastScrollSize = scrollSize
+ lastClientSize = clientSize
+
+ const maxScroll = scrollSize - clientSize
+ const canScroll = maxScroll > 1
+
+ setIsScrollable(canScroll)
+
+ if (!canScroll) {
+ setFadeStart(0)
+ setFadeEnd(0)
+ return
+ }
+
+ const progress = maxScroll > 0 ? effectiveScrollPos / maxScroll : 0
+
+ const startProgress = Math.min(progress / 0.1, 1)
+ setFadeStart(startProgress * fadeStartSize())
+
+ const endProgress = progress > 0.9 ? (1 - progress) / 0.1 : 1
+ setFadeEnd(Math.max(0, endProgress) * fadeEndSize())
+ }
+
+ onMount(() => {
+ if (!containerRef) return
+
+ updateFade()
+
+ containerRef.addEventListener("scroll", updateFade, { passive: true })
+
+ const resizeObserver = new ResizeObserver(() => {
+ lastScrollSize = 0
+ lastClientSize = 0
+ updateFade()
+ })
+ resizeObserver.observe(containerRef)
+
+ const mutationObserver = new MutationObserver(() => {
+ lastScrollSize = 0
+ lastClientSize = 0
+ requestAnimationFrame(updateFade)
+ })
+ mutationObserver.observe(containerRef, {
+ childList: true,
+ subtree: true,
+ characterData: true,
+ })
+
+ let rafId: number
+ const pollScroll = () => {
+ updateFade()
+ rafId = requestAnimationFrame(pollScroll)
+ }
+ rafId = requestAnimationFrame(pollScroll)
+
+ onCleanup(() => {
+ containerRef?.removeEventListener("scroll", updateFade)
+ resizeObserver.disconnect()
+ mutationObserver.disconnect()
+ cancelAnimationFrame(rafId)
+ })
+ })
+
+ createEffect(() => {
+ local.children
+ requestAnimationFrame(updateFade)
+ })
+
+ return (
+ {
+ containerRef = el
+ local.ref?.(el)
+ }}
+ data-component="scroll-fade"
+ data-direction={direction()}
+ data-scrollable={isScrollable() || undefined}
+ data-fade-start={fadeStart() > 0 || undefined}
+ data-fade-end={fadeEnd() > 0 || undefined}
+ class={local.class}
+ style={{
+ ...(typeof local.style === "object" ? local.style : {}),
+ "--scroll-fade-start": `${fadeStart()}px`,
+ "--scroll-fade-end": `${fadeEnd()}px`,
+ }}
+ {...others}
+ >
+ {local.children}
+
+ )
+}
diff --git a/packages/ui/src/components/scroll-reveal.tsx b/packages/ui/src/components/scroll-reveal.tsx
new file mode 100644
index 00000000000..acf4b516b2e
--- /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={8}
+ direction="horizontal"
+ onMouseEnter={handleMouseEnter}
+ onMouseLeave={handleMouseLeave}
+ {...others}
+ >
+ {local.children}
+
+ )
+}
diff --git a/packages/ui/src/components/select.css b/packages/ui/src/components/select.css
index 49b644db015..0839c47c1d1 100644
--- a/packages/ui/src/components/select.css
+++ b/packages/ui/src/components/select.css
@@ -1,7 +1,11 @@
[data-component="select"] {
[data-slot="select-select-trigger"] {
- padding: 0 4px 0 8px;
+ display: flex;
+ padding: 4px 8px !important;
+ align-items: center;
+ justify-content: space-between;
box-shadow: none;
+ transition: background-color 200ms cubic-bezier(0.25, 0, 0.5, 1);
[data-slot="select-select-trigger-value"] {
overflow: hidden;
@@ -16,9 +20,10 @@
justify-content: center;
flex-shrink: 0;
color: var(--text-weak);
- transition: transform 0.1s ease-in-out;
+ transition: transform 200ms cubic-bezier(0.25, 0, 0.5, 1);
}
+ &:hover,
&[data-expanded] {
&[data-variant="secondary"] {
background-color: var(--button-secondary-hover);
@@ -46,21 +51,26 @@
}
[data-component="select-content"] {
- min-width: 4rem;
+ min-width: 8rem;
max-width: 23rem;
overflow: hidden;
border-radius: var(--radius-md);
background-color: var(--surface-raised-stronger-non-alpha);
- padding: 2px;
+ padding: 4px;
box-shadow: var(--shadow-xs-border);
z-index: 50;
+ transform-origin: var(--kb-popper-content-transform-origin);
+ pointer-events: none;
+
+ animation: selectContentHide 200ms cubic-bezier(0.25, 0, 0.5, 1) forwards;
- &[data-closed] {
- animation: select-close 0.15s ease-out;
+ @starting-style {
+ animation: none;
}
&[data-expanded] {
- animation: select-open 0.15s ease-out;
+ pointer-events: auto;
+ animation: selectContentShow 200ms cubic-bezier(0.25, 0, 0.5, 1) forwards;
}
[data-slot="select-select-content-list"] {
@@ -87,13 +97,13 @@
position: relative;
display: flex;
align-items: center;
- padding: 0 6px 0 6px;
+ padding: 4px 8px;
gap: 12px;
border-radius: var(--radius-sm);
/* text-12-medium */
font-family: var(--font-family-sans);
- font-size: var(--font-size-small);
+ font-size: var(--font-size-base);
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large); /* 166.667% */
@@ -102,13 +112,13 @@
color: var(--text-strong);
transition:
- background-color 0.2s ease-in-out,
- color 0.2s ease-in-out;
+ background-color 200ms cubic-bezier(0.25, 0, 0.5, 1),
+ color 200ms cubic-bezier(0.25, 0, 0.5, 1);
outline: none;
user-select: none;
- &[data-highlighted] {
- background: var(--surface-raised-base-hover);
+ &:hover {
+ background-color: var(--surface-raised-base-hover);
}
&[data-disabled] {
background-color: var(--surface-raised-base);
@@ -131,24 +141,24 @@
}
}
-@keyframes select-open {
+@keyframes selectContentShow {
from {
opacity: 0;
- transform: scale(0.95);
+ transform: scaleY(0.95);
}
to {
opacity: 1;
- transform: scale(1);
+ transform: scaleY(1);
}
}
-@keyframes select-close {
+@keyframes selectContentHide {
from {
opacity: 1;
- transform: scale(1);
+ transform: scaleY(1);
}
to {
opacity: 0;
- transform: scale(0.95);
+ transform: scaleY(0.95);
}
-}
+}
\ No newline at end of file
diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx
index e60fcbee14d..5be00b4e92d 100644
--- a/packages/ui/src/components/select.tsx
+++ b/packages/ui/src/components/select.tsx
@@ -49,6 +49,7 @@ export function Select(props: SelectProps & ButtonProps) {
placement="bottom-start"
value={local.current}
options={grouped()}
+ gutter={12}
optionValue={(x) => (local.value ? local.value(x) : (x as string))}
optionTextValue={(x) => (local.label ? local.label(x) : (x as string))}
optionGroupChildren="options"
@@ -73,7 +74,7 @@ export function Select(props: SelectProps & ButtonProps) {
: (itemProps.item.rawValue as string)}
-
+
)}
diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx
index be5181a985f..349dee891ca 100644
--- a/packages/ui/src/components/session-review.tsx
+++ b/packages/ui/src/components/session-review.tsx
@@ -72,17 +72,17 @@ export const SessionReview = (props: SessionReviewProps) => {
style}
label={(style) => (style === "unified" ? "Unified" : "Split")}
- onSelect={(style) => style && props.onDiffStyleChange?.(style)}
+ onSelect={(style) => style && props.onDiffStyleChange?.(style as SessionReviewDiffStyle)}
/>
{props.actions}