From 4246cdb069502c96ab11e260eb36a07a0370b710 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 14 Dec 2025 19:33:40 -0600 Subject: wip(desktop): progress --- packages/ui/src/components/dialog.css | 27 ++++- packages/ui/src/components/list.css | 38 ++++++++ packages/ui/src/components/list.tsx | 141 +++++++++++++++++---------- packages/ui/src/components/select-dialog.css | 44 --------- packages/ui/src/components/select-dialog.tsx | 93 ------------------ packages/ui/src/components/session-turn.css | 2 - packages/ui/src/components/session-turn.tsx | 4 +- packages/ui/src/components/switch.css | 131 +++++++++++++++++++++++++ packages/ui/src/components/switch.tsx | 30 ++++++ packages/ui/src/hooks/use-filtered-list.tsx | 11 ++- packages/ui/src/styles/index.css | 2 +- 11 files changed, 320 insertions(+), 203 deletions(-) delete mode 100644 packages/ui/src/components/select-dialog.css delete mode 100644 packages/ui/src/components/select-dialog.tsx create mode 100644 packages/ui/src/components/switch.css create mode 100644 packages/ui/src/components/switch.tsx (limited to 'packages/ui') diff --git a/packages/ui/src/components/dialog.css b/packages/ui/src/components/dialog.css index 979906e26..fa5e1171e 100644 --- a/packages/ui/src/components/dialog.css +++ b/packages/ui/src/components/dialog.css @@ -59,9 +59,7 @@ [data-slot="dialog-header"] { display: flex; - /* height: 40px; */ - /* padding: 4px 4px 4px 8px; */ - padding: 20px; + padding: 16px; justify-content: space-between; align-items: center; flex-shrink: 0; @@ -80,7 +78,28 @@ } /* [data-slot="dialog-close-button"] {} */ } - /* [data-slot="dialog-description"] {} */ + + [data-slot="dialog-description"] { + display: flex; + padding: 16px; + padding-top: 0; + margin-top: -8px; + justify-content: space-between; + align-items: center; + flex-shrink: 0; + align-self: stretch; + + color: var(--text-base); + + /* text-14-regular */ + font-family: var(--font-family-sans); + font-size: 14px; + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); /* 142.857% */ + letter-spacing: var(--letter-spacing-normal); + } + [data-slot="dialog-body"] { width: 100%; position: relative; diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css index 132824164..cd9e73d1d 100644 --- a/packages/ui/src/components/list.css +++ b/packages/ui/src/components/list.css @@ -2,6 +2,43 @@ display: flex; flex-direction: column; gap: 20px; + overflow: hidden; + + [data-slot="list-search"] { + display: flex; + height: 40px; + flex-shrink: 0; + padding: 4px 10px 4px 16px; + align-items: center; + gap: 12px; + align-self: stretch; + + border-radius: var(--radius-md); + background: var(--surface-base); + + [data-slot="list-search-container"] { + display: flex; + align-items: center; + gap: 16px; + flex: 1 0 0; + + [data-slot="list-search-input"] { + width: 100%; + } + } + } + + [data-slot="list-scroll"] { + display: flex; + flex-direction: column; + gap: 20px; + overflow-y: auto; + scrollbar-width: none; + -ms-overflow-style: none; + &::-webkit-scrollbar { + display: none; + } + } [data-slot="list-empty-state"] { display: flex; @@ -41,6 +78,7 @@ [data-slot="list-header"] { display: flex; + z-index: 10; height: 28px; padding: 0 10px; justify-content: space-between; diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 013767e60..2923956a9 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -2,6 +2,13 @@ import { createEffect, Show, For, type JSX, createSignal } from "solid-js" import { createStore } from "solid-js/store" import { FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks" import { Icon, IconProps } from "./icon" +import { IconButton } from "./icon-button" +import { TextField } from "./text-field" + +export interface ListSearchProps { + placeholder?: string + autofocus?: boolean +} export interface ListProps extends FilteredListProps { class?: string @@ -10,6 +17,7 @@ export interface ListProps extends FilteredListProps { onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void activeIcon?: IconProps["name"] filter?: string + search?: ListSearchProps | boolean } export interface ListRef { @@ -19,23 +27,22 @@ export interface ListRef { export function List(props: ListProps & { ref?: (ref: ListRef) => void }) { const [scrollRef, setScrollRef] = createSignal(undefined) + const [internalFilter, setInternalFilter] = createSignal("") const [store, setStore] = createStore({ mouseActive: false, }) - const { filter, grouped, flat, reset, active, setActive, onKeyDown, onInput } = useFilteredList({ - items: props.items, - key: props.key, - filterKeys: props.filterKeys, - current: props.current, - groupBy: props.groupBy, - sortBy: props.sortBy, - sortGroupsBy: props.sortGroupsBy, - }) + const { filter, grouped, flat, reset, active, setActive, onKeyDown, onInput } = useFilteredList(props) + + const searchProps = () => (typeof props.search === "object" ? props.search : {}) + const hasSearch = () => !!props.search createEffect(() => { - if (props.filter === undefined) return - onInput(props.filter) + if (props.filter !== undefined) { + onInput(props.filter) + } else if (hasSearch()) { + onInput(internalFilter()) + } }) createEffect(() => { @@ -92,52 +99,78 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) }) return ( -
- 0} - fallback={ -
-
- {props.emptyMessage ?? "No results"} for "{filter()}" -
+
+ +
+
+ +
- } - > - - {(group) => ( -
- -
{group.category}
-
-
- - {(item, i) => ( - - )} - + + setInternalFilter("")} /> + +
+ +
+ 0} + fallback={ +
+
+ {props.emptyMessage ?? "No results"} for "{filter()}"
- )} - -
+ } + > + + {(group) => ( +
+ +
{group.category}
+
+
+ + {(item, i) => ( + + )} + +
+
+ )} +
+ +
) } diff --git a/packages/ui/src/components/select-dialog.css b/packages/ui/src/components/select-dialog.css deleted file mode 100644 index 9759174a6..000000000 --- a/packages/ui/src/components/select-dialog.css +++ /dev/null @@ -1,44 +0,0 @@ -[data-slot="select-dialog-content"] { - width: 100%; - display: flex; - flex-direction: column; - overflow: hidden; - gap: 20px; - padding: 0 10px; - - [data-slot="dialog-body"] { - scrollbar-width: none; - -ms-overflow-style: none; - &::-webkit-scrollbar { - display: none; - } - } -} - -[data-component="select-dialog-input"] { - display: flex; - height: 40px; - flex-shrink: 0; - padding: 4px 10px 4px 16px; - align-items: center; - gap: 12px; - align-self: stretch; - - border-radius: var(--radius-md); - background: var(--surface-base); - - [data-slot="select-dialog-input-container"] { - display: flex; - align-items: center; - gap: 16px; - flex: 1 0 0; - - /* [data-slot="select-dialog-icon"] {} */ - - [data-slot="select-dialog-input"] { - width: 100%; - } - } - - /* [data-slot="select-dialog-clear-button"] {} */ -} diff --git a/packages/ui/src/components/select-dialog.tsx b/packages/ui/src/components/select-dialog.tsx deleted file mode 100644 index 68707536a..000000000 --- a/packages/ui/src/components/select-dialog.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { Show, type JSX, splitProps, createSignal } from "solid-js" -import { Dialog, DialogProps } from "./dialog" -import { Icon } from "./icon" -import { IconButton } from "./icon-button" -import { List, ListRef, ListProps } from "./list" -import { TextField } from "./text-field" - -interface SelectDialogProps - extends Omit, "filter">, - Pick { - title: string - placeholder?: string - actions?: JSX.Element -} - -export function SelectDialog(props: SelectDialogProps) { - const [dialog, others] = splitProps(props, ["trigger", "onOpenChange", "defaultOpen"]) - let closeButton!: HTMLButtonElement - let inputRef: HTMLInputElement | undefined - const [filter, setFilter] = createSignal("") - let listRef: ListRef | undefined - - const handleSelect = (item: T | undefined, index: number) => { - others.onSelect?.(item, index) - closeButton.click() - } - - const handleKey = (e: KeyboardEvent) => { - if (e.key === "Escape") return - listRef?.onKeyDown(e) - } - - const handleOpenChange = (open: boolean) => { - if (!open) setFilter("") - props.onOpenChange?.(open) - } - - return ( - - - {others.title} - {others.actions} - - -
-
-
- - -
- - setFilter("")} /> - -
- - { - listRef = ref - }} - items={others.items} - key={others.key} - filterKeys={others.filterKeys} - current={others.current} - groupBy={others.groupBy} - sortBy={others.sortBy} - sortGroupsBy={others.sortGroupsBy} - emptyMessage={others.emptyMessage} - activeIcon={others.activeIcon} - filter={filter()} - onSelect={handleSelect} - onKeyEvent={others.onKeyEvent} - > - {others.children} - - -
-
- ) -} diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index bc61318e3..0f218b515 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -37,7 +37,6 @@ top: 0; background-color: var(--background-stronger); z-index: 21; - /* padding-bottom: clamp(0px, calc(8px - var(--scroll-y) * 0.16), 8px); */ } [data-slot="session-turn-response-trigger"] { @@ -297,7 +296,6 @@ [data-slot="session-turn-collapsible"] { gap: 32px; overflow: visible; - /* margin-top: clamp(8px, calc(24px - var(--scroll-y) * 0.32), 24px); */ } [data-slot="session-turn-collapsible-trigger-content"] { diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 196e0bdb6..ad2e6c36e 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -60,6 +60,8 @@ export function SessionTurn( function handleScroll() { if (!scrollRef) return + // prevents scroll loops + if (working() && scrollRef.scrollTop < 100) return setState("scrollY", scrollRef.scrollTop) if (state.autoScrolling) return const { scrollTop, scrollHeight, clientHeight } = scrollRef @@ -79,7 +81,7 @@ export function SessionTurn( if (!scrollRef || state.userScrolled || !working() || state.autoScrolling) return setState("autoScrolling", true) requestAnimationFrame(() => { - scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "auto" }) + scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "instant" }) requestAnimationFrame(() => { setState("autoScrolling", false) }) diff --git a/packages/ui/src/components/switch.css b/packages/ui/src/components/switch.css new file mode 100644 index 000000000..c01e45d5f --- /dev/null +++ b/packages/ui/src/components/switch.css @@ -0,0 +1,131 @@ +[data-component="switch"] { + display: flex; + align-items: center; + gap: 8px; + cursor: default; + + [data-slot="switch-input"] { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; + } + + [data-slot="switch-control"] { + display: inline-flex; + align-items: center; + width: 28px; + height: 16px; + flex-shrink: 0; + border-radius: 3px; + border: 1px solid var(--border-weak-base); + background: var(--surface-base); + transition: + background-color 150ms, + border-color 150ms; + } + + [data-slot="switch-thumb"] { + width: 14px; + height: 14px; + box-sizing: content-box; + + border-radius: 2px; + border: 1px solid var(--border-base); + background: var(--icon-invert-base); + + /* shadows/shadow-xs */ + box-shadow: + 0 1px 2px -1px rgba(19, 16, 16, 0.04), + 0 1px 2px 0 rgba(19, 16, 16, 0.06), + 0 1px 3px 0 rgba(19, 16, 16, 0.08); + + transform: translateX(-1px); + transition: + transform 150ms, + background-color 150ms; + } + + [data-slot="switch-label"] { + user-select: none; + color: var(--text-base); + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + } + + [data-slot="switch-description"] { + color: var(--text-base); + font-family: var(--font-family-sans); + font-size: 12px; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-normal); + letter-spacing: var(--letter-spacing-normal); + } + + [data-slot="switch-error"] { + color: var(--text-error); + font-family: var(--font-family-sans); + font-size: 12px; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-normal); + letter-spacing: var(--letter-spacing-normal); + } + + &:hover:not([data-disabled], [data-readonly]) [data-slot="switch-control"] { + border-color: var(--border-hover); + background-color: var(--surface-hover); + } + + &:focus-within:not([data-readonly]) [data-slot="switch-control"] { + border-color: var(--border-focus); + box-shadow: 0 0 0 2px var(--surface-focus); + } + + &[data-checked] [data-slot="switch-control"] { + box-sizing: border-box; + border-color: var(--icon-strong-base); + background-color: var(--icon-strong-base); + } + + &[data-checked] [data-slot="switch-thumb"] { + border: none; + transform: translateX(12px); + background-color: var(--icon-invert-base); + } + + &[data-checked]:hover:not([data-disabled], [data-readonly]) [data-slot="switch-control"] { + border-color: var(--border-hover); + background-color: var(--surface-hover); + } + + &[data-disabled] { + cursor: not-allowed; + } + + &[data-disabled] [data-slot="switch-control"] { + border-color: var(--border-disabled); + background-color: var(--surface-disabled); + } + + &[data-disabled] [data-slot="switch-thumb"] { + background-color: var(--icon-disabled); + } + + &[data-invalid] [data-slot="switch-control"] { + border-color: var(--border-error); + } + + &[data-readonly] { + cursor: default; + pointer-events: none; + } +} diff --git a/packages/ui/src/components/switch.tsx b/packages/ui/src/components/switch.tsx new file mode 100644 index 000000000..af70dfb5c --- /dev/null +++ b/packages/ui/src/components/switch.tsx @@ -0,0 +1,30 @@ +import { Switch as Kobalte } from "@kobalte/core/switch" +import { children, Show, splitProps } from "solid-js" +import type { ComponentProps, ParentProps } from "solid-js" + +export interface SwitchProps extends ParentProps> { + hideLabel?: boolean + description?: string +} + +export function Switch(props: SwitchProps) { + const [local, others] = splitProps(props, ["children", "class", "hideLabel", "description"]) + const resolved = children(() => local.children) + return ( + + + + + {resolved()} + + + + {local.description} + + + + + + + ) +} diff --git a/packages/ui/src/hooks/use-filtered-list.tsx b/packages/ui/src/hooks/use-filtered-list.tsx index e3b373d4d..76a5ae84f 100644 --- a/packages/ui/src/hooks/use-filtered-list.tsx +++ b/packages/ui/src/hooks/use-filtered-list.tsx @@ -5,7 +5,7 @@ import { createStore } from "solid-js/store" import { createList } from "solid-list" export interface FilteredListProps { - items: (filter: string) => T[] | Promise + items: T[] | ((filter: string) => T[] | Promise) key: (item: T) => string filterKeys?: string[] current?: T @@ -19,10 +19,13 @@ export function useFilteredList(props: FilteredListProps) { const [store, setStore] = createStore<{ filter: string }>({ filter: "" }) const [grouped, { refetch }] = createResource( - () => store.filter, - async (filter) => { + () => ({ + filter: store.filter, + items: typeof props.items === "function" ? undefined : props.items, + }), + async ({ filter, items }) => { const needle = filter?.toLowerCase() - const all = (await props.items(needle)) || [] + const all = (items ?? (await (props.items as (filter: string) => T[] | Promise)(needle))) || [] const result = pipe( all, (x) => { diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index ba2c954bc..3f8838a7a 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -30,8 +30,8 @@ @import "../components/progress-circle.css" layer(components); @import "../components/resize-handle.css" layer(components); @import "../components/select.css" layer(components); -@import "../components/select-dialog.css" layer(components); @import "../components/spinner.css" layer(components); +@import "../components/switch.css" layer(components); @import "../components/session-review.css" layer(components); @import "../components/session-turn.css" layer(components); @import "../components/sticky-accordion-header.css" layer(components); -- cgit v1.2.3