Просмотр исходного кода

Revert "feat(ui): Select, dropdown, popover styles & transitions (#11675)"

This reverts commit 377bf7ff21a4f05807c38675ac70cd08fe67b516.
Adam 2 недель назад
Родитель
Сommit
70cf609ce9

+ 3 - 4
packages/app/src/components/dialog-select-model.tsx

@@ -90,10 +90,9 @@ const ModelList: Component<{
 
 export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
   provider?: string
-  children?: JSX.Element | ((open: boolean) => JSX.Element)
+  children?: JSX.Element
   triggerAs?: T
   triggerProps?: ComponentProps<T>
-  gutter?: number
 }) {
   const [store, setStore] = createStore<{
     open: boolean
@@ -176,14 +175,14 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
       }}
       modal={false}
       placement="top-start"
-      gutter={props.gutter ?? 8}
+      gutter={8}
     >
       <Kobalte.Trigger
         ref={(el) => setStore("trigger", el)}
         as={props.triggerAs ?? "div"}
         {...(props.triggerProps as any)}
       >
-        {typeof props.children === "function" ? props.children(store.open) : props.children}
+        {props.children}
       </Kobalte.Trigger>
       <Kobalte.Portal>
         <Kobalte.Content

+ 42 - 83
packages/app/src/components/prompt-input.tsx

@@ -32,9 +32,7 @@ import { useNavigate, useParams } from "@solidjs/router"
 import { useSync } from "@/context/sync"
 import { useComments } from "@/context/comments"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
-import { MorphChevron } from "@opencode-ai/ui/morph-chevron"
 import { Button } from "@opencode-ai/ui/button"
-import { CycleLabel } from "@opencode-ai/ui/cycle-label"
 import { Icon } from "@opencode-ai/ui/icon"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import type { IconName } from "@opencode-ai/ui/icons/provider"
@@ -44,7 +42,6 @@ import { Select } from "@opencode-ai/ui/select"
 import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { ImagePreview } from "@opencode-ai/ui/image-preview"
-import { ReasoningIcon } from "@opencode-ai/ui/reasoning-icon"
 import { ModelSelectorPopover } from "@/components/dialog-select-model"
 import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
 import { useProviders } from "@/hooks/use-providers"
