summaryrefslogtreecommitdiffhomepage
path: root/packages/ui/src
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-14 19:33:40 -0600
committerAdam <[email protected]>2025-12-14 21:38:58 -0600
commit4246cdb069502c96ab11e260eb36a07a0370b710 (patch)
treea6340608c5d4954b860806ca807e95682385be96 /packages/ui/src
parent7ade6d386daeea120415b69f9df522001350db7b (diff)
downloadopencode-4246cdb069502c96ab11e260eb36a07a0370b710.tar.gz
opencode-4246cdb069502c96ab11e260eb36a07a0370b710.zip
wip(desktop): progress
Diffstat (limited to 'packages/ui/src')
-rw-r--r--packages/ui/src/components/dialog.css27
-rw-r--r--packages/ui/src/components/list.css38
-rw-r--r--packages/ui/src/components/list.tsx141
-rw-r--r--packages/ui/src/components/select-dialog.css44
-rw-r--r--packages/ui/src/components/select-dialog.tsx93
-rw-r--r--packages/ui/src/components/session-turn.css2
-rw-r--r--packages/ui/src/components/session-turn.tsx4
-rw-r--r--packages/ui/src/components/switch.css131
-rw-r--r--packages/ui/src/components/switch.tsx30
-rw-r--r--packages/ui/src/hooks/use-filtered-list.tsx11
-rw-r--r--packages/ui/src/styles/index.css2
11 files changed, 320 insertions, 203 deletions
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<T> extends FilteredListProps<T> {
class?: string
@@ -10,6 +17,7 @@ export interface ListProps<T> extends FilteredListProps<T> {
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<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void }) {
const [scrollRef, setScrollRef] = createSignal<HTMLDivElement | undefined>(undefined)
+ const [internalFilter, setInternalFilter] = createSignal("")
const [store, setStore] = createStore({
mouseActive: false,
})
- const { filter, grouped, flat, reset, active, setActive, onKeyDown, onInput } = useFilteredList<T>({
- 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<T>(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<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
})
return (
- <div ref={setScrollRef} data-component="list" classList={{ [props.class ?? ""]: !!props.class }}>
- <Show
- when={flat().length > 0}
- fallback={
- <div data-slot="list-empty-state">
- <div data-slot="list-message">
- {props.emptyMessage ?? "No results"} for <span data-slot="list-filter">&quot;{filter()}&quot;</span>
- </div>
+ <div data-component="list" classList={{ [props.class ?? ""]: !!props.class }}>
+ <Show when={hasSearch()}>
+ <div data-slot="list-search">
+ <div data-slot="list-search-container">
+ <Icon name="magnifying-glass" />
+ <TextField
+ autofocus={searchProps().autofocus}
+ variant="ghost"
+ data-slot="list-search-input"
+ type="text"
+ value={internalFilter()}
+ onChange={setInternalFilter}
+ onKeyDown={handleKey}
+ placeholder={searchProps().placeholder}
+ spellcheck={false}
+ autocorrect="off"
+ autocomplete="off"
+ autocapitalize="off"
+ />
</div>
- }
- >
- <For each={grouped()}>
- {(group) => (
- <div data-slot="list-group">
- <Show when={group.category}>
- <div data-slot="list-header">{group.category}</div>
- </Show>
- <div data-slot="list-items">
- <For each={group.items}>
- {(item, i) => (
- <button
- data-slot="list-item"
- data-key={props.key(item)}
- data-active={props.key(item) === active()}
- data-selected={item === props.current}
- onClick={() => handleSelect(item, i())}
- onMouseMove={() => {
- setStore("mouseActive", true)
- setActive(props.key(item))
- }}
- >
- {props.children(item)}
- <Show when={item === props.current}>
- <Icon data-slot="list-item-selected-icon" name="check-small" />
- </Show>
- <Show when={props.activeIcon}>
- {(icon) => <Icon data-slot="list-item-active-icon" name={icon()} />}
- </Show>
- </button>
- )}
- </For>
+ <Show when={internalFilter()}>
+ <IconButton icon="circle-x" variant="ghost" onClick={() => setInternalFilter("")} />
+ </Show>
+ </div>
+ </Show>
+ <div ref={setScrollRef} data-slot="list-scroll">
+ <Show
+ when={flat().length > 0}
+ fallback={
+ <div data-slot="list-empty-state">
+ <div data-slot="list-message">
+ {props.emptyMessage ?? "No results"} for <span data-slot="list-filter">&quot;{filter()}&quot;</span>
</div>
</div>
- )}
- </For>
- </Show>
+ }
+ >
+ <For each={grouped()}>
+ {(group) => (
+ <div data-slot="list-group">
+ <Show when={group.category}>
+ <div data-slot="list-header">{group.category}</div>
+ </Show>
+ <div data-slot="list-items">
+ <For each={group.items}>
+ {(item, i) => (
+ <button
+ data-slot="list-item"
+ data-key={props.key(item)}
+ data-active={props.key(item) === active()}
+ data-selected={item === props.current}
+ onClick={() => handleSelect(item, i())}
+ onMouseMove={() => {
+ setStore("mouseActive", true)
+ setActive(props.key(item))
+ }}
+ >
+ {props.children(item)}
+ <Show when={item === props.current}>
+ <Icon data-slot="list-item-selected-icon" name="check-small" />
+ </Show>
+ <Show when={props.activeIcon}>
+ {(icon) => <Icon data-slot="list-item-active-icon" name={icon()} />}
+ </Show>
+ </button>
+ )}
+ </For>
+ </div>
+ </div>
+ )}
+ </For>
+ </Show>
+ </div>
</div>
)
}
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<T>
- extends Omit<ListProps<T>, "filter">,
- Pick<DialogProps, "trigger" | "onOpenChange" | "defaultOpen"> {
- title: string
- placeholder?: string
- actions?: JSX.Element
-}
-
-export function SelectDialog<T>(props: SelectDialogProps<T>) {
- 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 (
- <Dialog modal {...dialog} onOpenChange={handleOpenChange}>
- <Dialog.Header>
- <Dialog.Title>{others.title}</Dialog.Title>
- <Show when={others.actions}>{others.actions}</Show>
- <Dialog.CloseButton ref={closeButton} tabIndex={-1} style={{ display: others.actions ? "none" : undefined }} />
- </Dialog.Header>
- <div data-slot="select-dialog-content">
- <div data-component="select-dialog-input">
- <div data-slot="select-dialog-input-container">
- <Icon name="magnifying-glass" />
- <TextField
- ref={inputRef}
- autofocus
- variant="ghost"
- data-slot="select-dialog-input"
- type="text"
- value={filter()}
- onChange={setFilter}
- onKeyDown={handleKey}
- placeholder={others.placeholder}
- spellcheck={false}
- autocorrect="off"
- autocomplete="off"
- autocapitalize="off"
- />
- </div>
- <Show when={filter()}>
- <IconButton icon="circle-x" variant="ghost" onClick={() => setFilter("")} />
- </Show>
- </div>
- <Dialog.Body>
- <List
- ref={(ref) => {
- 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}
- </List>
- </Dialog.Body>
- </div>
- </Dialog>
- )
-}
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<ComponentProps<typeof Kobalte>> {
+ hideLabel?: boolean
+ description?: string
+}
+
+export function Switch(props: SwitchProps) {
+ const [local, others] = splitProps(props, ["children", "class", "hideLabel", "description"])
+ const resolved = children(() => local.children)
+ return (
+ <Kobalte {...others} data-component="switch">
+ <Kobalte.Input data-slot="switch-input" />
+ <Show when={resolved()}>
+ <Kobalte.Label data-slot="switch-label" classList={{ "sr-only": local.hideLabel }}>
+ {resolved()}
+ </Kobalte.Label>
+ </Show>
+ <Show when={local.description}>
+ <Kobalte.Description data-slot="switch-description">{local.description}</Kobalte.Description>
+ </Show>
+ <Kobalte.ErrorMessage data-slot="switch-error" />
+ <Kobalte.Control data-slot="switch-control">
+ <Kobalte.Thumb data-slot="switch-thumb" />
+ </Kobalte.Control>
+ </Kobalte>
+ )
+}
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<T> {
- items: (filter: string) => T[] | Promise<T[]>
+ items: T[] | ((filter: string) => T[] | Promise<T[]>)
key: (item: T) => string
filterKeys?: string[]
current?: T
@@ -19,10 +19,13 @@ export function useFilteredList<T>(props: FilteredListProps<T>) {
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<T[]>)(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);