From 3bb546c94d6bb295bfeafdafbb9d34b7cc462560 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 10 Dec 2025 21:16:50 -0600 Subject: wip(desktop): progress --- packages/ui/src/components/button.css | 32 ++++++------ packages/ui/src/components/dialog.tsx | 7 ++- packages/ui/src/components/icon.tsx | 1 + packages/ui/src/components/input.css | 76 +++++++++++++++++++++++++++- packages/ui/src/components/input.tsx | 42 ++++++++++++--- packages/ui/src/components/select-dialog.tsx | 7 +-- packages/ui/src/hooks/use-filtered-list.tsx | 7 +-- 7 files changed, 143 insertions(+), 29 deletions(-) (limited to 'packages/ui/src') diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css index f95317028..3a32672fe 100644 --- a/packages/ui/src/components/button.css +++ b/packages/ui/src/components/button.css @@ -11,27 +11,29 @@ outline: none; &[data-variant="primary"] { - border-color: var(--border-base); - background-color: var(--surface-brand-base); - color: var(--text-on-brand-strong); + background-color: var(--icon-strong-base); + border-color: var(--border-weak-base); + color: var(--icon-invert-base); + + [data-slot="icon-svg"] { + color: var(--icon-invert-base); + } &:hover:not(:disabled) { - border-color: var(--border-hover); - background-color: var(--surface-brand-hover); + background-color: var(--icon-strong-hover); } &:focus:not(:disabled) { - border-color: var(--border-focus); - background-color: var(--surface-brand-focus); + background-color: var(--icon-strong-focus); } &:active:not(:disabled) { - border-color: var(--border-active); - background-color: var(--surface-brand-active); + background-color: var(--icon-strong-active); } &:disabled { - border-color: var(--border-disabled); - background-color: var(--surface-disabled); - color: var(--text-weak); - cursor: not-allowed; + background-color: var(--icon-strong-disabled); + + [data-slot="icon-svg"] { + color: var(--icon-invert-base); + } } } @@ -120,13 +122,13 @@ &[data-size="large"] { height: 32px; - padding: 0 8px; + padding: 6px 12px; &[data-icon] { padding: 0 12px 0 8px; } - gap: 8px; + gap: 4px; /* text-14-medium */ font-family: var(--font-family-sans); diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx index a16705a57..56053278d 100644 --- a/packages/ui/src/components/dialog.tsx +++ b/packages/ui/src/components/dialog.tsx @@ -5,7 +5,7 @@ import { DialogCloseButtonProps, DialogDescriptionProps, } from "@kobalte/core/dialog" -import { ComponentProps, type JSX, onCleanup, Show, splitProps } from "solid-js" +import { ComponentProps, type JSX, onCleanup, onMount, Show, splitProps } from "solid-js" import { IconButton } from "./icon-button" export interface DialogProps extends DialogRootProps { @@ -35,6 +35,11 @@ export function DialogRoot(props: DialogProps) { }) } + onMount(() => { + // @ts-ignore + document?.activeElement?.blur?.() + }) + return ( diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 97f2e8eab..080a6274d 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -3,6 +3,7 @@ import { splitProps, type ComponentProps } from "solid-js" const icons = { "align-right": ``, "arrow-up": ``, + "arrow-left": ``, "bubble-5": ``, "bullet-list": ``, "check-small": ``, diff --git a/packages/ui/src/components/input.css b/packages/ui/src/components/input.css index c5f8cb8c5..276e8069b 100644 --- a/packages/ui/src/components/input.css +++ b/packages/ui/src/components/input.css @@ -1,6 +1,5 @@ [data-component="input"] { width: 100%; - /* [data-slot="input-label"] {} */ [data-slot="input-input"] { width: 100%; @@ -22,4 +21,79 @@ color: var(--text-weak); } } + + &[data-variant="normal"] { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + + [data-slot="input-label"] { + color: var(--text-weak); + + /* text-12-medium */ + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: 18px; /* 150% */ + letter-spacing: var(--letter-spacing-normal); + } + + [data-slot="input-input"] { + color: var(--text-strong); + + display: flex; + height: 32px; + padding: 2px 12px; + align-items: center; + gap: 8px; + align-self: stretch; + + border-radius: var(--radius-md); + border: 1px solid var(--border-weak-base); + background: var(--input-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); + + &:focus { + outline: none; + + /* border/shadow-xs/select */ + box-shadow: + 0 0 0 3px var(--border-weak-selected), + 0 0 0 1px var(--border-selected), + 0 1px 2px -1px rgba(19, 16, 16, 0.25), + 0 1px 2px 0 rgba(19, 16, 16, 0.08), + 0 1px 3px 0 rgba(19, 16, 16, 0.12); + } + + &[data-invalid] { + background: var(--surface-critical-weak); + border: 1px solid var(--border-critical-selected); + } + + &::placeholder { + color: var(--text-weak); + } + } + + [data-slot="input-error"] { + color: var(--text-on-critical-base); + + /* text-12-medium */ + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: 18px; /* 150% */ + letter-spacing: var(--letter-spacing-normal); + } + } } diff --git a/packages/ui/src/components/input.tsx b/packages/ui/src/components/input.tsx index 82f704e8c..8e2a115c6 100644 --- a/packages/ui/src/components/input.tsx +++ b/packages/ui/src/components/input.tsx @@ -4,31 +4,61 @@ import type { ComponentProps } from "solid-js" export interface InputProps extends ComponentProps, - Partial, "value" | "onChange" | "onKeyDown">> { + Partial< + Pick< + ComponentProps, + | "name" + | "defaultValue" + | "value" + | "onChange" + | "onKeyDown" + | "validationState" + | "required" + | "disabled" + | "readOnly" + > + > { label?: string hideLabel?: boolean hidden?: boolean description?: string + error?: string + variant?: "normal" | "ghost" } export function Input(props: InputProps) { const [local, others] = splitProps(props, [ + "name", + "defaultValue", + "value", + "onChange", + "onKeyDown", + "validationState", + "required", + "disabled", + "readOnly", "class", "label", "hidden", "hideLabel", "description", - "value", - "onChange", - "onKeyDown", + "error", + "variant", ]) return ( @@ -39,7 +69,7 @@ export function Input(props: InputProps) { {local.description} - + {local.error} ) } diff --git a/packages/ui/src/components/select-dialog.tsx b/packages/ui/src/components/select-dialog.tsx index 952ba881f..efa6c405b 100644 --- a/packages/ui/src/components/select-dialog.tsx +++ b/packages/ui/src/components/select-dialog.tsx @@ -1,9 +1,9 @@ import { createEffect, Show, type JSX, splitProps, createSignal } from "solid-js" import { Dialog, DialogProps } from "./dialog" import { Icon } from "./icon" -import { Input } from "./input" import { IconButton } from "./icon-button" import { List, ListRef, ListProps } from "./list" +import { Input } from "./input" interface SelectDialogProps extends Omit, "filter">, @@ -29,8 +29,8 @@ export function SelectDialog(props: SelectDialogProps) { }) }) - const handleSelect = (item: T | undefined) => { - others.onSelect?.(item) + const handleSelect = (item: T | undefined, index: number) => { + others.onSelect?.(item, index) closeButton.click() } @@ -58,6 +58,7 @@ export function SelectDialog(props: SelectDialogProps) { { groupBy?: (x: T) => string sortBy?: (a: T, b: T) => number sortGroupsBy?: (a: { category: string; items: T[] }, b: { category: string; items: T[] }) => number - onSelect?: (value: T | undefined) => void + onSelect?: (value: T | undefined, index: number) => void } export function useFilteredList(props: FilteredListProps) { @@ -63,8 +63,9 @@ export function useFilteredList(props: FilteredListProps) { const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Enter") { event.preventDefault() - const selected = flat().find((x) => props.key(x) === list.active()) - if (selected) props.onSelect?.(selected) + const selectedIndex = flat().findIndex((x) => props.key(x) === list.active()) + const selected = flat()[selectedIndex] + if (selected) props.onSelect?.(selected, selectedIndex) } else { list.onKeyDown(event) } -- cgit v1.2.3 From 1980113ee4305844803f866aef05d742f7cffd47 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 11 Dec 2025 03:13:08 -0600 Subject: wip(desktop): progress --- packages/desktop/src/context/global-sync.tsx | 75 ++++++++++++++++++++-------- packages/desktop/src/context/layout.tsx | 32 +++++++++++- packages/desktop/src/context/sync.tsx | 50 +++---------------- packages/desktop/src/pages/layout.tsx | 5 +- packages/ui/src/components/list.tsx | 11 ++-- 5 files changed, 101 insertions(+), 72 deletions(-) (limited to 'packages/ui/src') diff --git a/packages/desktop/src/context/global-sync.tsx b/packages/desktop/src/context/global-sync.tsx index 770275a5c..2a24a845c 100644 --- a/packages/desktop/src/context/global-sync.tsx +++ b/packages/desktop/src/context/global-sync.tsx @@ -1,19 +1,20 @@ -import type { - Message, - Agent, - Session, - Part, - Config, - Path, - File, - FileNode, - Project, - FileDiff, - Todo, - SessionStatus, - ProviderListResponse, - ProviderAuthResponse, -} from "@opencode-ai/sdk/v2" +import { + type Message, + type Agent, + type Session, + type Part, + type Config, + type Path, + type File, + type FileNode, + type Project, + type FileDiff, + type Todo, + type SessionStatus, + type ProviderListResponse, + type ProviderAuthResponse, + createOpencodeClient, +} from "@opencode-ai/sdk/v2/client" import { createStore, produce, reconcile } from "solid-js/store" import { Binary } from "@opencode-ai/util/binary" import { createSimpleContext } from "@opencode-ai/ui/context" @@ -51,7 +52,7 @@ type State = { export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimpleContext({ name: "GlobalSync", init: () => { - const sdk = useGlobalSDK() + const globalSDK = useGlobalSDK() const [globalStore, setGlobalStore] = createStore<{ ready: boolean project: Project[] @@ -66,6 +67,33 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple children: {}, }) + async function bootstrapInstance(directory: string) { + const [store, setStore] = child(directory) + const sdk = createOpencodeClient({ + baseUrl: globalSDK.url, + directory, + }) + const load = { + project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)), + provider: () => sdk.provider.list().then((x) => setStore("provider", x.data!)), + path: () => sdk.path.get().then((x) => setStore("path", x.data!)), + agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])), + session: () => + sdk.session.list().then((x) => { + const sessions = (x.data ?? []) + .slice() + .sort((a, b) => a.id.localeCompare(b.id)) + .slice(0, store.limit) + setStore("session", sessions) + }), + status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)), + config: () => sdk.config.get().then((x) => setStore("config", x.data!)), + changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)), + node: () => sdk.file.list({ path: "/" }).then((x) => setStore("node", x.data!)), + } + await Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true)) + } + const children: Record>> = {} function child(directory: string) { if (!children[directory]) { @@ -87,11 +115,12 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple changes: [], }) children[directory] = createStore(globalStore.children[directory]) + bootstrapInstance(directory) } return children[directory] } - sdk.event.listen((e) => { + globalSDK.event.listen((e) => { const directory = e.name const event = e.details @@ -121,6 +150,10 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple const [store, setStore] = child(directory) switch (event.type) { + case "server.instance.disposed": { + bootstrapInstance(directory) + break + } case "session.updated": { const result = Binary.search(store.session, event.properties.info.id, (s) => s.id) if (result.found) { @@ -191,7 +224,7 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple async function bootstrap() { return Promise.all([ - sdk.client.project.list().then(async (x) => { + globalSDK.client.project.list().then(async (x) => { setGlobalStore( "project", x @@ -199,10 +232,10 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple .sort((a, b) => a.id.localeCompare(b.id)), ) }), - sdk.client.provider.list().then((x) => { + globalSDK.client.provider.list().then((x) => { setGlobalStore("provider", x.data ?? {}) }), - sdk.client.provider.auth().then((x) => { + globalSDK.client.provider.auth().then((x) => { setGlobalStore("provider_auth", x.data ?? {}) }), ]).then(() => setGlobalStore("ready", true)) diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx index d00e101b8..d4a8875f7 100644 --- a/packages/desktop/src/context/layout.tsx +++ b/packages/desktop/src/context/layout.tsx @@ -1,4 +1,4 @@ -import { createStore } from "solid-js/store" +import { createStore, produce } from "solid-js/store" import { batch, createMemo, onMount } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" import { makePersisted } from "@solid-primitives/storage" @@ -48,6 +48,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const [ephemeral, setEphemeral] = createStore({ connect: { provider: undefined as undefined | string, + state: undefined as undefined | "pending" | "complete" | "error", + error: undefined as undefined | string, }, dialog: { open: undefined as undefined | Dialog, @@ -176,21 +178,47 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( opened: createMemo(() => ephemeral.dialog?.open), open(dialog: Dialog) { setEphemeral("dialog", "open", dialog) + if (dialog !== "connect") { + setEphemeral("connect", {}) + } }, close(dialog: Dialog) { if (ephemeral.dialog?.open === dialog) { setEphemeral("dialog", "open", undefined) + if (dialog === "connect") { + setEphemeral("connect", {}) + } } }, connect(provider: string) { batch(() => { setEphemeral("dialog", "open", "connect") - setEphemeral("connect", "provider", provider) + setEphemeral("connect", { provider, state: "pending" }) }) }, }, connect: { provider: createMemo(() => ephemeral.connect.provider), + state: createMemo(() => ephemeral.connect.state), + complete() { + setEphemeral( + produce((state) => { + state.dialog.open = "model" + state.connect.state = "complete" + }), + ) + }, + error(message: string) { + setEphemeral( + produce((state) => { + state.connect.state = "error" + state.connect.error = message + }), + ) + }, + clear() { + setEphemeral("connect", {}) + }, }, } }, diff --git a/packages/desktop/src/context/sync.tsx b/packages/desktop/src/context/sync.tsx index 9c3abd731..85758c5b6 100644 --- a/packages/desktop/src/context/sync.tsx +++ b/packages/desktop/src/context/sync.tsx @@ -1,5 +1,5 @@ import { produce } from "solid-js/store" -import { createMemo, onMount } from "solid-js" +import { createMemo } from "solid-js" import { Binary } from "@opencode-ai/util/binary" import { createSimpleContext } from "@opencode-ai/ui/context" import { useGlobalSync } from "./global-sync" @@ -11,45 +11,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const globalSync = useGlobalSync() const sdk = useSDK() const [store, setStore] = globalSync.child(sdk.directory) - - const load = { - project: () => sdk.client.project.current().then((x) => setStore("project", x.data!.id)), - provider: () => sdk.client.provider.list().then((x) => setStore("provider", x.data!)), - path: () => sdk.client.path.get().then((x) => setStore("path", x.data!)), - agent: () => sdk.client.app.agents().then((x) => setStore("agent", x.data ?? [])), - session: () => - sdk.client.session.list().then((x) => { - const sessions = (x.data ?? []) - .slice() - .sort((a, b) => a.id.localeCompare(b.id)) - .slice(0, store.limit) - setStore("session", sessions) - }), - status: () => sdk.client.session.status().then((x) => setStore("session_status", x.data!)), - config: () => sdk.client.config.get().then((x) => setStore("config", x.data!)), - changes: () => sdk.client.file.status().then((x) => setStore("changes", x.data!)), - node: () => sdk.client.file.list({ path: "/" }).then((x) => setStore("node", x.data!)), - } - - async function bootstrap() { - return Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true)) - } - - onMount(() => { - bootstrap() - }) - - sdk.event.listen((e) => { - const event = e.details - console.log(event) - switch (event.type) { - case "server.instance.disposed": { - bootstrap() - break - } - } - }) - const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/") return { @@ -95,11 +56,16 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }, fetch: async (count = 10) => { setStore("limit", (x) => x + count) - await load.session() + await sdk.client.session.list().then((x) => { + const sessions = (x.data ?? []) + .slice() + .sort((a, b) => a.id.localeCompare(b.id)) + .slice(0, store.limit) + setStore("session", sessions) + }) }, more: createMemo(() => store.session.length >= store.limit), }, - bootstrap, absolute, get directory() { return store.path.directory diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 0bc6e9e09..5f0b26f6b 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -440,7 +440,7 @@ export default function Layout(props: ParentProps) {