@@ -1257,7 +1254,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       clearInput()
       client.session
         .shell({
-          sessionID: session?.id || "",
+          sessionID: session.id,
           agent,
           model,
           command: text,
@@ -1280,7 +1277,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         clearInput()
         client.session
           .command({
-            sessionID: session?.id || "",
+            sessionID: session.id,
             command: commandName,
             arguments: args.join(" "),
             agent,
@@ -1436,13 +1433,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
     const optimisticParts = requestParts.map((part) => ({
       ...part,
-      sessionID: session?.id || "",
+      sessionID: session.id,
       messageID,
     })) as unknown as Part[]
 
     const optimisticMessage: Message = {
       id: messageID,
-      sessionID: session?.id || "",
+      sessionID: session.id,
       role: "user",
       time: { created: Date.now() },
       agent,
@@ -1453,9 +1450,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       if (sessionDirectory === projectDirectory) {
         sync.set(
           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)
@@ -1471,9 +1468,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
       globalSync.child(sessionDirectory)[1](
         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)
@@ -1490,7 +1487,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       if (sessionDirectory === projectDirectory) {
         sync.set(
           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)
@@ -1503,7 +1500,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
       globalSync.child(sessionDirectory)[1](
         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)
@@ -1524,15 +1521,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       const worktree = WorktreeState.get(sessionDirectory)
       if (!worktree || worktree.status !== "pending") return true
 
-      if (sessionDirectory === projectDirectory && session?.id) {
-        sync.set("session_status", session?.id, { type: "busy" })
+      if (sessionDirectory === projectDirectory) {
+        sync.set("session_status", session.id, { type: "busy" })
       }
 
       const controller = new AbortController()
 
       const cleanup = () => {
-        if (sessionDirectory === projectDirectory && session?.id) {
-          sync.set("session_status", session?.id, { type: "idle" })
+        if (sessionDirectory === projectDirectory) {
+          sync.set("session_status", session.id, { type: "idle" })
         }
         removeOptimisticMessage()
         for (const item of commentItems) {
@@ -1549,7 +1546,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         restoreInput()
       }
 
-      pending.set(session?.id || "", { abort: controller, cleanup })
+      pending.set(session.id, { abort: controller, cleanup })
 
       const abort = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
         if (controller.signal.aborted) {
@@ -1577,7 +1574,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         if (timer.id === undefined) return
         clearTimeout(timer.id)
       })
-      pending.delete(session?.id || "")
+      pending.delete(session.id)
       if (controller.signal.aborted) return false
       if (result.status === "failed") throw new Error(result.message)
       return true
@@ -1587,7 +1584,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       const ok = await waitForWorktree()
       if (!ok) return
       await client.session.prompt({
-        sessionID: session?.id || "",
+        sessionID: session.id,
         agent,
         model,
         messageID,
@@ -1597,9 +1594,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     }
 
     void send().catch((err) => {
-      pending.delete(session?.id || "")
-      if (sessionDirectory === projectDirectory && session?.id) {
-        sync.set("session_status", session?.id, { type: "idle" })
+      pending.delete(session.id)
+      if (sessionDirectory === projectDirectory) {
+        sync.set("session_status", session.id, { type: "idle" })
       }
       showToast({
         title: language.t("prompt.toast.promptSendFailed.title"),
@@ -1621,28 +1618,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     })
   }
 
-  const currrentModelVariant = createMemo(() => {
-    const modelVariant = local.model.variant.current() ?? ""
-    return modelVariant === "xhigh"
-      ? "xHigh"
-      : modelVariant.length > 0
-        ? modelVariant[0].toUpperCase() + modelVariant.slice(1)
-        : "Default"
-  })
-
-  const reasoningPercentage = createMemo(() => {
-    const variants = local.model.variant.list()
-    const current = local.model.variant.current()
-    const totalEntries = variants.length + 1
-
-    if (totalEntries <= 2 || current === "Default") {
-      return 0
-    }
-
-    const currentIndex = current ? variants.indexOf(current) + 1 : 0
-    return ((currentIndex + 1) / totalEntries) * 100
-  }, [local.model.variant])
-
   return (
     <div class="relative size-full _max-h-[320px] flex flex-col gap-3">
       <Show when={store.popover}>
@@ -1695,7 +1670,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                           </>
                         }
                       >
-                        <Icon name="brain" size="normal" class="text-icon-info-active shrink-0" />
+                        <Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
                         <span class="text-14-regular text-text-strong whitespace-nowrap">
                           @{(item as { type: "agent"; name: string }).name}
                         </span>
@@ -1760,9 +1735,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         }}
       >
         <Show when={store.dragging}>
-          <div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 mr-1 pointer-events-none">
+          <div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
             <div class="flex flex-col items-center gap-2 text-text-weak">
-              <Icon name="photo" size={18} class="text-icon-base stroke-1.5" />
+              <Icon name="photo" class="size-8" />
               <span class="text-14-regular">{language.t("prompt.dropzone.label")}</span>
             </div>
           </div>
@@ -1848,7 +1823,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                     when={attachment.mime.startsWith("image/")}
                     fallback={
                       <div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
-                        <Icon name="folder" size="normal" class="size-6 text-text-base" />
+                        <Icon name="folder" class="size-6 text-text-weak" />
                       </div>
                     }
                   >
@@ -1922,7 +1897,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-2">
+          <div class="flex items-center justify-start gap-0.5">
             <Switch>
               <Match when={store.mode === "shell"}>
                 <div class="flex items-center gap-2 px-2 h-6">
@@ -1943,7 +1918,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                     onSelect={local.agent.set}
                     class="capitalize"
                     variant="ghost"
-                    gutter={12}
                   />
                 </TooltipKeybind>
                 <Show
@@ -1954,19 +1928,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                       title={language.t("command.model.choose")}
                       keybind={command.keybind("model.choose")}
                     >
-                      <Button
-                        as="div"
-                        variant="ghost"
-                        class="px-2"
-                        onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
-                      >
+                      <Button as="div" variant="ghost" onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}>
                         <Show when={local.model.current()?.provider?.id}>
                           <ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
                         </Show>
                         {local.model.current()?.name ?? language.t("dialog.model.select.title")}
-                        <MorphChevron
-                          expanded={!!dialog.active?.id && dialog.active.id.startsWith("select-model-unpaid")}
-                        />
+                        <Icon name="chevron-down" size="small" />
                       </Button>
                     </TooltipKeybind>
                   }
@@ -1976,16 +1943,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                     title={language.t("command.model.choose")}
                     keybind={command.keybind("model.choose")}
                   >
-                    <ModelSelectorPopover triggerAs={Button} triggerProps={{ variant: "ghost" }} gutter={12}>
-                      {(open) => (
-                        <>
-                          <Show when={local.model.current()?.provider?.id}>
-                            <ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
-                          </Show>
-                          {local.model.current()?.name ?? language.t("dialog.model.select.title")}
-                          <MorphChevron expanded={open} class="text-text-weak" />
-                        </>
-                      )}
+                    <ModelSelectorPopover triggerAs={Button} triggerProps={{ variant: "ghost" }}>
+                      <Show when={local.model.current()?.provider?.id}>
+                        <ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
+                      </Show>
+                      {local.model.current()?.name ?? language.t("dialog.model.select.title")}
+                      <Icon name="chevron-down" size="small" />
                     </ModelSelectorPopover>
                   </TooltipKeybind>
                 </Show>
@@ -1998,13 +1961,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                     <Button
                       data-action="model-variant-cycle"
                       variant="ghost"
-                      class="text-text-strong text-12-regular"
+                      class="text-text-base _hidden group-hover/prompt-input:inline-block capitalize text-12-regular"
                       onClick={() => local.model.variant.cycle()}
                     >
-                      <Show when={local.model.variant.list().length > 1}>
-                        <ReasoningIcon percentage={reasoningPercentage()} size={16} strokeWidth={1.25} />
-                      </Show>
-                      <CycleLabel value={currrentModelVariant()} />
+                      {local.model.variant.current() ?? language.t("common.default")}
                     </Button>
                   </TooltipKeybind>
                 </Show>
@@ -2018,7 +1978,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                       variant="ghost"
                       onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
                       classList={{
-                        "_hidden group-hover/prompt-input:flex items-center justify-center": true,
+                        "_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
                         "text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
                         "hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
                       }}
@@ -2040,7 +2000,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
               </Match>
             </Switch>
           </div>
-          <div class="flex items-center gap-1 absolute right-3 bottom-3">
+          <div class="flex items-center gap-3 absolute right-3 bottom-3">
             <input
               ref={fileInputRef}
               type="file"
@@ -2052,19 +2012,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                 e.currentTarget.value = ""
               }}
             />
-            <div class="flex items-center gap-1.5 mr-1.5">
+            <div class="flex items-center gap-2">
               <SessionContextUsage />
               <Show when={store.mode === "normal"}>
                 <Tooltip placement="top" value={language.t("prompt.action.attachFile")}>
                   <Button
                     type="button"
                     variant="ghost"
-                    size="small"
-                    class="px-1"
+                    class="size-6"
                     onClick={() => fileInputRef.click()}
                     aria-label={language.t("prompt.action.attachFile")}
                   >
-                    <Icon name="photo" class="size-6 text-icon-base" />
+                    <Icon name="photo" class="size-4.5" />
                   </Button>
                 </Tooltip>
               </Show>
@@ -2083,7 +2042,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                   <Match when={true}>
                     <div class="flex items-center gap-2">
                       <span>{language.t("prompt.action.send")}</span>
-                      <Icon name="enter" size="normal" class="text-icon-base" />
+                      <Icon name="enter" size="small" class="text-icon-base" />
                     </div>
                   </Match>
                 </Switch>
@@ -2094,7 +2053,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                 disabled={!prompt.dirty() && !working()}
                 icon={working() ? "stop" : "arrow-up"}
                 variant="primary"
-                class="h-6 w-5.5"
+                class="h-6 w-4.5"
                 aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
               />
             </Tooltip>

+ 1 - 1
packages/app/src/components/settings-general.tsx

@@ -226,7 +226,7 @@ export const SettingsGeneral: Component = () => {
                 variant="secondary"
                 size="small"
                 triggerVariant="settings"
-                triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "field-sizing": "content" }}
+                triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }}
               >
                 {(option) => (
                   <span style={{ "font-family": monoFontFamily(option?.value) }}>

+ 11 - 15
packages/ui/src/components/button.css

@@ -9,13 +9,7 @@
   user-select: none;
   cursor: default;
   outline: none;
-  padding: 4px 8px;
   white-space: nowrap;
-  transition-property: background-color, border-color, color, box-shadow, opacity;
-  transition-duration: var(--transition-duration);
-  transition-timing-function: var(--transition-easing);
-  outline: none;
-  line-height: 20px;
 
   &[data-variant="primary"] {
     background-color: var(--button-primary-base);
@@ -100,6 +94,7 @@
     &:active:not(:disabled) {
       background-color: var(--button-secondary-base);
       scale: 0.99;
+      transition: all 150ms ease-out;
     }
     &:disabled {
       border-color: var(--border-disabled);
@@ -115,32 +110,33 @@
 
   &[data-size="small"] {
     height: 22px;
-    padding: 4px 8px;
+    padding: 0 8px;
     &[data-icon] {
-      padding: 4px 12px 4px 4px;
+      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-base);
+    font-size: var(--font-size-small);
     font-style: normal;
     font-weight: var(--font-weight-medium);
+    line-height: var(--line-height-large); /* 166.667% */
     letter-spacing: var(--letter-spacing-normal);
   }
 
   &[data-size="normal"] {
     height: 24px;
-    padding: 4px 6px;
+    line-height: 24px;
+    padding: 0 6px;
     &[data-icon] {
-      padding: 4px 12px 4px 4px;
-    }
-
-    &[aria-haspopup] {
-      padding: 4px 6px 4px 8px;
+      padding: 0 12px 0 4px;
     }
 
+    font-size: var(--font-size-small);
     gap: 6px;
 
     /* text-12-medium */

+ 1 - 1
packages/ui/src/components/button.tsx

@@ -4,7 +4,7 @@ import { Icon, IconProps } from "./icon"
 
 export interface ButtonProps
   extends ComponentProps<typeof Kobalte>,
-    Pick<ComponentProps<"button">, "class" | "classList" | "children" | "style"> {
+    Pick<ComponentProps<"button">, "class" | "classList" | "children"> {
   size?: "small" | "normal" | "large"
   variant?: "primary" | "secondary" | "ghost"
   icon?: IconProps["name"]

+ 0 - 49
packages/ui/src/components/cycle-label.css

@@ -1,49 +0,0 @@
-.cycle-label {
-  --c-duration: 200ms;
-  --c-stagger: 30ms;
-  --c-opacity-start: 0;
-  --c-opacity-end: 1;
-  --c-blur-start: 0px;
-  --c-blur-end: 0px;
-  --c-skew: 10deg;
-
-  display: inline-flex;
-  position: relative;
-
-  transform-style: preserve-3d;
-  perspective: 500px;
-  transition: width var(--transition-duration) var(--transition-easing);
-  will-change: width;
-  overflow: hidden;
-
-  .cycle-char {
-    display: inline-block;
-    transform-style: preserve-3d;
-    min-width: 0.25em;
-    backface-visibility: hidden;
-
-    transition-property: transform, opacity, filter;
-    transition-duration: var(--transition-duration);
-    transition-timing-function: var(--transition-easing);
-    transition-delay: calc(var(--i, 0) * var(--c-stagger));
-
-    &.enter {
-      opacity: var(--c-opacity-end);
-      filter: blur(var(--c-blur-end));
-      transform: translateY(0) rotateX(0) skewX(0);
-    }
-
-    &.exit {
-      opacity: var(--c-opacity-start);
-      filter: blur(var(--c-blur-start));
-      transform: translateY(50%) rotateX(90deg) skewX(var(--c-skew));
-    }
-
-    &.pre {
-      opacity: var(--c-opacity-start);
-      filter: blur(var(--c-blur-start));
-      transition: none;
-      transform: translateY(-50%) rotateX(-90deg) skewX(calc(var(--c-skew) * -1));
-    }
-  }
-}

+ 0 - 135
packages/ui/src/components/cycle-label.tsx

@@ -1,135 +0,0 @@
-import "./cycle-label.css"
-import { createEffect, createSignal, JSX, on } from "solid-js"
-
-export interface CycleLabelProps extends JSX.HTMLAttributes<HTMLSpanElement> {
-  value: string
-  onValueChange?: (value: string) => void
-  duration?: number | ((value: string) => number)
-  stagger?: number
-  opacity?: [number, number]
-  blur?: [number, number]
-  skewX?: number
-  onAnimationStart?: () => void
-  onAnimationEnd?: () => void
-}
-
-const segmenter =
-  typeof Intl !== "undefined" && Intl.Segmenter ? new Intl.Segmenter("en", { granularity: "grapheme" }) : null
-
-const getChars = (text: string): string[] =>
-  segmenter ? Array.from(segmenter.segment(text), (s) => s.segment) : text.split("")
-
-const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
-
-export function CycleLabel(props: CycleLabelProps) {
-  const getDuration = (text: string) => {
-    const d =
-      props.duration ??
-      Number(getComputedStyle(document.documentElement).getPropertyValue("--transition-duration")) ??
-      200
-    return typeof d === "function" ? d(text) : d
-  }
-  const stagger = () => props?.stagger ?? 30
-  const opacity = () => props?.opacity ?? [0, 1]
-  const blur = () => props?.blur ?? [0, 0]
-  const skewX = () => props?.skewX ?? 10
-
-  let containerRef: HTMLSpanElement | undefined
-  let isAnimating = false
-  const [currentText, setCurrentText] = createSignal(props.value)
-
-  const setChars = (el: HTMLElement, text: string, state: "enter" | "exit" | "pre" = "enter") => {
-    el.innerHTML = ""
-    const chars = getChars(text)
-    chars.forEach((char, i) => {
-      const span = document.createElement("span")
-      span.textContent = char === " " ? "\u00A0" : char
-      span.className = `cycle-char ${state}`
-      span.style.setProperty("--i", String(i))
-      el.appendChild(span)
-    })
-  }
-
-  const animateToText = async (newText: string) => {
-    if (!containerRef || isAnimating) return
-    if (newText === currentText()) return
-
-    isAnimating = true
-    props.onAnimationStart?.()
-
-    const dur = getDuration(newText)
-    const stag = stagger()
-
-    containerRef.style.width = containerRef.offsetWidth + "px"
-
-    const oldChars = containerRef.querySelectorAll(".cycle-char")
-    oldChars.forEach((c) => c.classList.replace("enter", "exit"))
-
-    const clone = containerRef.cloneNode(false) as HTMLElement
-    Object.assign(clone.style, {
-      position: "absolute",
-      visibility: "hidden",
-      width: "auto",
-      transition: "none",
-    })
-    setChars(clone, newText)
-    document.body.appendChild(clone)
-    const nextWidth = clone.offsetWidth
-    clone.remove()
-
-    const exitTime = oldChars.length * stag + dur
-    await wait(exitTime * 0.3)
-
-    containerRef.style.width = nextWidth + "px"
-
-    const widthDur = 200
-    await wait(widthDur * 0.3)
-
-    setChars(containerRef, newText, "pre")
-    containerRef.offsetWidth
-
-    Array.from(containerRef.children).forEach((c) => (c.className = "cycle-char enter"))
-    setCurrentText(newText)
-    props.onValueChange?.(newText)
-
-    const enterTime = getChars(newText).length * stag + dur
-    await wait(enterTime)
-
-    containerRef.style.width = ""
-    isAnimating = false
-    props.onAnimationEnd?.()
-  }
-
-  createEffect(
-    on(
-      () => props.value,
-      (newValue) => {
-        if (newValue !== currentText()) {
-          animateToText(newValue)
-        }
-      },
-    ),
-  )
-
-  const initRef = (el: HTMLSpanElement) => {
-    containerRef = el
-    setChars(el, props.value)
-  }
-
-  return (
-    <span
-      ref={initRef}
-      class={`cycle-label ${props.class ?? ""}`}
-      style={{
-        "--c-duration": `${getDuration(currentText())}ms`,
-        "--c-stagger": `${stagger()}ms`,
-        "--c-opacity-start": opacity()[0],
-        "--c-opacity-end": opacity()[1],
-        "--c-blur-start": `${blur()[0]}px`,
-        "--c-blur-end": `${blur()[1]}px`,
-        "--c-skew": `${skewX()}deg`,
-        ...(typeof props.style === "object" ? props.style : {}),
-      }}
-    />
-  )
-}

+ 18 - 27
packages/ui/src/components/dropdown-menu.css

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

+ 2 - 5
packages/ui/src/components/icon.tsx

@@ -80,16 +80,13 @@ const icons = {
 
 export interface IconProps extends ComponentProps<"svg"> {
   name: keyof typeof icons
-  size?: "small" | "normal" | "medium" | "large" | number
+  size?: "small" | "normal" | "medium" | "large"
 }
 
 export function Icon(props: IconProps) {
   const [local, others] = splitProps(props, ["name", "size", "class", "classList"])
   return (
-    <div
-      data-component="icon"
-      data-size={typeof local.size !== "number" ? local.size || "normal" : `size-[${local.size}px]`}
-    >
+    <div data-component="icon" data-size={local.size || "normal"}>
       <svg
         data-slot="icon-svg"
         classList={{

+ 3 - 3
packages/ui/src/components/message-part.tsx

@@ -42,13 +42,13 @@ import { Checkbox } from "./checkbox"
 import { DiffChanges } from "./diff-changes"
 import { Markdown } from "./markdown"
 import { ImagePreview } from "./image-preview"
+import { findLast } from "@opencode-ai/util/array"
 import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path"
 import { checksum } from "@opencode-ai/util/encode"
 import { Tooltip } from "./tooltip"
 import { IconButton } from "./icon-button"
 import { createAutoScroll } from "../hooks"
 import { createResizeObserver } from "@solid-primitives/resize-observer"
-import { MorphChevron } from "./morph-chevron"
 
 interface Diagnostic {
   range: {
@@ -415,7 +415,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
               toggleExpanded()
             }}
           >
-            <MorphChevron expanded={expanded()} />
+            <Icon name="chevron-down" size="small" />
           </button>
           <div data-slot="user-message-copy-wrapper">
             <Tooltip
@@ -898,7 +898,7 @@ ToolRegistry.register({
       if (!sessionId) return undefined
       // Find the tool part that matches the permission's callID
       const messages = data.store.message[sessionId] ?? []
-      const message = messages.findLast((m) => m.id === perm.tool!.messageID)
+      const message = findLast(messages, (m) => m.id === perm.tool!.messageID)
       if (!message) return undefined
       const parts = data.store.part[message.id] ?? []
       for (const part of parts) {

+ 0 - 10
packages/ui/src/components/morph-chevron.css

@@ -1,10 +0,0 @@
-[data-slot="morph-chevron-svg"] {
-  width: 16px;
-  height: 16px;
-  display: block;
-  fill: none;
-  stroke-width: 1.5;
-  stroke: currentcolor;
-  stroke-linecap: round;
-  stroke-linejoin: round;
-}

+ 0 - 73
packages/ui/src/components/morph-chevron.tsx

@@ -1,73 +0,0 @@
-import { createEffect, createUniqueId, on } from "solid-js"
-
-export interface MorphChevronProps {
-  expanded: boolean
-  class?: string
-}
-
-const COLLAPSED = "M4 6L8 10L12 6"
-const EXPANDED = "M4 10L8 6L12 10"
-
-export function MorphChevron(props: MorphChevronProps) {
-  const id = createUniqueId()
-  let path: SVGPathElement | undefined
-  let expandAnim: SVGAnimateElement | undefined
-  let collapseAnim: SVGAnimateElement | undefined
-
-  createEffect(
-    on(
-      () => props.expanded,
-      (expanded, prev) => {
-        if (prev === undefined) {
-          // Set initial state without animation
-          path?.setAttribute("d", expanded ? EXPANDED : COLLAPSED)
-          return
-        }
-        if (expanded) {
-          expandAnim?.beginElement()
-        } else {
-          collapseAnim?.beginElement()
-        }
-      },
-    ),
-  )
-
-  return (
-    <svg
-      viewBox="0 0 16 16"
-      data-slot="morph-chevron-svg"
-      class={props.class}
-      xmlns="http://www.w3.org/2000/svg"
-      aria-hidden="true"
-    >
-      <path ref={path} d={COLLAPSED} id={`morph-chevron-path-${id}`}>
-        <animate
-          ref={(el) => {
-            expandAnim = el
-          }}
-          id={`morph-expand-${id}`}
-          attributeName="d"
-          dur="200ms"
-          fill="freeze"
-          calcMode="spline"
-          keySplines="0.25 0 0.5 1"
-          values="M4 6L8 10L12 6;M4 10L8 6L12 10"
-          begin="indefinite"
-        />
-        <animate
-          ref={(el) => {
-            collapseAnim = el
-          }}
-          id={`morph-collapse-${id}`}
-          attributeName="d"
-          dur="200ms"
-          fill="freeze"
-          calcMode="spline"
-          keySplines="0.25 0 0.5 1"
-          values="M4 10L8 6L12 10;M4 6L8 10L12 6"
-          begin="indefinite"
-        />
-      </path>
-    </svg>
-  )
-}

+ 12 - 46
packages/ui/src/components/popover.css

@@ -15,35 +15,16 @@
 
   transform-origin: var(--kb-popover-content-transform-origin);
 
-  animation: popoverContentHide var(--transition-duration) var(--transition-easing) forwards;
-
-  @starting-style {
-    animation: none;
-  }
-
-  &[data-expanded] {
-    pointer-events: auto;
-    animation: popoverContentShow var(--transition-duration) var(--transition-easing) forwards;
-  }
-
-  [data-origin-top-right] {
-    transform-origin: top right;
-  }
-
-  [data-origin-top-left] {
-    transform-origin: top left;
-  }
-
-  [data-origin-bottom-right] {
-    transform-origin: bottom right;
+  &:focus-within {
+    outline: none;
   }
 
-  [data-origin-bottom-left] {
-    transform-origin: bottom left;
+  &[data-closed] {
+    animation: popover-close 0.15s ease-out;
   }
 
-  &:focus-within {
-    outline: none;
+  &[data-expanded] {
+    animation: popover-open 0.15s ease-out;
   }
 
   [data-slot="popover-header"] {
@@ -94,39 +75,24 @@
   }
 }
 
-@keyframes popoverContentShow {
+@keyframes popover-open {
   from {
     opacity: 0;
-    transform: scaleY(0.95);
+    transform: scale(0.96);
   }
   to {
     opacity: 1;
-    transform: scaleY(1);
+    transform: scale(1);
   }
 }
 
-@keyframes popoverContentHide {
+@keyframes popover-close {
   from {
     opacity: 1;
-    transform: scaleY(1);
+    transform: scale(1);
   }
   to {
     opacity: 0;
-    transform: scaleY(0.95);
-  }
-}
-
-[data-component="model-popover-content"] {
-  transform-origin: var(--kb-popper-content-transform-origin);
-  pointer-events: none;
-  animation: popoverContentHide var(--transition-duration) var(--transition-easing) forwards;
-
-  @starting-style {
-    animation: none;
-  }
-
-  &[data-expanded] {
-    pointer-events: auto;
-    animation: popoverContentShow var(--transition-duration) var(--transition-easing) forwards;
+    transform: scale(0.96);
   }
 }

+ 0 - 9
packages/ui/src/components/reasoning-icon.css

@@ -1,9 +0,0 @@
-[data-component="reasoning-icon"] {
-  color: var(--icon-strong-base);
-
-  [data-slot="reasoning-icon-percentage"] {
-    transition: clip-path 200ms cubic-bezier(0.25, 0, 0.5, 1);
-    clip-path: inset(calc(100% - var(--reasoning-icon-percentage) * 100%) 0 0 0);
-    opacity: calc(var(--reasoning-icon-percentage) * 0.75);
-  }
-}

+ 0 - 46
packages/ui/src/components/reasoning-icon.tsx

@@ -1,46 +0,0 @@
-import { type ComponentProps, splitProps } from "solid-js"
-
-export interface ReasoningIconProps extends Pick<ComponentProps<"svg">, "class" | "classList"> {
-  percentage: number
-  size?: number
-  strokeWidth?: number
-}
-
-export function ReasoningIcon(props: ReasoningIconProps) {
-  const [split, rest] = splitProps(props, ["percentage", "size", "strokeWidth", "class", "classList"])
-
-  const size = () => split.size || 16
-  const strokeWidth = () => split.strokeWidth || 1.25
-
-  return (
-    <svg
-      {...rest}
-      width={size()}
-      height={size()}
-      viewBox={`0 0 16 16`}
-      fill="none"
-      data-component="reasoning-icon"
-      classList={{
-        ...(split.classList ?? {}),
-        [split.class ?? ""]: !!split.class,
-      }}
-    >
-      <path
-        d="M5.83196 10.3225V11.1666C5.83196 11.7189 6.27967 12.1666 6.83196 12.1666H9.16687C9.71915 12.1666 10.1669 11.7189 10.1669 11.1666V10.3225M5.83196 10.3225C5.55695 10.1843 5.29695 10.0206 5.05505 9.83459C3.90601 8.95086 3.16549 7.56219 3.16549 6.00055C3.16549 3.33085 5.32971 1.16663 7.99941 1.16663C10.6691 1.16663 12.8333 3.33085 12.8333 6.00055C12.8333 7.56219 12.0928 8.95086 10.9438 9.83459C10.7019 10.0206 10.4419 10.1843 10.1669 10.3225M5.83196 10.3225H10.1669M6.5 14.1666H9.5"
-        stroke="currentColor"
-        stroke-linecap="round"
-        stroke-linejoin="round"
-      />
-      <circle
-        cx="8"
-        cy="5.83325"
-        r="2.86364"
-        fill="currentColor"
-        stroke="currentColor"
-        stroke-width={strokeWidth()}
-        style={{ "--reasoning-icon-percentage": split.percentage / 100 }}
-        data-slot="reasoning-icon-percentage"
-      />
-    </svg>
-  )
-}

+ 32 - 55
packages/ui/src/components/select.css

@@ -1,13 +1,7 @@
 [data-component="select"] {
   [data-slot="select-select-trigger"] {
-    display: flex;
-    padding: 4px 8px !important;
-    align-items: center;
-    justify-content: space-between;
+    padding: 0 4px 0 8px;
     box-shadow: none;
-    transition-property: background-color;
-    transition-duration: var(--transition-duration);
-    transition-timing-function: var(--transition-easing);
 
     [data-slot="select-select-trigger-value"] {
       overflow: hidden;
@@ -21,10 +15,10 @@
       align-items: center;
       justify-content: center;
       flex-shrink: 0;
-      color: var(--icon-base);
+      color: var(--text-weak);
+      transition: transform 0.1s ease-in-out;
     }
 
-    &:hover,
     &[data-expanded] {
       &[data-variant="secondary"] {
         background-color: var(--button-secondary-hover);
@@ -36,13 +30,13 @@
         background-color: var(--icon-strong-active);
       }
     }
-    &:not([data-expanded]):focus,
+
     &:not([data-expanded]):focus-visible {
       &[data-variant="secondary"] {
         background-color: var(--button-secondary-base);
       }
       &[data-variant="ghost"] {
-        background-color: transparent;
+        background-color: var(--surface-raised-base-hover);
       }
       &[data-variant="primary"] {
         background-color: var(--icon-strong-base);
@@ -52,10 +46,10 @@
 
   &[data-trigger-style="settings"] {
     [data-slot="select-select-trigger"] {
-      padding: 6px 6px 6px 10px;
+      padding: 6px 6px 6px 12px;
       box-shadow: none;
       border-radius: 6px;
-      field-sizing: content;
+      min-width: 160px;
       height: 32px;
       justify-content: flex-end;
       gap: 12px;
@@ -67,7 +61,6 @@
         white-space: nowrap;
         font-size: var(--font-size-base);
         font-weight: var(--font-weight-regular);
-        padding: 4px 8px 4px 4px;
       }
       [data-slot="select-select-trigger-icon"] {
         width: 16px;
@@ -98,26 +91,17 @@
 }
 
 [data-component="select-content"] {
-  min-width: 8rem;
+  min-width: 104px;
   max-width: 23rem;
   overflow: hidden;
   border-radius: var(--radius-md);
   background-color: var(--surface-raised-stronger-non-alpha);
   padding: 4px;
   box-shadow: var(--shadow-xs-border);
-  z-index: 50;
-  transform-origin: var(--kb-popper-content-transform-origin);
-  pointer-events: none;
-
-  animation: selectContentHide var(--transition-duration) var(--transition-easing) forwards;
-
-  @starting-style {
-    animation: none;
-  }
+  z-index: 60;
 
   &[data-expanded] {
-    pointer-events: auto;
-    animation: selectContentShow var(--transition-duration) var(--transition-easing) forwards;
+    animation: select-open 0.15s ease-out;
   }
 
   [data-slot="select-select-content-list"] {
@@ -127,38 +111,43 @@
     overflow-x: hidden;
     display: flex;
     flex-direction: column;
+
     &:focus {
       outline: none;
     }
+
     > *:not([role="presentation"]) + *:not([role="presentation"]) {
       margin-top: 2px;
     }
   }
+
   [data-slot="select-select-item"] {
     position: relative;
     display: flex;
     align-items: center;
-    padding: 4px 8px;
+    padding: 2px 8px;
     gap: 12px;
-    border-radius: var(--radius-sm);
+    border-radius: 4px;
+    cursor: default;
 
     /* text-12-medium */
     font-family: var(--font-family-sans);
-    font-size: var(--font-size-base);
+    font-size: var(--font-size-small);
     font-style: normal;
     font-weight: var(--font-weight-medium);
     line-height: var(--line-height-large); /* 166.667% */
     letter-spacing: var(--letter-spacing-normal);
+
     color: var(--text-strong);
 
-    transition-property: background-color, color;
-    transition-duration: var(--transition-duration);
-    transition-timing-function: var(--transition-easing);
+    transition:
+      background-color 0.2s ease-in-out,
+      color 0.2s ease-in-out;
     outline: none;
     user-select: none;
 
-    &:hover {
-      background-color: var(--surface-raised-base-hover);
+    &[data-highlighted] {
+      background: var(--surface-raised-base-hover);
     }
     &[data-disabled] {
       background-color: var(--surface-raised-base);
@@ -171,11 +160,6 @@
       margin-left: auto;
       width: 16px;
       height: 16px;
-      color: var(--icon-strong-base);
-
-      svg {
-        color: var(--icon-strong-base);
-      }
     }
     &:focus {
       outline: none;
@@ -187,9 +171,13 @@
 }
 
 [data-component="select-content"][data-trigger-style="settings"] {
-  field-sizing: content;
+  min-width: 160px;
   border-radius: 8px;
-  padding: 0 0 0 4px;
+  padding: 0;
+
+  [data-slot="select-select-content-list"] {
+    padding: 4px;
+  }
 
   [data-slot="select-select-item"] {
     /* text-14-regular */
@@ -202,24 +190,13 @@
   }
 }
 
-@keyframes selectContentShow {
+@keyframes select-open {
   from {
     opacity: 0;
-    transform: scaleY(0.95);
+    transform: scale(0.95);
   }
   to {
     opacity: 1;
-    transform: scaleY(1);
-  }
-}
-
-@keyframes selectContentHide {
-  from {
-    opacity: 1;
-    transform: scaleY(1);
-  }
-  to {
-    opacity: 0;
-    transform: scaleY(0.95);
+    transform: scale(1);
   }
 }

+ 4 - 14
packages/ui/src/components/select.tsx

@@ -1,10 +1,8 @@
 import { Select as Kobalte } from "@kobalte/core/select"
-import { createMemo, createSignal, onCleanup, splitProps, type ComponentProps, type JSX } from "solid-js"
+import { createMemo, onCleanup, splitProps, type ComponentProps, type JSX } from "solid-js"
 import { pipe, groupBy, entries, map } from "remeda"
-import { Show } from "solid-js"
 import { Button, ButtonProps } from "./button"
 import { Icon } from "./icon"
-import { MorphChevron } from "./morph-chevron"
 
 export type SelectProps<T> = Omit<ComponentProps<typeof Kobalte<T>>, "value" | "onSelect" | "children"> & {
   placeholder?: string
@@ -40,8 +38,6 @@ export function Select<T>(props: SelectProps<T> & Omit<ButtonProps, "children">)
     "triggerVariant",
   ])
 
-  const [isOpen, setIsOpen] = createSignal(false)
-
   const state = {
     key: undefined as string | undefined,
     cleanup: undefined as (() => void) | void,
@@ -89,7 +85,7 @@ export function Select<T>(props: SelectProps<T> & Omit<ButtonProps, "children">)
       data-component="select"
       data-trigger-style={local.triggerVariant}
       placement={local.triggerVariant === "settings" ? "bottom-end" : "bottom-start"}
-      gutter={8}
+      gutter={4}
       value={local.current}
       options={grouped()}
       optionValue={(x) => (local.value ? local.value(x) : (x as string))}
@@ -119,7 +115,7 @@ export function Select<T>(props: SelectProps<T> & Omit<ButtonProps, "children">)
                 : (itemProps.item.rawValue as string)}
           </Kobalte.ItemLabel>
           <Kobalte.ItemIndicator data-slot="select-select-item-indicator">
-            <Icon name="check" size="small" />
+            <Icon name="check-small" size="small" />
           </Kobalte.ItemIndicator>
         </Kobalte.Item>
       )}
@@ -128,7 +124,6 @@ export function Select<T>(props: SelectProps<T> & Omit<ButtonProps, "children">)
         stop()
       }}
       onOpenChange={(open) => {
-        setIsOpen(open)
         local.onOpenChange?.(open)
         if (!open) stop()
       }}
@@ -154,12 +149,7 @@ export function Select<T>(props: SelectProps<T> & Omit<ButtonProps, "children">)
           }}
         </Kobalte.Value>
         <Kobalte.Icon data-slot="select-select-trigger-icon">
-          <Show when={local.triggerVariant === "settings"}>
-            <Icon name="selector" size="small" />
-          </Show>
-          <Show when={local.triggerVariant !== "settings"}>
-            <MorphChevron expanded={isOpen()} />
-          </Show>
+          <Icon name={local.triggerVariant === "settings" ? "selector" : "chevron-down"} size="small" />
         </Kobalte.Icon>
       </Kobalte.Trigger>
       <Kobalte.Portal>

+ 0 - 2
packages/ui/src/styles/index.css

@@ -49,8 +49,6 @@
 @import "../components/toast.css" layer(components);
 @import "../components/tooltip.css" layer(components);
 @import "../components/typewriter.css" layer(components);
-@import "../components/morph-chevron.css" layer(components);
-@import "../components/reasoning-icon.css" layer(components);
 
 @import "./utilities.css" layer(utilities);
 @import "./animations.css" layer(utilities);

+ 0 - 42
packages/ui/src/styles/utilities.css

@@ -1,17 +1,6 @@
 :root {
   interpolate-size: allow-keywords;
 
-  /* Transition tokens */
-  --transition-duration: 200ms;
-  --transition-easing: cubic-bezier(0.25, 0, 0.5, 1);
-  --transition-fast: 150ms;
-  --transition-slow: 300ms;
-
-  /* Allow height transitions from 0 to auto */
-  @supports (interpolate-size: allow-keywords) {
-    interpolate-size: allow-keywords;
-  }
-
   [data-popper-positioner] {
     pointer-events: none;
   }
@@ -140,34 +129,3 @@
   line-height: var(--line-height-x-large); /* 120% */
   letter-spacing: var(--letter-spacing-tightest);
 }
-
-/* Transition utility classes */
-.transition-colors {
-  transition-property: background-color, border-color, color, fill, stroke;
-  transition-duration: var(--transition-duration);
-  transition-timing-function: var(--transition-easing);
-}
-
-.transition-opacity {
-  transition-property: opacity;
-  transition-duration: var(--transition-duration);
-  transition-timing-function: var(--transition-easing);
-}
-
-.transition-transform {
-  transition-property: transform;
-  transition-duration: var(--transition-duration);
-  transition-timing-function: var(--transition-easing);
-}
-
-.transition-shadow {
-  transition-property: box-shadow;
-  transition-duration: var(--transition-duration);
-  transition-timing-function: var(--transition-easing);
-}
-
-.transition-interactive {
-  transition-property: background-color, border-color, color, box-shadow, opacity;
-  transition-duration: var(--transition-duration);
-  transition-timing-function: var(--transition-easing);
-}