diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index 4f0dcc3ee65e..00e654d8efeb 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -90,9 +90,10 @@ const ModelList: Component<{ export function ModelSelectorPopover(props: { provider?: string - children?: JSX.Element + children?: JSX.Element | ((open: boolean) => JSX.Element) triggerAs?: T - triggerProps?: ComponentProps + triggerProps?: ComponentProps, + gutter?: number }) { const [store, setStore] = createStore<{ open: boolean @@ -175,14 +176,14 @@ export function ModelSelectorPopover(props: { }} modal={false} placement="top-start" - gutter={8} + gutter={props.gutter ?? 8} > setStore("trigger", el)} as={props.triggerAs ?? "div"} {...(props.triggerProps as any)} > - {props.children} + {typeof props.children === "function" ? props.children(store.open) : props.children} = (props) => { .abort({ sessionID, }) - .catch(() => {}) + .catch(() => { }) } const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { @@ -1252,7 +1255,7 @@ export const PromptInput: Component = (props) => { clearInput() client.session .shell({ - sessionID: session.id, + sessionID: session?.id || "", agent, model, command: text, @@ -1275,7 +1278,7 @@ export const PromptInput: Component = (props) => { clearInput() client.session .command({ - sessionID: session.id, + sessionID: session?.id || "", command: commandName, arguments: args.join(" "), agent, @@ -1348,18 +1351,18 @@ export const PromptInput: Component = (props) => { const contextParts: Array< | { - id: string - type: "text" - text: string - synthetic?: boolean - } + id: string + type: "text" + text: string + synthetic?: boolean + } | { - id: string - type: "file" - mime: string - url: string - filename?: string - } + id: string + type: "file" + mime: string + url: string + filename?: string + } > = [] const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => { @@ -1431,13 +1434,13 @@ export const PromptInput: Component = (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, @@ -1448,9 +1451,9 @@ export const PromptInput: Component = (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) @@ -1466,9 +1469,9 @@ export const PromptInput: Component = (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) @@ -1485,7 +1488,7 @@ export const PromptInput: Component = (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) @@ -1498,7 +1501,7 @@ export const PromptInput: Component = (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) @@ -1519,15 +1522,15 @@ export const PromptInput: Component = (props) => { const worktree = WorktreeState.get(sessionDirectory) if (!worktree || worktree.status !== "pending") return true - if (sessionDirectory === projectDirectory) { - sync.set("session_status", session.id, { type: "busy" }) + if (sessionDirectory === projectDirectory && session?.id) { + sync.set("session_status", session?.id, { type: "busy" }) } const controller = new AbortController() const cleanup = () => { - if (sessionDirectory === projectDirectory) { - sync.set("session_status", session.id, { type: "idle" }) + if (sessionDirectory === projectDirectory && session?.id) { + sync.set("session_status", session?.id, { type: "idle" }) } removeOptimisticMessage() for (const item of commentItems) { @@ -1544,7 +1547,7 @@ export const PromptInput: Component = (props) => { restoreInput() } - pending.set(session.id, { abort: controller, cleanup }) + pending.set(session?.id || "", { abort: controller, cleanup }) const abort = new Promise>>((resolve) => { if (controller.signal.aborted) { @@ -1572,7 +1575,7 @@ export const PromptInput: Component = (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 @@ -1582,7 +1585,7 @@ export const PromptInput: Component = (props) => { const ok = await waitForWorktree() if (!ok) return await client.session.prompt({ - sessionID: session.id, + sessionID: session?.id || "", agent, model, messageID, @@ -1592,9 +1595,9 @@ export const PromptInput: Component = (props) => { } void send().catch((err) => { - pending.delete(session.id) - if (sessionDirectory === projectDirectory) { - sync.set("session_status", session.id, { type: "idle" }) + pending.delete(session?.id || "") + if (sessionDirectory === projectDirectory && session?.id) { + sync.set("session_status", session?.id, { type: "idle" }) } showToast({ title: language.t("prompt.toast.promptSendFailed.title"), @@ -1616,6 +1619,28 @@ export const PromptInput: Component = (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 (
@@ -1668,7 +1693,7 @@ export const PromptInput: Component = (props) => { } > - + @{(item as { type: "agent"; name: string }).name} @@ -1729,9 +1754,9 @@ export const PromptInput: Component = (props) => { }} > -
+
- + {language.t("prompt.dropzone.label")}
@@ -1770,7 +1795,7 @@ export const PromptInput: Component = (props) => { }} >
- +
{getFilenameTruncated(item.path, 14)} @@ -1787,7 +1812,7 @@ export const PromptInput: Component = (props) => { type="button" icon="close-small" variant="ghost" - class="ml-auto h-5 w-5 opacity-0 group-hover:opacity-100 transition-all" + class="ml-auto size-7 opacity-0 group-hover:opacity-100 transition-all" onClick={(e) => { e.stopPropagation() if (item.commentID) comments.remove(item.path, item.commentID) @@ -1817,7 +1842,7 @@ export const PromptInput: Component = (props) => { when={attachment.mime.startsWith("image/")} fallback={
- +
} > @@ -1891,7 +1916,7 @@ export const PromptInput: Component = (props) => {
-
+
@@ -1912,6 +1937,7 @@ export const PromptInput: Component = (props) => { onSelect={local.agent.set} class="capitalize" variant="ghost" + gutter={12} /> = (props) => { title={language.t("command.model.choose")} keybind={command.keybind("model.choose")} > - } @@ -1937,12 +1968,16 @@ export const PromptInput: Component = (props) => { title={language.t("command.model.choose")} keybind={command.keybind("model.choose")} > - - - - - {local.model.current()?.name ?? language.t("dialog.model.select.title")} - + + {(open) => ( + <> + + + + {local.model.current()?.name ?? language.t("dialog.model.select.title")} + + + )} @@ -1955,10 +1990,13 @@ export const PromptInput: Component = (props) => { @@ -1972,7 +2010,7 @@ export const PromptInput: Component = (props) => { variant="ghost" onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)} classList={{ - "_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true, + "_hidden group-hover/prompt-input:flex 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), }} @@ -1994,7 +2032,7 @@ export const PromptInput: Component = (props) => {
-
+
= (props) => { e.currentTarget.value = "" }} /> -
+
@@ -2036,7 +2075,7 @@ export const PromptInput: Component = (props) => {
{language.t("prompt.action.send")} - +
@@ -2047,7 +2086,7 @@ export const PromptInput: Component = (props) => { disabled={!prompt.dirty() && !working()} icon={working() ? "stop" : "arrow-up"} variant="primary" - class="h-6 w-4.5" + class="h-6 w-5.5" aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")} /> @@ -2162,4 +2201,4 @@ function setCursorPosition(parent: HTMLElement, position: number) { fallbackRange.collapse(false) fallbackSelection?.removeAllRanges() fallbackSelection?.addRange(fallbackRange) -} +} \ No newline at end of file diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index b31cfb6cc794..e43b82e4a8e6 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -60,24 +60,24 @@ export const SettingsGeneral: Component = () => { const actions = platform.update && platform.restart ? [ - { - label: language.t("toast.update.action.installRestart"), - onClick: async () => { - await platform.update!() - await platform.restart!() - }, + { + label: language.t("toast.update.action.installRestart"), + onClick: async () => { + await platform.update!() + await platform.restart!() }, - { - label: language.t("toast.update.action.notYet"), - onClick: "dismiss" as const, - }, - ] + }, + { + label: language.t("toast.update.action.notYet"), + onClick: "dismiss" as const, + }, + ] : [ - { - label: language.t("toast.update.action.notYet"), - onClick: "dismiss" as const, - }, - ] + { + label: language.t("toast.update.action.notYet"), + onClick: "dismiss" as const, + }, + ] showToast({ persistent: true, @@ -226,7 +226,7 @@ export const SettingsGeneral: Component = () => { variant="secondary" size="small" triggerVariant="settings" - triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }} + triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "field-sizing": "content" }} > {(option) => ( diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css index d9b345923047..3e5d21d1de81 100644 --- a/packages/ui/src/components/button.css +++ b/packages/ui/src/components/button.css @@ -9,7 +9,13 @@ 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); @@ -94,7 +100,6 @@ &:active:not(:disabled) { background-color: var(--button-secondary-base); scale: 0.99; - transition: all 150ms ease-out; } &:disabled { border-color: var(--border-disabled); @@ -109,34 +114,31 @@ } &[data-size="small"] { - height: 22px; - padding: 0 8px; + padding: 4px 8px; &[data-icon] { - padding: 0 12px 0 4px; + padding: 4px 12px 4px 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% */ letter-spacing: var(--letter-spacing-normal); } &[data-size="normal"] { - height: 24px; - line-height: 24px; - padding: 0 6px; + padding: 4px 6px; &[data-icon] { - padding: 0 12px 0 4px; + padding: 4px 12px 4px 4px; + } + + &[aria-haspopup] { + padding: 4px 6px 4px 8px; } - font-size: var(--font-size-small); gap: 6px; /* text-12-medium */ @@ -148,7 +150,6 @@ } &[data-size="large"] { - height: 32px; padding: 6px 12px; &[data-icon] { diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx index 7f974b2f76e7..1b110ca8afce 100644 --- a/packages/ui/src/components/button.tsx +++ b/packages/ui/src/components/button.tsx @@ -4,7 +4,7 @@ import { Icon, IconProps } from "./icon" export interface ButtonProps extends ComponentProps, - Pick, "class" | "classList" | "children"> { + Pick, "class" | "classList" | "children" | "style"> { size?: "small" | "normal" | "large" variant?: "primary" | "secondary" | "ghost" icon?: IconProps["name"] diff --git a/packages/ui/src/components/cycle-label.css b/packages/ui/src/components/cycle-label.css new file mode 100644 index 000000000000..3c98fcd261eb --- /dev/null +++ b/packages/ui/src/components/cycle-label.css @@ -0,0 +1,49 @@ +.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)); + } + } +} diff --git a/packages/ui/src/components/cycle-label.tsx b/packages/ui/src/components/cycle-label.tsx new file mode 100644 index 000000000000..f1eaa88f31c4 --- /dev/null +++ b/packages/ui/src/components/cycle-label.tsx @@ -0,0 +1,132 @@ +import "./cycle-label.css" +import { createEffect, createSignal, JSX, on } from "solid-js" + +export interface CycleLabelProps extends JSX.HTMLAttributes { + 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 ( + + ) +} \ No newline at end of file diff --git a/packages/ui/src/components/dropdown-menu.css b/packages/ui/src/components/dropdown-menu.css index cba041613eab..18266ac1a1c7 100644 --- a/packages/ui/src/components/dropdown-menu.css +++ b/packages/ui/src/components/dropdown-menu.css @@ -2,26 +2,29 @@ [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); - &:focus, - &:focus-visible { + &:focus-within, + &:focus { outline: none; } - &[data-closed] { - animation: dropdown-menu-close 0.15s ease-out; + animation: dropdownMenuContentHide var(--transition-duration) var(--transition-easing) forwards; + + @starting-style { + animation: none; } &[data-expanded] { - animation: dropdown-menu-open 0.15s ease-out; + pointer-events: auto; + animation: dropdownMenuContentShow var(--transition-duration) var(--transition-easing) forwards; } } @@ -38,18 +41,22 @@ 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-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-disabled] { @@ -61,6 +68,8 @@ [data-slot="dropdown-menu-sub-trigger"] { &[data-expanded] { background: var(--surface-raised-base-hover); + outline: none; + border: none; } } @@ -102,24 +111,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); } } diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 544c6abdd214..f23357293ee6 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -80,13 +80,13 @@ const icons = { export interface IconProps extends ComponentProps<"svg"> { name: keyof typeof icons - size?: "small" | "normal" | "medium" | "large" + size?: "small" | "normal" | "medium" | "large" | number } export function Icon(props: IconProps) { const [local, others] = splitProps(props, ["name", "size", "class", "classList"]) return ( -
+
- +
m.id === perm.tool!.messageID) + const message = messages.findLast((m) => m.id === perm.tool!.messageID) if (!message) return undefined const parts = data.store.part[message.id] ?? [] for (const part of parts) { diff --git a/packages/ui/src/components/morph-chevron.css b/packages/ui/src/components/morph-chevron.css new file mode 100644 index 000000000000..f6edb3f649ac --- /dev/null +++ b/packages/ui/src/components/morph-chevron.css @@ -0,0 +1,10 @@ +[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; +} diff --git a/packages/ui/src/components/morph-chevron.tsx b/packages/ui/src/components/morph-chevron.tsx new file mode 100644 index 000000000000..a14a2252b6ad --- /dev/null +++ b/packages/ui/src/components/morph-chevron.tsx @@ -0,0 +1,73 @@ +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 ( + + ) +} \ No newline at end of file diff --git a/packages/ui/src/components/popover.css b/packages/ui/src/components/popover.css index b49542afd9b8..d200fe8b2476 100644 --- a/packages/ui/src/components/popover.css +++ b/packages/ui/src/components/popover.css @@ -15,16 +15,35 @@ transform-origin: var(--kb-popover-content-transform-origin); - &:focus-within { - outline: none; - } + animation: popoverContentHide var(--transition-duration) var(--transition-easing) forwards; - &[data-closed] { - animation: popover-close 0.15s ease-out; + @starting-style { + animation: none; } &[data-expanded] { - animation: popover-open 0.15s ease-out; + 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; + } + + [data-origin-bottom-left] { + transform-origin: bottom left; + } + + &:focus-within { + outline: none; } [data-slot="popover-header"] { @@ -75,24 +94,39 @@ } } -@keyframes popover-open { +@keyframes popoverContentShow { from { opacity: 0; - transform: scale(0.96); + transform: scaleY(0.95); } to { opacity: 1; - transform: scale(1); + transform: scaleY(1); } } -@keyframes popover-close { +@keyframes popoverContentHide { from { opacity: 1; - transform: scale(1); + transform: scaleY(1); } to { opacity: 0; - transform: scale(0.96); + 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; } } diff --git a/packages/ui/src/components/reasoning-icon.css b/packages/ui/src/components/reasoning-icon.css new file mode 100644 index 000000000000..26fbc0144896 --- /dev/null +++ b/packages/ui/src/components/reasoning-icon.css @@ -0,0 +1,9 @@ +[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); + } +} diff --git a/packages/ui/src/components/reasoning-icon.tsx b/packages/ui/src/components/reasoning-icon.tsx new file mode 100644 index 000000000000..ba753b3b843d --- /dev/null +++ b/packages/ui/src/components/reasoning-icon.tsx @@ -0,0 +1,32 @@ +import { type ComponentProps, splitProps } from "solid-js" + +export interface ReasoningIconProps extends Pick, "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 ( + + + + + ) +} \ No newline at end of file diff --git a/packages/ui/src/components/select.css b/packages/ui/src/components/select.css index 25dd2eb40b67..eaba6fd6d2a1 100644 --- a/packages/ui/src/components/select.css +++ b/packages/ui/src/components/select.css @@ -1,7 +1,13 @@ [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-property: background-color; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); [data-slot="select-select-trigger-value"] { overflow: hidden; @@ -15,10 +21,10 @@ align-items: center; justify-content: center; flex-shrink: 0; - color: var(--text-weak); - transition: transform 0.1s ease-in-out; + color: var(--icon-base); } + &:hover, &[data-expanded] { &[data-variant="secondary"] { background-color: var(--button-secondary-hover); @@ -30,13 +36,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: var(--surface-raised-base-hover); + background-color: transparent; } &[data-variant="primary"] { background-color: var(--icon-strong-base); @@ -46,10 +52,10 @@ &[data-trigger-style="settings"] { [data-slot="select-select-trigger"] { - padding: 6px 6px 6px 12px; + padding: 6px 6px 6px 10px; box-shadow: none; border-radius: 6px; - min-width: 160px; + field-sizing: content; height: 32px; justify-content: flex-end; gap: 12px; @@ -61,6 +67,7 @@ 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; @@ -91,17 +98,26 @@ } [data-component="select-content"] { - min-width: 104px; + min-width: 8rem; 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: 60; + 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; + } &[data-expanded] { - animation: select-open 0.15s ease-out; + pointer-events: auto; + animation: selectContentShow var(--transition-duration) var(--transition-easing) forwards; } [data-slot="select-select-content-list"] { @@ -111,43 +127,38 @@ 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: 2px 8px; + padding: 4px 8px; gap: 12px; - border-radius: 4px; - cursor: default; + 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% */ letter-spacing: var(--letter-spacing-normal); - color: var(--text-strong); - transition: - background-color 0.2s ease-in-out, - color 0.2s ease-in-out; + transition-property: background-color, color; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); 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); @@ -160,6 +171,11 @@ margin-left: auto; width: 16px; height: 16px; + color: var(--icon-strong-base); + + svg { + color: var(--icon-strong-base); + } } &:focus { outline: none; @@ -171,13 +187,9 @@ } [data-component="select-content"][data-trigger-style="settings"] { - min-width: 160px; + field-sizing: content; border-radius: 8px; - padding: 0; - - [data-slot="select-select-content-list"] { - padding: 4px; - } + padding: 0 0 0 4px; [data-slot="select-select-item"] { /* text-14-regular */ @@ -190,13 +202,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 selectContentHide { + from { + opacity: 1; + transform: scaleY(1); + } + to { + opacity: 0; + transform: scaleY(0.95); } } diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx index 0386c329ec4b..66f48e69b106 100644 --- a/packages/ui/src/components/select.tsx +++ b/packages/ui/src/components/select.tsx @@ -1,8 +1,10 @@ import { Select as Kobalte } from "@kobalte/core/select" -import { createMemo, onCleanup, splitProps, type ComponentProps, type JSX } from "solid-js" +import { createMemo, createSignal, 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 = Omit>, "value" | "onSelect" | "children"> & { placeholder?: string @@ -38,6 +40,8 @@ export function Select(props: SelectProps & Omit) "triggerVariant", ]) + const [isOpen, setIsOpen] = createSignal(false) + const state = { key: undefined as string | undefined, cleanup: undefined as (() => void) | void, @@ -85,7 +89,7 @@ export function Select(props: SelectProps & Omit) data-component="select" data-trigger-style={local.triggerVariant} placement={local.triggerVariant === "settings" ? "bottom-end" : "bottom-start"} - gutter={4} + gutter={8} value={local.current} options={grouped()} optionValue={(x) => (local.value ? local.value(x) : (x as string))} @@ -115,7 +119,7 @@ export function Select(props: SelectProps & Omit) : (itemProps.item.rawValue as string)} - + )} @@ -124,6 +128,7 @@ export function Select(props: SelectProps & Omit) stop() }} onOpenChange={(open) => { + setIsOpen(open) local.onOpenChange?.(open) if (!open) stop() }} @@ -149,7 +154,12 @@ export function Select(props: SelectProps & Omit) }} - + + + + + + @@ -166,4 +176,4 @@ export function Select(props: SelectProps & Omit) ) -} +} \ No newline at end of file diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 3ed0310ef2b6..cae48137ba60 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -48,6 +48,8 @@ @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); diff --git a/packages/ui/src/styles/utilities.css b/packages/ui/src/styles/utilities.css index 8c954f1fe4e7..82a913c8830c 100644 --- a/packages/ui/src/styles/utilities.css +++ b/packages/ui/src/styles/utilities.css @@ -1,6 +1,17 @@ :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; } @@ -129,3 +140,34 @@ 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); +}