feat: small transitions, spacing, polishing

This commit is contained in:
Aaron Iker 2026-01-20 01:24:48 +01:00
commit d6dbdb0b40
9 changed files with 131 additions and 35 deletions

View file

@ -1557,7 +1557,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</Show> </Show>
</div> </div>
<div class="relative p-3 flex items-center justify-between"> <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> <Switch>
<Match when={store.mode === "shell"}> <Match when={store.mode === "shell"}>
<div class="flex items-center gap-2 px-2 h-6"> <div class="flex items-center gap-2 px-2 h-6">
@ -1608,13 +1608,60 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
title="Thinking effort" title="Thinking effort"
keybind={command.keybind("model.variant.cycle")} keybind={command.keybind("model.variant.cycle")}
> >
<Button {(() => {
variant="ghost" const [text, setText] = createSignal(local.model.variant.current() ?? "Default")
class="text-text-base _hidden group-hover/prompt-input:inline-block capitalize text-12-regular" const [animating, setAnimating] = createSignal(false)
onClick={() => local.model.variant.cycle()} let locked = false
>
{local.model.variant.current() ?? "Default"} const handleClick = async () => {
</Button> 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> </TooltipKeybind>
</Show> </Show>
<Show when={permission.permissionsEnabled() && params.id}> <Show when={permission.permissionsEnabled() && params.id}>

View file

@ -34,7 +34,7 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { Spinner } from "@opencode-ai/ui/spinner" import { Spinner } from "@opencode-ai/ui/spinner"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { getFilename } from "@opencode-ai/util/path" 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 { usePlatform } from "@/context/platform"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import { import {
@ -1352,7 +1352,7 @@ export default function Layout(props: ParentProps) {
}) })
const hoverMessages = createMemo(() => 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 hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined)
const hoverAllowed = createMemo(() => !props.mobile && layout.sidebar.opened()) const hoverAllowed = createMemo(() => !props.mobile && layout.sidebar.opened())
@ -1425,8 +1425,8 @@ export default function Layout(props: ParentProps) {
</Tooltip> </Tooltip>
} }
> >
<HoverCard openDelay={150} closeDelay={100} placement="right" gutter={12} 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>}> <Show when={hoverReady()} fallback={<div>Loading messages</div>}>
<MessageNav <MessageNav
messages={hoverMessages() ?? []} messages={hoverMessages() ?? []}
current={undefined} current={undefined}
@ -1822,7 +1822,7 @@ export default function Layout(props: ParentProps) {
class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar" class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar"
style={{ "overflow-anchor": "none" }} 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()}> <Show when={loading()}>
<SessionSkeleton /> <SessionSkeleton />
</Show> </Show>

View file

@ -1180,7 +1180,7 @@ export default function Page() {
if (isDesktop()) scheduleScrollSpy(e.currentTarget) if (isDesktop()) scheduleScrollSpy(e.currentTarget)
}} }}
onClick={autoScroll.handleInteraction} onClick={autoScroll.handleInteraction}
class="relative min-w-0 w-full h-full overflow-y-auto no-scrollbar snap-y snap-mandatory" 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" }} style={{ "--session-title-height": info()?.title ? "40px" : "0px" }}
> >
<Show when={info()?.title}> <Show when={info()?.title}>
@ -1252,7 +1252,7 @@ export default function Page() {
id={anchor(message.id)} id={anchor(message.id)}
data-message-id={message.id} data-message-id={message.id}
classList={{ classList={{
"min-w-0 w-full max-w-full snap-start": true, "min-w-0 w-full max-w-full snap-both": true,
"md:max-w-200": !showTabs(), "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)]": "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", platform.platform !== "desktop",

View file

@ -9,6 +9,7 @@
user-select: none; user-select: none;
cursor: default; cursor: default;
outline: none; outline: none;
padding: 4px 8px;
white-space: nowrap; white-space: nowrap;
transition-property: background-color, border-color, color, box-shadow; transition-property: background-color, border-color, color, box-shadow;
transition-duration: 200ms; transition-duration: 200ms;
@ -105,19 +106,15 @@
} }
&[data-size="small"] { &[data-size="small"] {
height: 22px; padding: 2px 10px;
padding: 0 8px;
&[data-icon] { &[data-icon] {
padding: 0 12px 0 4px; padding: 0 12px 0 4px;
} }
font-size: var(--font-size-small);
line-height: var(--line-height-large);
gap: 4px; gap: 4px;
/* text-12-medium */ /* text-12-medium */
font-family: var(--font-family-sans); font-family: var(--font-family-sans);
font-size: var(--font-size-small); font-size: var(--font-size-base);
font-style: normal; font-style: normal;
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
line-height: var(--line-height-large); /* 166.667% */ line-height: var(--line-height-large); /* 166.667% */
@ -166,3 +163,39 @@
outline: none; 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

@ -2,13 +2,14 @@
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px;
align-items: flex-start; align-items: flex-start;
padding-left: 0; padding: 0;
list-style: none; list-style: none;
&[data-size="normal"] { &[data-size="normal"] {
width: 240px; width: 240px;
gap: 4px; gap: 8px;
} }
&[data-size="compact"] { &[data-size="compact"] {
@ -36,6 +37,7 @@
border: none; border: none;
background: none; background: none;
padding: 0; padding: 0;
margin: 0;
&[data-active] [data-slot="message-nav-tick-line"] { &[data-active] [data-slot="message-nav-tick-line"] {
background-color: var(--icon-strong-base); background-color: var(--icon-strong-base);
@ -54,7 +56,8 @@
[data-slot="message-nav-tick-button"]:hover [data-slot="message-nav-tick-line"] { [data-slot="message-nav-tick-button"]:hover [data-slot="message-nav-tick-line"] {
width: 100%; width: 100%;
background-color: var(--icon-strong-base); color: var(--text-strong);
box-sizing: border-box;
} }
[data-slot="message-nav-message-button"] { [data-slot="message-nav-message-button"] {
@ -62,33 +65,38 @@
align-items: center; align-items: center;
align-self: stretch; align-self: stretch;
width: 100%; width: 100%;
color: inherit;
column-gap: 12px; column-gap: 12px;
cursor: default; cursor: default;
border: none; border: none;
background: none; background: none;
padding: 4px 12px; padding: 0;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
[data-slot="message-nav-title-preview"] { [data-slot="message-nav-title-preview"] {
font-size: 14px; /* text-14-regular */ font-size: 14px; /* text-14-regular */
color: var(--text-weak);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
min-width: 0; min-width: 0;
text-align: left; text-align: left;
&:not(:hover) {
color: var(--text-weak);
}
&:hover,
&[data-active] { &[data-active] {
color: var(--text-strong); color: var(--text-strong);
} }
} }
[data-slot="message-nav-item"]:hover [data-slot="message-nav-message-button"] { [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"] { [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"] { [data-slot="message-nav-item"]:active [data-slot="message-nav-title-preview"] {
@ -101,7 +109,7 @@
[data-slot="message-nav-tooltip-content"] { [data-slot="message-nav-tooltip-content"] {
display: flex; display: flex;
padding: 4px 4px 6px 4px; padding: 4px;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
border-radius: var(--radius-md); border-radius: var(--radius-md);

View file

@ -2,6 +2,7 @@ import { UserMessage } from "@opencode-ai/sdk/v2"
import { ComponentProps, For, Match, Show, splitProps, Switch } from "solid-js" import { ComponentProps, For, Match, Show, splitProps, Switch } from "solid-js"
import { DiffChanges } from "./diff-changes" import { DiffChanges } from "./diff-changes"
import { Tooltip } from "@kobalte/core/tooltip" import { Tooltip } from "@kobalte/core/tooltip"
import { ScrollReveal } from "./scroll-reveal"
export function MessageNav( export function MessageNav(
props: ComponentProps<"ul"> & { props: ComponentProps<"ul"> & {
@ -43,14 +44,16 @@ export function MessageNav(
</Match> </Match>
<Match when={local.size === "normal"}> <Match when={local.size === "normal"}>
<button data-slot="message-nav-message-button" onClick={handleClick} onKeyDown={handleKeyPress}> <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 <div
data-slot="message-nav-title-preview" data-slot="message-nav-title-preview"
data-active={message.id === local.current?.id || undefined} data-active={message.id === local.current?.id || undefined}
> >
<Show when={local.getLabel?.(message) ?? message.summary?.title} fallback="New message"> <ScrollReveal fadeEndSize={12}>
{local.getLabel?.(message) ?? message.summary?.title} <Show when={local.getLabel?.(message) ?? message.summary?.title} fallback="New message">
</Show> {local.getLabel?.(message) ?? message.summary?.title}
</Show>
</ScrollReveal>
</div> </div>
</button> </button>
</Match> </Match>

View file

@ -3,6 +3,8 @@
overscroll-behavior: contain; overscroll-behavior: contain;
scrollbar-width: none; scrollbar-width: none;
box-sizing: border-box; box-sizing: border-box;
color: inherit;
font: inherit;
-ms-overflow-style: none; -ms-overflow-style: none;
&::-webkit-scrollbar { &::-webkit-scrollbar {

View file

@ -127,7 +127,7 @@ export function ScrollReveal(props: ScrollRevealProps) {
local.ref?.(el) local.ref?.(el)
}} }}
fadeStartSize={8} fadeStartSize={8}
fadeEndSize={16} fadeEndSize={8}
direction="horizontal" direction="horizontal"
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}

View file

@ -1,6 +1,9 @@
[data-component="select"] { [data-component="select"] {
[data-slot="select-select-trigger"] { [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; box-shadow: none;
transition: background-color 200ms cubic-bezier(0.25, 0, 0.5, 1); transition: background-color 200ms cubic-bezier(0.25, 0, 0.5, 1);