Compare commits

...

25 Commits

Author SHA1 Message Date
Aaron Iker 05a0df5680 Merge branch 'dev' into desktop-poilsh-styles-ui-ux 2026-01-20 01:34:16 +01:00
Aaron Iker 5fdc32c5b3 Merge branch 'dev' into desktop-poilsh-styles-ui-ux 2026-01-20 01:32:24 +01:00
Aaron Iker d6dbdb0b40 feat: small transitions, spacing, polishing 2026-01-20 01:24:48 +01:00
Aaron Iker 0beb8a0bf6 feat: layout, scroll sidebar 2026-01-19 20:46:14 +01:00
Aaron Iker 457c246f10 Merge branch 'dev' into desktop-poilsh-styles-ui-ux 2026-01-19 20:14:40 +01:00
Aaron Iker 413f808dc0 feat: style improvements 2026-01-19 19:16:10 +01:00
Aaron Iker 0860300660 fix: format 2026-01-19 17:26:52 +01:00
Aaron Iker 4658da0128 fix: lint 2026-01-19 17:24:45 +01:00
Aaron Iker ac255c8553 feat: transitions, spacing 2026-01-19 17:16:00 +01:00
Aaron Iker e3b78fc03a Merge branch 'dev' into desktop-poilsh-styles-ui-ux 2026-01-19 15:15:53 +01:00
Aaron Iker 8abcd13e9d feat: message nav 2026-01-19 15:15:00 +01:00
Aaron Iker 97fd10be5d feat: scroll fixes, z-index 2026-01-19 14:27:13 +01:00
Aaron Iker ecbda741d9 feat: scroll fade component 2026-01-19 14:16:16 +01:00
Aaron Iker 0296ab2cee feat: message nav animation 2026-01-19 09:14:18 +01:00
Github Action b6b3867325 Update node_modules hash (aarch64-darwin) 2026-01-16 13:22:32 +00:00
Aaron Iker 0983ee5b58 Merge branch 'dev' into desktop-poilsh-styles-ui-ux 2026-01-16 13:59:49 +01:00
Aaron Iker 6535556203 Merge branch 'dev' into desktop-poilsh-styles-ui-ux 2026-01-16 13:59:38 +01:00
Aaron Iker 5a6c23d484 feat: rename project-avatar 2026-01-15 22:57:10 +01:00
Aaron Iker 26bb424f1d Merge branch 'dev' into desktop-poilsh-styles-ui-ux 2026-01-15 22:49:45 +01:00
Aaron Iker 9f47fa7f97 feat: WIP session nav 2026-01-13 19:41:51 +01:00
Aaron Iker cffdf3320b feat: session scroll snap 2026-01-13 19:41:40 +01:00
Aaron Iker 2610fec62b fix: workspace icon fix, fallback, clean structure 2026-01-13 19:41:29 +01:00
Aaron Iker 1d8f764114 feat: icon button weak color variant 2026-01-13 19:40:56 +01:00
Aaron Iker ae7e07dcb4 fix: search clear icon contrast 2026-01-13 19:40:26 +01:00
Aaron Iker 0ed60110bd feat: lint, wording, WIP 2026-01-13 19:39:35 +01:00
24 changed files with 861 additions and 202 deletions

View File

@ -3,15 +3,20 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { TextField } from "@opencode-ai/ui/text-field"
import { Icon } from "@opencode-ai/ui/icon"
import { Avatar } from "@opencode-ai/ui/avatar"
import { createMemo, createSignal, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useGlobalSDK } from "@/context/global-sdk"
import { type LocalProject, getAvatarColors } from "@/context/layout"
import { getFilename } from "@opencode-ai/util/path"
import { Avatar } from "@opencode-ai/ui/avatar"
import { ProjectAvatar, isValidImageFile } from "@/components/project-avatar"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
function getFilename(input: string) {
const parts = input.split("/")
return parts[parts.length - 1] || input
}
export function DialogEditProject(props: { project: LocalProject }) {
const dialog = useDialog()
const globalSDK = useGlobalSDK()
@ -30,7 +35,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
const [iconHover, setIconHover] = createSignal(false)
function handleFileSelect(file: File) {
if (!file.type.startsWith("image/")) return
if (!isValidImageFile(file)) return
const reader = new FileReader()
reader.onload = (e) => {
setStore("iconUrl", e.target?.result as string)
@ -98,7 +103,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
<div class="flex gap-3 items-start">
<div class="relative" onMouseEnter={() => setIconHover(true)} onMouseLeave={() => setIconHover(false)}>
<div
class="relative size-16 rounded-md transition-colors cursor-pointer"
class="size-16 rounded-md overflow-hidden border border-dashed transition-colors cursor-pointer"
classList={{
"border-text-interactive-base bg-surface-info-base/20": dragOver(),
"border-border-base hover:border-border-strong": !dragOver(),
@ -115,20 +120,13 @@ export function DialogEditProject(props: { project: LocalProject }) {
}
}}
>
<Show
when={store.iconUrl}
fallback={
<div class="size-full flex items-center justify-center">
<Avatar
fallback={store.name || defaultName()}
{...getAvatarColors(store.color)}
class="size-full"
/>
</div>
}
>
<img src={store.iconUrl} alt="Project icon" class="size-full object-cover" />
</Show>
<ProjectAvatar
name={store.name || defaultName()}
projectId={props.project.id}
iconUrl={store.iconUrl}
iconColor={store.color}
class="size-full"
/>
</div>
<div
style={{

View File

@ -73,7 +73,7 @@ export const ModelSelectorPopover: Component<{
const [open, setOpen] = createSignal(false)
return (
<Kobalte open={open()} onOpenChange={setOpen} placement="top-start" gutter={8}>
<Kobalte open={open()} onOpenChange={setOpen} placement="top-start" gutter={12}>
<Kobalte.Trigger as="div">{props.children}</Kobalte.Trigger>
<Kobalte.Portal>
<Kobalte.Content class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden">

View File

@ -0,0 +1,73 @@
import { createMemo, splitProps, type ComponentProps, type JSX } from "solid-js"
import { Avatar } from "@opencode-ai/ui/avatar"
import { getAvatarColors } from "@/context/layout"
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
const OPENCODE_FAVICON_URL = "https://opencode.ai/favicon.svg"
export interface ProjectAvatarProps extends Omit<ComponentProps<"div">, "children"> {
name: string
iconUrl?: string
iconColor?: string
projectId?: string
size?: "small" | "normal" | "large"
}
export const isValidImageUrl = (url: string | undefined): boolean => {
if (!url) {
return false
}
if (url.startsWith("data:image/x-icon")) {
return false
}
if (url.startsWith("data:image/vnd.microsoft.icon")) {
return false
}
return true
}
export const isValidImageFile = (file: File): boolean => {
if (!file.type.startsWith("image/")) {
return false
}
if (file.type === "image/x-icon" || file.type === "image/vnd.microsoft.icon") {
return false
}
return true
}
export const ProjectAvatar = (props: ProjectAvatarProps) => {
const [local, rest] = splitProps(props, [
"name",
"iconUrl",
"iconColor",
"projectId",
"size",
"class",
"classList",
"style",
])
const colors = createMemo(() => getAvatarColors(local.iconColor))
const validSrc = createMemo(() => {
if (isValidImageUrl(local.iconUrl)) {
return local.iconUrl
}
if (local.projectId === OPENCODE_PROJECT_ID) {
return OPENCODE_FAVICON_URL
}
return undefined
})
return (
<Avatar
fallback={local.name}
src={validSrc()}
size={local.size}
{...colors()}
class={local.class}
classList={local.classList}
style={local.style as JSX.CSSProperties}
{...rest}
/>
)
}

View File

@ -794,7 +794,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
.abort({
sessionID: params.id!,
})
.catch(() => {})
.catch(() => { })
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
const text = prompt
@ -1255,7 +1255,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const optimisticParts = requestParts.map((part) => ({
...part,
sessionID: session.id,
sessionID: session?.id ?? "",
messageID,
})) as unknown as Part[]
@ -1273,9 +1273,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const addOptimisticMessage = () => {
setSyncStore(
produce((draft) => {
const messages = draft.message[session.id]
const messages = draft.message[session?.id ?? ""]
if (!messages) {
draft.message[session.id] = [optimisticMessage]
draft.message[session?.id ?? ""] = [optimisticMessage]
} else {
const result = Binary.search(messages, messageID, (m) => m.id)
messages.splice(result.index, 0, optimisticMessage)
@ -1291,7 +1291,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const removeOptimisticMessage = () => {
setSyncStore(
produce((draft) => {
const messages = draft.message[session.id]
const messages = draft.message[session?.id ?? ""]
if (messages) {
const result = Binary.search(messages, messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1)
@ -1567,7 +1567,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</Show>
</div>
<div class="relative p-3 flex items-center justify-between">
<div class="flex items-center justify-start gap-0.5">
<div class="flex items-center justify-start gap-1">
<Switch>
<Match when={store.mode === "shell"}>
<div class="flex items-center gap-2 px-2 h-6">
@ -1618,13 +1618,60 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
title="Thinking effort"
keybind={command.keybind("model.variant.cycle")}
>
<Button
variant="ghost"
class="text-text-base _hidden group-hover/prompt-input:inline-block capitalize text-12-regular"
onClick={() => local.model.variant.cycle()}
>
{local.model.variant.current() ?? "Default"}
</Button>
{(() => {
const [text, setText] = createSignal(local.model.variant.current() ?? "Default")
const [animating, setAnimating] = createSignal(false)
let locked = false
const handleClick = async () => {
if (locked) return
local.model.variant.cycle()
const newText = local.model.variant.current() ?? "Default"
if (newText === text()) return
locked = true
setAnimating(true)
// Wait for exit animation
const charCount = text().length
await new Promise((r) => setTimeout(r, charCount * 40 + 400))
// Reset animating before setting new text so @starting-style works
setAnimating(false)
setText(newText)
// Wait for enter animation
const newCharCount = newText.length
await new Promise((r) => setTimeout(r, newCharCount * 40 + 400))
locked = false
}
return (
<Button
variant="ghost"
class="text-text-base _hidden text-12-regular"
onClick={handleClick}
>
<span data-slot="cycle-text" data-animating={animating()}>
<For each={text().split("")}>
{(char, i) =>
char === " " ? (
<span data-slot="space" />
) : (
<span data-slot="char" style={{ "--i": i() }}>
{i() === 0 ? char.toUpperCase() : char}
</span>
)
}
</For>
</span>
<Icon name="chevron-down" size="small" />
</Button>
)
})()}
</TooltipKeybind>
</Show>
<Show when={permission.permissionsEnabled() && params.id}>
@ -1700,7 +1747,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
disabled={!prompt.dirty() && !working()}
icon={working() ? "stop" : "arrow-up"}
variant="primary"
class="h-6 w-4.5"
class="h-6 w-6"
/>
</Tooltip>
</div>

View File

@ -236,6 +236,7 @@ export function SessionHeader() {
<Show when={shareEnabled() && currentSession()}>
<div class="flex items-center">
<Popover
gutter={16}
title="Publish on web"
description={
shareUrl()
@ -298,7 +299,7 @@ export function SessionHeader() {
</div>
</Popover>
<Show when={shareUrl()} fallback={<div class="size-6" aria-hidden="true" />}>
<Tooltip value={state.copied ? "Copied" : "Copy link"} placement="top" gutter={8}>
<Tooltip value={state.copied ? "Copied" : "Copy link"} placement="top-end" gutter={12}>
<IconButton
icon={state.copied ? "check" : "copy"}
variant="secondary"

View File

@ -20,7 +20,6 @@ import { useLayout, getAvatarColors, LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
import { Persist, persisted } from "@/utils/persist"
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
import { Avatar } from "@opencode-ai/ui/avatar"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
@ -35,7 +34,7 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Dialog } from "@opencode-ai/ui/dialog"
import { getFilename } from "@opencode-ai/util/path"
import { Session, type Message, type TextPart } from "@opencode-ai/sdk/v2/client"
import { Session, type Message, type TextPart, UserMessage } from "@opencode-ai/sdk/v2/client"
import { usePlatform } from "@/context/platform"
import { createStore, produce, reconcile } from "solid-js/store"
import {
@ -60,12 +59,14 @@ import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { DialogSelectProvider } from "@/components/dialog-select-provider"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { useCommand, type CommandOption } from "@/context/command"
import { ProjectAvatar } from "@/components/project-avatar"
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
import { navStart } from "@/utils/perf"
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>
}
>
@ -1277,15 +1280,16 @@ export default function Layout(props: ParentProps) {
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
const mask = "radial-gradient(circle 5px at calc(100% - 4px) 4px, transparent 5px, black 5.5px)"
const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
return (
<div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}>
<div class="size-full rounded overflow-clip">
<Avatar
fallback={name()}
src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.override}
{...getAvatarColors(props.project.icon?.color)}
<div class={`relative size-8 shrink-0 rounded-sm ${props.class ?? ""}`}>
<div class="size-full rounded-sm overflow-clip">
<ProjectAvatar
name={name()}
projectId={props.project.id}
iconUrl={props.project.icon?.url}
iconColor={props.project.icon?.color}
size="small"
class="size-full rounded"
style={
notifications().length > 0 && props.notify
@ -1348,7 +1352,7 @@ export default function Layout(props: ParentProps) {
})
const hoverMessages = createMemo(() =>
sessionStore.message[props.session.id]?.filter((message) => message.role === "user"),
sessionStore.message[props.session.id]?.filter((message) => message.role === "user") as UserMessage[],
)
const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined)
const hoverAllowed = createMemo(() => !props.mobile && layout.sidebar.opened())
@ -1421,7 +1425,7 @@ export default function Layout(props: ParentProps) {
</Tooltip>
}
>
<HoverCard openDelay={150} closeDelay={100} placement="right-start" gutter={16} trigger={item}>
<HoverCard openDelay={150} closeDelay={100} placement="right" gutter={28} trigger={item}>
<Show when={hoverReady()} fallback={<div class="text-12-regular text-text-weak">Loading messages</div>}>
<MessageNav
messages={hoverMessages() ?? []}
@ -1539,7 +1543,7 @@ export default function Layout(props: ParentProps) {
return (
// @ts-ignore
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<div use: sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<Collapsible variant="ghost" open={open()} class="shrink-0" onOpenChange={openWrapper}>
<div class="px-2 py-1">
<div class="group/workspace relative">
@ -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
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<div use: sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<HoverCard
openDelay={0}
closeDelay={0}
@ -1819,7 +1823,7 @@ export default function Layout(props: ParentProps) {
class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar"
style={{ "overflow-anchor": "none" }}
>
<nav class="flex flex-col gap-1 px-2">
<nav class="flex flex-col gap-2 px-2">
<Show when={loading()}>
<SessionSkeleton />
</Show>
@ -1834,7 +1838,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

View File

@ -570,7 +570,7 @@ export default function Page() {
const sessionID = params.id
if (!sessionID) return
if (status()?.type !== "idle") {
await sdk.client.session.abort({ sessionID }).catch(() => {})
await sdk.client.session.abort({ sessionID }).catch(() => { })
}
const revert = info()?.revert?.messageID
// Find the last user message that's not already reverted
@ -653,69 +653,69 @@ export default function Page() {
},
...(sync.data.config.share !== "disabled"
? [
{
id: "session.share",
title: "Share session",
description: "Share this session and copy the URL to clipboard",
category: "Session",
slash: "share",
disabled: !params.id || !!info()?.share?.url,
onSelect: async () => {
if (!params.id) return
await sdk.client.session
.share({ sessionID: params.id })
.then((res) => {
navigator.clipboard.writeText(res.data!.share!.url).catch(() =>
showToast({
title: "Failed to copy URL to clipboard",
variant: "error",
}),
)
})
.then(() =>
{
id: "session.share",
title: "Share session",
description: "Share this session and copy the URL to clipboard",
category: "Session",
slash: "share",
disabled: !params.id || !!info()?.share?.url,
onSelect: async () => {
if (!params.id) return
await sdk.client.session
.share({ sessionID: params.id })
.then((res) => {
navigator.clipboard.writeText(res.data!.share!.url).catch(() =>
showToast({
title: "Session shared",
description: "Share URL copied to clipboard!",
variant: "success",
}),
)
.catch(() =>
showToast({
title: "Failed to share session",
description: "An error occurred while sharing the session",
title: "Failed to copy URL to clipboard",
variant: "error",
}),
)
},
})
.then(() =>
showToast({
title: "Session shared",
description: "Share URL copied to clipboard!",
variant: "success",
}),
)
.catch(() =>
showToast({
title: "Failed to share session",
description: "An error occurred while sharing the session",
variant: "error",
}),
)
},
{
id: "session.unshare",
title: "Unshare session",
description: "Stop sharing this session",
category: "Session",
slash: "unshare",
disabled: !params.id || !info()?.share?.url,
onSelect: async () => {
if (!params.id) return
await sdk.client.session
.unshare({ sessionID: params.id })
.then(() =>
showToast({
title: "Session unshared",
description: "Session unshared successfully!",
variant: "success",
}),
)
.catch(() =>
showToast({
title: "Failed to unshare session",
description: "An error occurred while unsharing the session",
variant: "error",
}),
)
},
},
{
id: "session.unshare",
title: "Unshare session",
description: "Stop sharing this session",
category: "Session",
slash: "unshare",
disabled: !params.id || !info()?.share?.url,
onSelect: async () => {
if (!params.id) return
await sdk.client.session
.unshare({ sessionID: params.id })
.then(() =>
showToast({
title: "Session unshared",
description: "Session unshared successfully!",
variant: "success",
}),
)
.catch(() =>
showToast({
title: "Failed to unshare session",
description: "An error occurred while unsharing the session",
variant: "error",
}),
)
},
]
},
]
: []),
])
@ -1265,7 +1265,7 @@ export default function Page() {
if (isDesktop()) scheduleScrollSpy(e.currentTarget)
}}
onClick={autoScroll.handleInteraction}
class="relative min-w-0 w-full h-full overflow-y-auto no-scrollbar"
class="relative min-w-0 w-full h-full overflow-y-auto no-scrollbar snap-both snap-mandatory"
style={{ "--session-title-height": info()?.title ? "40px" : "0px" }}
>
<Show when={info()?.title}>
@ -1332,13 +1332,12 @@ export default function Page() {
navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" })
})
}
return (
<div
id={anchor(message.id)}
data-message-id={message.id}
classList={{
"min-w-0 w-full max-w-full": true,
"min-w-0 w-full max-w-full snap-both": true,
"md:max-w-200": !showTabs(),
"last:min-h-[calc(100vh-5.5rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-4.5rem-var(--prompt-height,10rem)-64px)]":
platform.platform !== "desktop",

View File

@ -9,11 +9,15 @@
user-select: none;
cursor: default;
outline: none;
padding: 4px 8px;
white-space: nowrap;
transition-property: background-color, border-color, color, box-shadow;
transition-duration: 200ms;
transition-timing-function: cubic-bezier(0.25, 0, 0.5, 1);
&[data-variant="primary"] {
background-color: var(--icon-strong-base);
border-color: var(--border-weak-base);
border-color: var(--border-weaker-base);
color: var(--icon-invert-base);
[data-slot="icon-svg"] {
@ -102,19 +106,15 @@
}
&[data-size="small"] {
height: 22px;
padding: 0 8px;
padding: 2px 10px;
&[data-icon] {
padding: 0 12px 0 4px;
}
font-size: var(--font-size-small);
line-height: var(--line-height-large);
gap: 4px;
/* 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% */
@ -163,3 +163,39 @@
outline: none;
}
}
[data-slot="cycle-text"] {
display: inline-flex;
perspective: 400px;
overflow: hidden;
[data-slot="char"] {
display: inline-block;
transform-style: preserve-3d;
transform-origin: 50% 100%;
transform: rotateX(0deg);
opacity: 1;
transition:
transform 0.4s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);
transition-delay: calc(var(--i, 0) * 40ms);
/* Entry animation using @starting-style */
@starting-style {
transform: rotateX(90deg);
opacity: 0;
}
}
/* Exit animation when animating */
&[data-animating="true"] [data-slot="char"] {
transform: rotateX(-90deg);
opacity: 0;
}
/* Preserve spaces */
[data-slot="space"] {
display: inline-block;
width: 0.25em;
}
}

View File

@ -109,6 +109,7 @@
flex-direction: column;
flex: 1;
overflow-y: auto;
padding-bottom: 12px;
&:focus-visible {
outline: none;

View File

@ -2,26 +2,24 @@
[data-component="dropdown-menu-sub-content"] {
min-width: 8rem;
overflow: hidden;
border: none;
border-radius: var(--radius-md);
border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent);
box-shadow: var(--shadow-xs-border);
background-clip: padding-box;
background-color: var(--surface-raised-stronger-non-alpha);
padding: 4px;
box-shadow: var(--shadow-md);
z-index: 50;
z-index: 100;
transform-origin: var(--kb-menu-content-transform-origin);
animation: dropdownMenuContentHide 200ms cubic-bezier(0.25, 0, 0.5, 1) forwards;
&:focus,
&:focus-visible {
outline: none;
}
&[data-closed] {
animation: dropdown-menu-close 0.15s ease-out;
@starting-style {
animation: none;
}
&[data-expanded] {
animation: dropdown-menu-open 0.15s ease-out;
pointer-events: auto;
animation: dropdownMenuContentShow 200ms cubic-bezier(0.25, 0, 0.5, 1) forwards;
}
}
@ -38,18 +36,23 @@
padding: 4px 8px;
border-radius: var(--radius-sm);
cursor: default;
user-select: none;
outline: none;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-strong);
&[data-highlighted] {
background: var(--surface-raised-base-hover);
transition:
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;
&:hover {
background-color: var(--surface-raised-base-hover);
}
&[data-disabled] {
@ -61,6 +64,8 @@
[data-slot="dropdown-menu-sub-trigger"] {
&[data-expanded] {
background: var(--surface-raised-base-hover);
outline: none;
border: none;
}
}
@ -102,24 +107,24 @@
}
}
@keyframes dropdown-menu-open {
@keyframes dropdownMenuContentShow {
from {
opacity: 0;
transform: scale(0.96);
transform: scaleY(0.95);
}
to {
opacity: 1;
transform: scale(1);
transform: scaleY(1);
}
}
@keyframes dropdown-menu-close {
@keyframes dropdownMenuContentHide {
from {
opacity: 1;
transform: scale(1);
transform: scaleY(1);
}
to {
opacity: 0;
transform: scale(0.96);
transform: scaleY(0.95);
}
}
}

View File

@ -77,7 +77,6 @@
&[data-variant="ghost"] {
background-color: transparent;
/* color: var(--icon-base); */
[data-slot="icon-svg"] {
color: var(--icon-base);
@ -85,25 +84,41 @@
&:hover:not(:disabled) {
background-color: var(--surface-raised-base-hover);
/* [data-slot="icon-svg"] { */
/* color: var(--icon-hover); */
/* } */
}
&:focus:not(:disabled) {
background-color: var(--surface-focus);
}
&:active:not(:disabled) {
background-color: var(--surface-raised-base-active);
/* [data-slot="icon-svg"] { */
/* color: var(--icon-active); */
/* } */
}
&:selected:not(:disabled) {
background-color: var(--surface-raised-base-active);
/* [data-slot="icon-svg"] { */
/* color: var(--icon-selected); */
/* } */
}
}
&[data-variant="weak"] {
opacity: 0.8;
background-color: transparent;
[data-slot="icon-svg"] {
color: var(--icon-base);
}
&:hover:not(:disabled) {
opacity: 1;
background-color: var(--surface-raised-base-hover);
}
&:focus:not(:disabled) {
opacity: 1;
background-color: var(--surface-focus);
}
&:active:not(:disabled) {
opacity: 1;
background-color: var(--surface-raised-base-active);
}
&:selected:not(:disabled) {
opacity: 1;
background-color: var(--surface-raised-base-active);
}
&:disabled {
color: var(--icon-invert-base);

View File

@ -6,7 +6,7 @@ export interface IconButtonProps extends ComponentProps<typeof Kobalte> {
icon: IconProps["name"]
size?: "normal" | "large"
iconSize?: IconProps["size"]
variant?: "primary" | "secondary" | "ghost"
variant?: "primary" | "secondary" | "ghost" | "weak"
}
export function IconButton(props: ComponentProps<"button"> & IconButtonProps) {

View File

@ -8,7 +8,7 @@ const icons = {
"bubble-5": `<path d="M18.3327 9.99935C18.3327 5.57227 15.0919 2.91602 9.99935 2.91602C4.90676 2.91602 1.66602 5.57227 1.66602 9.99935C1.66602 11.1487 2.45505 13.1006 2.57637 13.3939C2.58707 13.4197 2.59766 13.4434 2.60729 13.4697C2.69121 13.6987 3.04209 14.9354 1.66602 16.7674C3.51787 17.6528 5.48453 16.1973 5.48453 16.1973C6.84518 16.9193 8.46417 17.0827 9.99935 17.0827C15.0919 17.0827 18.3327 14.4264 18.3327 9.99935Z" stroke="currentColor" stroke-linecap="square"/>`,
brain: `<path d="M13.332 8.7487C11.4911 8.7487 9.9987 7.25631 9.9987 5.41536M6.66536 11.2487C8.50631 11.2487 9.9987 12.7411 9.9987 14.582M9.9987 2.78209L9.9987 17.0658M16.004 15.0475C17.1255 14.5876 17.9154 13.4849 17.9154 12.1978C17.9154 11.3363 17.5615 10.5575 16.9913 9.9987C17.5615 9.43991 17.9154 8.66108 17.9154 7.79962C17.9154 6.21199 16.7136 4.90504 15.1702 4.73878C14.7858 3.21216 13.4039 2.08203 11.758 2.08203C11.1171 2.08203 10.5162 2.25337 9.9987 2.55275C9.48117 2.25337 8.88032 2.08203 8.23944 2.08203C6.59353 2.08203 5.21157 3.21216 4.82722 4.73878C3.28377 4.90504 2.08203 6.21199 2.08203 7.79962C2.08203 8.66108 2.43585 9.43991 3.00609 9.9987C2.43585 10.5575 2.08203 11.3363 2.08203 12.1978C2.08203 13.4849 2.87191 14.5876 3.99339 15.0475C4.46688 16.7033 5.9917 17.9154 7.79962 17.9154C8.61335 17.9154 9.36972 17.6698 9.9987 17.2488C10.6277 17.6698 11.384 17.9154 12.1978 17.9154C14.0057 17.9154 15.5305 16.7033 16.004 15.0475Z" stroke="currentColor"/>`,
"bullet-list": `<path d="M9.58329 13.7497H17.0833M9.58329 6.24967H17.0833M6.24996 6.24967C6.24996 7.17015 5.50377 7.91634 4.58329 7.91634C3.66282 7.91634 2.91663 7.17015 2.91663 6.24967C2.91663 5.3292 3.66282 4.58301 4.58329 4.58301C5.50377 4.58301 6.24996 5.3292 6.24996 6.24967ZM6.24996 13.7497C6.24996 14.6701 5.50377 15.4163 4.58329 15.4163C3.66282 15.4163 2.91663 14.6701 2.91663 13.7497C2.91663 12.8292 3.66282 12.083 4.58329 12.083C5.50377 12.083 6.24996 12.8292 6.24996 13.7497Z" stroke="currentColor" stroke-linecap="square"/>`,
"check-small": `<path d="M6.5 11.4412L8.97059 13.5L13.5 6.5" stroke="currentColor" stroke-linecap="square"/>`,
"check-small": `<path d="M6.5 11.4412L8.97059 13.5L13.5 6.5" stroke="currentColor" stroke-linecap="square" stroke-width="1.25"/>`,
"chevron-down": `<path d="M6.6665 8.33325L9.99984 11.6666L13.3332 8.33325" stroke="currentColor" stroke-linecap="square"/>`,
"chevron-right": `<path d="M8.33301 13.3327L11.6663 9.99935L8.33301 6.66602" stroke="currentColor" stroke-linecap="square"/>`,
"chevron-grabber-vertical": `<path d="M6.66675 12.4998L10.0001 15.8332L13.3334 12.4998M6.66675 7.49984L10.0001 4.1665L13.3334 7.49984" stroke="currentColor" stroke-linecap="square"/>`,
@ -33,7 +33,7 @@ const icons = {
mcp: `<g><path d="M0.972656 9.37176L9.5214 1.60019C10.7018 0.527151 12.6155 0.527151 13.7957 1.60019C14.9761 2.67321 14.9761 4.41295 13.7957 5.48599L7.3397 11.3552" stroke="currentColor" stroke-linecap="round"/><path d="M7.42871 11.2747L13.7957 5.48643C14.9761 4.41338 16.8898 4.41338 18.0702 5.48643L18.1147 5.52688C19.2951 6.59993 19.2951 8.33966 18.1147 9.4127L10.3831 16.4414C9.98966 16.7991 9.98966 17.379 10.3831 17.7366L11.9707 19.1799" stroke="currentColor" stroke-linecap="round"/><path d="M11.6587 3.54346L5.33619 9.29119C4.15584 10.3642 4.15584 12.1039 5.33619 13.177C6.51649 14.25 8.43019 14.25 9.61054 13.177L15.9331 7.42923" stroke="currentColor" stroke-linecap="round"/></g>`,
glasses: `<path d="M0.416626 7.91667H1.66663M19.5833 7.91667H18.3333M11.866 7.57987C11.3165 7.26398 10.6793 7.08333 9.99996 7.08333C9.32061 7.08333 8.68344 7.26398 8.13389 7.57987M8.74996 10C8.74996 12.0711 7.07103 13.75 4.99996 13.75C2.92889 13.75 1.24996 12.0711 1.24996 10C1.24996 7.92893 2.92889 6.25 4.99996 6.25C7.07103 6.25 8.74996 7.92893 8.74996 10ZM18.75 10C18.75 12.0711 17.071 13.75 15 13.75C12.9289 13.75 11.25 12.0711 11.25 10C11.25 7.92893 12.9289 6.25 15 6.25C17.071 6.25 18.75 7.92893 18.75 10Z" stroke="currentColor" stroke-linecap="square"/>`,
"magnifying-glass-menu": `<path d="M2.08325 10.0002H4.58325M2.08325 5.41683H5.41659M2.08325 14.5835H5.41659M16.4583 13.9585L18.7499 16.2502M17.9166 10.0002C17.9166 12.9917 15.4915 15.4168 12.4999 15.4168C9.50838 15.4168 7.08325 12.9917 7.08325 10.0002C7.08325 7.00862 9.50838 4.5835 12.4999 4.5835C15.4915 4.5835 17.9166 7.00862 17.9166 10.0002Z" stroke="currentColor" stroke-linecap="square"/>`,
"window-cursor": `<path d="M17.9166 10.4167V3.75H2.08325V17.0833H10.4166M17.9166 13.5897L11.6666 11.6667L13.5897 17.9167L15.032 15.0321L17.9166 13.5897Z" stroke="currentColor" stroke-width="1.07143" stroke-linecap="square"/><path d="M5.00024 6.125C5.29925 6.12518 5.54126 6.36795 5.54126 6.66699C5.54108 6.96589 5.29914 7.20783 5.00024 7.20801C4.7012 7.20801 4.45843 6.966 4.45825 6.66699C4.45825 6.36784 4.70109 6.125 5.00024 6.125ZM7.91626 6.125C8.21541 6.125 8.45825 6.36784 8.45825 6.66699C8.45808 6.966 8.21531 7.20801 7.91626 7.20801C7.61736 7.20783 7.37542 6.96589 7.37524 6.66699C7.37524 6.36795 7.61726 6.12518 7.91626 6.125ZM10.8333 6.125C11.1324 6.125 11.3752 6.36784 11.3752 6.66699C11.3751 6.966 11.1323 7.20801 10.8333 7.20801C10.5342 7.20801 10.2914 6.966 10.2913 6.66699C10.2913 6.36784 10.5341 6.125 10.8333 6.125Z" fill="currentColor" stroke="currentColor" stroke-width="0.25" stroke-linecap="square"/>`,
"window-cursor": `<path d="M17.9166 10.4167V3.75H2.08325V17.0833H10.4166M17.9166 13.5897L11.6666 11.6667L13.5897 17.9167L15.032 15.0321L17.9166 13.5897Z" stroke="currentColor" stroke-width="1.25" stroke-linecap="square"/><path d="M5.00024 6.125C5.29925 6.12518 5.54126 6.36795 5.54126 6.66699C5.54108 6.96589 5.29914 7.20783 5.00024 7.20801C4.7012 7.20801 4.45843 6.966 4.45825 6.66699C4.45825 6.36784 4.70109 6.125 5.00024 6.125ZM7.91626 6.125C8.21541 6.125 8.45825 6.36784 8.45825 6.66699C8.45808 6.966 8.21531 7.20801 7.91626 7.20801C7.61736 7.20783 7.37542 6.96589 7.37524 6.66699C7.37524 6.36795 7.61726 6.12518 7.91626 6.125ZM10.8333 6.125C11.1324 6.125 11.3752 6.36784 11.3752 6.66699C11.3751 6.966 11.1323 7.20801 10.8333 7.20801C10.5342 7.20801 10.2914 6.966 10.2913 6.66699C10.2913 6.36784 10.5341 6.125 10.8333 6.125Z" fill="currentColor" stroke="currentColor" stroke-width="0.25" stroke-linecap="square"/>`,
task: `<path d="M9.99992 2.0835V17.9168M7.08325 3.75016H2.08325V16.2502H7.08325M12.9166 16.2502H17.9166V3.75016H12.9166" stroke="currentColor" stroke-linecap="square"/>`,
stop: `<rect x="5" y="5" width="10" height="10" fill="currentColor"/>`,
"layout-left": `<path d="M2.91675 2.91699L2.91675 2.41699L2.41675 2.41699L2.41675 2.91699L2.91675 2.91699ZM17.0834 2.91699L17.5834 2.91699L17.5834 2.41699L17.0834 2.41699L17.0834 2.91699ZM17.0834 17.0837L17.0834 17.5837L17.5834 17.5837L17.5834 17.0837L17.0834 17.0837ZM2.91675 17.0837L2.41675 17.0837L2.41675 17.5837L2.91675 17.5837L2.91675 17.0837ZM7.41674 17.0837L7.41674 17.5837L8.41674 17.5837L8.41674 17.0837L7.91674 17.0837L7.41674 17.0837ZM8.41674 2.91699L8.41674 2.41699L7.41674 2.41699L7.41674 2.91699L7.91674 2.91699L8.41674 2.91699ZM2.91675 2.91699L2.91675 3.41699L17.0834 3.41699L17.0834 2.91699L17.0834 2.41699L2.91675 2.41699L2.91675 2.91699ZM17.0834 2.91699L16.5834 2.91699L16.5834 17.0837L17.0834 17.0837L17.5834 17.0837L17.5834 2.91699L17.0834 2.91699ZM17.0834 17.0837L17.0834 16.5837L2.91675 16.5837L2.91675 17.0837L2.91675 17.5837L17.0834 17.5837L17.0834 17.0837ZM2.91675 17.0837L3.41675 17.0837L3.41675 2.91699L2.91675 2.91699L2.41675 2.91699L2.41675 17.0837L2.91675 17.0837ZM7.91674 17.0837L8.41674 17.0837L8.41674 2.91699L7.91674 2.91699L7.41674 2.91699L7.41674 17.0837L7.91674 17.0837Z" fill="currentColor"/>`,
@ -53,7 +53,7 @@ const icons = {
"dot-grid": `<path d="M2.08398 9.16602H3.75065V10.8327H2.08398V9.16602Z" fill="currentColor"/><path d="M10.834 9.16602H9.16732V10.8327H10.834V9.16602Z" fill="currentColor"/><path d="M16.2507 9.16602H17.9173V10.8327H16.2507V9.16602Z" fill="currentColor"/><path d="M2.08398 9.16602H3.75065V10.8327H2.08398V9.16602Z" stroke="currentColor"/><path d="M10.834 9.16602H9.16732V10.8327H10.834V9.16602Z" stroke="currentColor"/><path d="M16.2507 9.16602H17.9173V10.8327H16.2507V9.16602Z" stroke="currentColor"/>`,
"circle-check": `<path d="M12.4987 7.91732L8.7487 12.5007L7.08203 10.834M17.9154 10.0007C17.9154 14.3729 14.371 17.9173 9.9987 17.9173C5.62644 17.9173 2.08203 14.3729 2.08203 10.0007C2.08203 5.6284 5.62644 2.08398 9.9987 2.08398C14.371 2.08398 17.9154 5.6284 17.9154 10.0007Z" stroke="currentColor" stroke-linecap="square"/>`,
copy: `<path d="M6.2513 6.24935V2.91602H17.0846V13.7493H13.7513M13.7513 6.24935V17.0827H2.91797V6.24935H13.7513Z" stroke="currentColor" stroke-linecap="round"/>`,
check: `<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square"/>`,
check: `<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square" stroke-width="1.25" />`,
photo: `<path d="M16.6665 16.6666L11.6665 11.6666L9.99984 13.3333L6.6665 9.99996L3.08317 13.5833M2.9165 2.91663H17.0832V17.0833H2.9165V2.91663ZM13.3332 7.49996C13.3332 8.30537 12.6803 8.95829 11.8748 8.95829C11.0694 8.95829 10.4165 8.30537 10.4165 7.49996C10.4165 6.69454 11.0694 6.04163 11.8748 6.04163C12.6803 6.04163 13.3332 6.69454 13.3332 7.49996Z" stroke="currentColor" stroke-linecap="square"/>`,
share: `<path d="M10.0013 12.0846L10.0013 3.33464M13.7513 6.66797L10.0013 2.91797L6.2513 6.66797M17.0846 10.418V17.0846H2.91797V10.418" stroke="currentColor" stroke-linecap="square"/>`,
download: `<path d="M13.9583 10.6257L10 14.584L6.04167 10.6257M10 2.08398V13.959M16.25 17.9173H3.75" stroke="currentColor" stroke-linecap="square"/>`,

View File

@ -19,7 +19,7 @@
[data-component="list"] {
display: flex;
flex-direction: column;
gap: 12px;
gap: 8px;
overflow: hidden;
padding: 0 12px;
@ -82,12 +82,9 @@
gap: 12px;
overflow-y: auto;
overscroll-behavior: contain;
mask: linear-gradient(to bottom, #ffff calc(100% - var(--bottom-fade)), #0000);
animation: scroll;
animation-timeline: --scroll;
scroll-timeline: --scroll y;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}

View File

@ -3,6 +3,7 @@ import { createEffect, createSignal, For, onCleanup, type JSX, on, Show } from "
import { createStore } from "solid-js/store"
import { Icon, type IconProps } from "./icon"
import { IconButton } from "./icon-button"
import { ScrollFade } from "./scroll-fade"
import { TextField } from "./text-field"
export interface ListSearchProps {
@ -197,11 +198,17 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
/>
</div>
<Show when={internalFilter()}>
<IconButton icon="circle-x" variant="ghost" onClick={() => setInternalFilter("")} />
<IconButton icon="circle-x" variant="weak" onClick={() => setInternalFilter("")} />
</Show>
</div>
</Show>
<div ref={setScrollRef} data-slot="list-scroll">
<ScrollFade
ref={setScrollRef}
direction="vertical"
fadeStartSize={0}
fadeEndSize={20}
data-slot="list-scroll"
>
<Show
when={flat().length > 0}
fallback={
@ -260,7 +267,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
)}
</For>
</Show>
</div>
</ScrollFade>
</div>
)
}

View File

@ -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;
}
}
}

View File

@ -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(
</Match>
<Match when={local.size === "normal"}>
<button data-slot="message-nav-message-button" onClick={handleClick} onKeyDown={handleKeyPress}>
<DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
<DiffChanges changes={message.summary?.diffs ?? []} variant="bars" class="-ml-1" />
<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>
<ScrollReveal fadeEndSize={12}>
<Show when={local.getLabel?.(message) ?? message.summary?.title} fallback="New message">
{local.getLabel?.(message) ?? message.summary?.title}
</Show>
</ScrollReveal>
</div>
</button>
</Match>
@ -79,4 +82,4 @@ export function MessageNav(
<Match when={local.size === "normal"}>{content()}</Match>
</Switch>
)
}
}

View File

@ -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;
}

View File

@ -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
);
}
}
}

View File

@ -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<HTMLDivElement> {
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 (
<div
ref={(el) => {
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}
</div>
)
}

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={8}
direction="horizontal"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...others}
>
{local.children}
</ScrollFade>
)
}

View File

@ -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;
&[data-closed] {
animation: select-close 0.15s ease-out;
animation: selectContentHide 200ms cubic-bezier(0.25, 0, 0.5, 1) forwards;
@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);
}
}
}

View File

@ -49,6 +49,7 @@ export function Select<T>(props: SelectProps<T> & 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<T>(props: SelectProps<T> & ButtonProps) {
: (itemProps.item.rawValue as string)}
</Kobalte.ItemLabel>
<Kobalte.ItemIndicator data-slot="select-select-item-indicator">
<Icon name="check-small" size="small" />
<Icon name="check" size="small" class="text-icon-strong-base" />
</Kobalte.ItemIndicator>
</Kobalte.Item>
)}

View File

@ -72,17 +72,17 @@ export const SessionReview = (props: SessionReviewProps) => {
<div data-slot="session-review-actions">
<Show when={props.onDiffStyleChange}>
<RadioGroup
options={["unified", "split"] as const}
options={["unified", "split"]}
current={diffStyle()}
value={(style) => style}
label={(style) => (style === "unified" ? "Unified" : "Split")}
onSelect={(style) => style && props.onDiffStyleChange?.(style)}
onSelect={(style) => style && props.onDiffStyleChange?.(style as SessionReviewDiffStyle)}
/>
</Show>
<Button size="normal" icon="chevron-grabber-vertical" onClick={handleExpandOrCollapseAll}>
<Switch>
<Match when={open().length > 0}>Collapse all</Match>
<Match when={true}>Expand all</Match>
<Match when={open().length > 0}>Collapse</Match>
<Match when={true}>Expand</Match>
</Switch>
</Button>
{props.actions}