diff options
Diffstat (limited to 'packages/app/src')
67 files changed, 545 insertions, 1039 deletions
diff --git a/packages/app/src/addons/serialize.test.ts b/packages/app/src/addons/serialize.test.ts index 7f6780557..6828e60f8 100644 --- a/packages/app/src/addons/serialize.test.ts +++ b/packages/app/src/addons/serialize.test.ts @@ -180,8 +180,8 @@ describe("SerializeAddon", () => { await writeAndWait(term, input) const origLine = term.buffer.active.getLine(0) - const origFg = origLine!.getCell(0)!.getFgColor() - const origBg = origLine!.getCell(0)!.getBgColor() + const _origFg = origLine!.getCell(0)!.getFgColor() + const _origBg = origLine!.getCell(0)!.getBgColor() expect(origLine!.getCell(0)!.isBold()).toBe(1) const serialized = addon.serialize({ range: { start: 0, end: 0 } }) diff --git a/packages/app/src/addons/serialize.ts b/packages/app/src/addons/serialize.ts index 4cab55b3f..3823fb443 100644 --- a/packages/app/src/addons/serialize.ts +++ b/packages/app/src/addons/serialize.ts @@ -258,8 +258,8 @@ class StringSerializeHandler extends BaseSerializeHandler { } protected _beforeSerialize(rows: number, start: number, _end: number): void { - this._allRows = new Array<string>(rows) - this._allRowSeparators = new Array<string>(rows) + this._allRows = Array.from<string>({ length: rows }) + this._allRowSeparators = Array.from<string>({ length: rows }) this._rowIndex = 0 this._currentRow = "" diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 35fd36cca..dbe107448 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -10,7 +10,7 @@ import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { MetaProvider } from "@solidjs/meta" import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" -import { type Duration, Effect } from "effect" +import { Effect } from "effect" import { type Component, createMemo, @@ -121,10 +121,10 @@ function SessionProviders(props: ParentProps) { function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { return ( <AppShellProviders> - <Suspense fallback={<Loading />}> - {props.appChildren} - {props.children} - </Suspense> + {/*<Suspense fallback={<Loading />}>*/} + {props.appChildren} + {props.children} + {/*</Suspense>*/} </AppShellProviders> ) } @@ -156,11 +156,6 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { ) } -const effectMinDuration = - (duration: Duration.Input) => - <A, E, R>(e: Effect.Effect<A, E, R>) => - Effect.all([e, Effect.sleep(duration)], { concurrency: "unbounded" }).pipe(Effect.map((v) => v[0])) - function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) { const server = useServer() const checkServerHealth = useCheckServerHealth() @@ -189,32 +184,41 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) { ) return ( - <Show - when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"} + <Suspense fallback={ <div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base"> <Splash class="w-16 h-20 opacity-50 animate-pulse" /> </div> } > + {/*<Show + when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"} + fallback={ + <div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base"> + <Splash class="w-16 h-20 opacity-50 animate-pulse" /> + </div> + } + >*/} + {checkMode() === "blocking" ? startupHealthCheck() : startupHealthCheck.latest} <Show when={startupHealthCheck()} fallback={ <ConnectionError onRetry={() => { - if (checkMode() === "background") healthCheckActions.refetch() + if (checkMode() === "background") void healthCheckActions.refetch() }} onServerSelected={(key) => { setCheckMode("blocking") server.setActive(key) - healthCheckActions.refetch() + void healthCheckActions.refetch() }} /> } > {props.children} </Show> - </Show> + {/*</Show>*/} + </Suspense> ) } diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 41225d02a..e30574379 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -327,7 +327,7 @@ export function DialogConnectProvider(props: { provider: string }) { if (loading()) return if (methods().length === 1) { auto = true - selectMethod(0) + void selectMethod(0) } }) @@ -373,7 +373,7 @@ export function DialogConnectProvider(props: { provider: string }) { key={(m) => m?.label} onSelect={async (selected, index) => { if (!selected) return - selectMethod(index) + void selectMethod(index) }} > {(i) => ( diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index eb962f47e..ea5d70065 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -9,7 +9,7 @@ import { createStore } from "solid-js/store" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" import { type LocalProject, getAvatarColors } from "@/context/layout" -import { getFilename } from "@opencode-ai/util/path" +import { getFilename } from "@opencode-ai/shared/util/path" import { Avatar } from "@opencode-ai/ui/avatar" import { useLanguage } from "@/context/language" diff --git a/packages/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx index 9e1b896fa..710618c30 100644 --- a/packages/app/src/components/dialog-fork.tsx +++ b/packages/app/src/components/dialog-fork.tsx @@ -9,7 +9,7 @@ import { List } from "@opencode-ai/ui/list" import { showToast } from "@opencode-ai/ui/toast" import { extractPromptFromParts } from "@/utils/prompt" import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client" -import { base64Encode } from "@opencode-ai/util/encode" +import { base64Encode } from "@opencode-ai/shared/util/encode" import { useLanguage } from "@/context/language" interface ForkableMessage { diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index 91e23f8ff..903cb1915 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -3,7 +3,7 @@ import { Dialog } from "@opencode-ai/ui/dialog" import { FileIcon } from "@opencode-ai/ui/file-icon" import { List } from "@opencode-ai/ui/list" import type { ListRef } from "@opencode-ai/ui/list" -import { getDirectory, getFilename } from "@opencode-ai/util/path" +import { getDirectory, getFilename } from "@opencode-ai/shared/util/path" import fuzzysort from "fuzzysort" import { createMemo, createResource, createSignal } from "solid-js" import { useGlobalSDK } from "@/context/global-sdk" diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index e21be77fb..186906f92 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -4,8 +4,8 @@ import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" import { Keybind } from "@opencode-ai/ui/keybind" import { List } from "@opencode-ai/ui/list" -import { base64Encode } from "@opencode-ai/util/encode" -import { getDirectory, getFilename } from "@opencode-ai/util/path" +import { base64Encode } from "@opencode-ai/shared/util/encode" +import { getDirectory, getFilename } from "@opencode-ai/shared/util/path" import { useNavigate } from "@solidjs/router" import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js" import { formatKeybind, useCommand, type CommandOption } from "@/context/command" @@ -348,8 +348,8 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil const open = (path: string) => { const value = file.tab(path) - tabs().open(value) - file.load(path) + void tabs().open(value) + void file.load(path) if (!view().reviewPanel.opened()) view().reviewPanel.open() layout.fileTree.setTab("all") props.onOpenFile?.(path) diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index ca4c42a37..dd92edec3 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -344,7 +344,7 @@ export function DialogSelectServer() { createEffect(() => { items() - refreshHealth() + void refreshHealth() const interval = setInterval(refreshHealth, 10_000) onCleanup(() => clearInterval(interval)) }) @@ -498,7 +498,7 @@ export function DialogSelectServer() { async function handleRemove(url: ServerConnection.Key) { server.remove(url) if ((await platform.getDefaultServer?.()) === url) { - platform.setDefaultServer?.(null) + void platform.setDefaultServer?.(null) } } @@ -536,7 +536,7 @@ export function DialogSelectServer() { items={sortedItems} key={(x) => x.http.url} onSelect={(x) => { - if (x) select(x) + if (x) void select(x) }} divider={true} class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent" diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index 930832fb6..211ce05ef 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -14,7 +14,6 @@ import { Switch, untrack, type ComponentProps, - type JSXElement, type ParentProps, } from "solid-js" import { Dynamic } from "solid-js/web" @@ -149,7 +148,7 @@ const FileTreeNode = ( classList={{ "w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true, "bg-surface-base-active": local.node.path === local.active, - ...(local.classList ?? {}), + ...local.classList, [local.class ?? ""]: !!local.class, [local.nodeClass ?? ""]: !!local.nodeClass, }} diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index eedbc91cf..156b0b3a4 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -35,7 +35,6 @@ import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" -import { promptEnabled, promptProbe } from "@/testing/prompt" import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" import { createPromptAttachments } from "./prompt-input/attachments" import { ACCEPTED_FILE_TYPES } from "./prompt-input/files" @@ -55,6 +54,8 @@ import { PromptImageAttachments } from "./prompt-input/image-attachments" import { PromptDragOverlay } from "./prompt-input/drag-overlay" import { promptPlaceholder } from "./prompt-input/placeholder" import { ImagePreview } from "@opencode-ai/ui/image-preview" +import { useQuery } from "@tanstack/solid-query" +import { loadAgentsQuery, loadProvidersQuery } from "@/context/global-sync/bootstrap" interface PromptInputProps { class?: string @@ -101,6 +102,7 @@ const NON_EMPTY_TEXT = /[^\s\u200B]/ export const PromptInput: Component<PromptInputProps> = (props) => { const sdk = useSDK() + const sync = useSync() const local = useLocal() const files = useFile() @@ -213,9 +215,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => { if (!view().reviewPanel.opened()) view().reviewPanel.open() layout.fileTree.setTab("all") const tab = files.tab(item.path) - tabs().open(tab) + void tabs().open(tab) tabs().setActive(tab) - Promise.resolve(files.load(item.path)).finally(() => queueCommentFocus()) + void Promise.resolve(files.load(item.path)).finally(() => queueCommentFocus()) } const recent = createMemo(() => { @@ -639,7 +641,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const handleSlashSelect = (cmd: SlashCommand | undefined) => { if (!cmd) return - promptProbe.select(cmd.id) closePopover() const images = imageAttachments() @@ -728,21 +729,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => { element?.scrollIntoView({ block: "nearest", behavior: "smooth" }) }) }) - - if (promptEnabled()) { - createEffect(() => { - promptProbe.set({ - popover: store.popover, - slash: { - active: slashActive() ?? null, - ids: slashFlat().map((cmd) => cmd.id), - }, - }) - }) - - onCleanup(() => promptProbe.clear()) - } - const selectPopoverActive = () => { if (store.popover === "at") { const items = atFlat() @@ -1156,7 +1142,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { } if (working()) { - abort() + void abort() event.preventDefault() event.stopPropagation() return @@ -1222,7 +1208,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { return } if (working()) { - abort() + void abort() event.preventDefault() } return @@ -1262,10 +1248,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => { ) { return } - handleSubmit(event) + void handleSubmit(event) } } + const agentsQuery = useQuery(() => loadAgentsQuery(sdk.directory)) + const agentsLoading = () => agentsQuery.isLoading + + const globalProvidersQuery = useQuery(() => loadProvidersQuery(null)) + const providersQuery = useQuery(() => loadProvidersQuery(sdk.directory)) + + const providersLoading = () => agentsLoading() || providersQuery.isLoading || globalProvidersQuery.isLoading + return ( <div class="relative size-full _max-h-[320px] flex flex-col gap-0"> <PromptPopover @@ -1461,53 +1455,89 @@ export const PromptInput: Component<PromptInputProps> = (props) => { <span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span> <div class="size-4 shrink-0" /> </div> - <div class="flex items-center gap-1.5 min-w-0 flex-1"> - <div data-component="prompt-agent-control"> - <TooltipKeybind - placement="top" - gutter={4} - title={language.t("command.agent.cycle")} - keybind={command.keybind("agent.cycle")} - > - <Select - size="normal" - options={agentNames()} - current={local.agent.current()?.name ?? ""} - onSelect={(value) => { - local.agent.set(value) - restoreFocus() - }} - class="capitalize max-w-[160px] text-text-base" - valueClass="truncate text-13-regular text-text-base" - triggerStyle={control()} - triggerProps={{ "data-action": "prompt-agent" }} - variant="ghost" - /> - </TooltipKeybind> - </div> - <Show when={store.mode !== "shell"}> - <div data-component="prompt-model-control"> - <Show - when={providers.paid().length > 0} - fallback={ + <div class="flex items-center gap-1.5 min-w-0 flex-1 h-7"> + <Show when={!agentsLoading()}> + <div data-component="prompt-agent-control"> + <TooltipKeybind + placement="top" + gutter={4} + title={language.t("command.agent.cycle")} + keybind={command.keybind("agent.cycle")} + > + <Select + size="normal" + options={agentNames()} + current={local.agent.current()?.name ?? ""} + onSelect={(value) => { + local.agent.set(value) + restoreFocus() + }} + class="capitalize max-w-[160px] text-text-base" + valueClass="truncate text-13-regular text-text-base" + triggerStyle={control()} + triggerProps={{ "data-action": "prompt-agent" }} + variant="ghost" + /> + </TooltipKeybind> + </div> + </Show> + <Show when={!providersLoading()}> + <Show when={store.mode !== "shell"}> + <div data-component="prompt-model-control"> + <Show + when={providers.paid().length > 0} + fallback={ + <TooltipKeybind + placement="top" + gutter={4} + title={language.t("command.model.choose")} + keybind={command.keybind("model.choose")} + > + <Button + data-action="prompt-model" + as="div" + variant="ghost" + size="normal" + class="min-w-0 max-w-[320px] text-13-regular text-text-base group" + style={control()} + onClick={() => { + void import("@/components/dialog-select-model-unpaid").then((x) => { + dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />) + }) + }} + > + <Show when={local.model.current()?.provider?.id}> + <ProviderIcon + id={local.model.current()?.provider?.id ?? ""} + class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150" + style={{ "will-change": "opacity", transform: "translateZ(0)" }} + /> + </Show> + <span class="truncate"> + {local.model.current()?.name ?? language.t("dialog.model.select.title")} + </span> + <Icon name="chevron-down" size="small" class="shrink-0" /> + </Button> + </TooltipKeybind> + } + > <TooltipKeybind placement="top" gutter={4} title={language.t("command.model.choose")} keybind={command.keybind("model.choose")} > - <Button - data-action="prompt-model" - as="div" - variant="ghost" - size="normal" - class="min-w-0 max-w-[320px] text-13-regular text-text-base group" - style={control()} - onClick={() => { - void import("@/components/dialog-select-model-unpaid").then((x) => { - dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />) - }) + <ModelSelectorPopover + model={local.model} + triggerAs={Button} + triggerProps={{ + variant: "ghost", + size: "normal", + style: control(), + class: "min-w-0 max-w-[320px] text-13-regular text-text-base group", + "data-action": "prompt-model", }} + onClose={restoreFocus} > <Show when={local.model.current()?.provider?.id}> <ProviderIcon @@ -1520,67 +1550,35 @@ export const PromptInput: Component<PromptInputProps> = (props) => { {local.model.current()?.name ?? language.t("dialog.model.select.title")} </span> <Icon name="chevron-down" size="small" class="shrink-0" /> - </Button> + </ModelSelectorPopover> </TooltipKeybind> - } - > + </Show> + </div> + <div data-component="prompt-variant-control"> <TooltipKeybind placement="top" gutter={4} - title={language.t("command.model.choose")} - keybind={command.keybind("model.choose")} + title={language.t("command.model.variant.cycle")} + keybind={command.keybind("model.variant.cycle")} > - <ModelSelectorPopover - model={local.model} - triggerAs={Button} - triggerProps={{ - variant: "ghost", - size: "normal", - style: control(), - class: "min-w-0 max-w-[320px] text-13-regular text-text-base group", - "data-action": "prompt-model", + <Select + size="normal" + options={variants()} + current={local.model.variant.current() ?? "default"} + label={(x) => (x === "default" ? language.t("common.default") : x)} + onSelect={(value) => { + local.model.variant.set(value === "default" ? undefined : value) + restoreFocus() }} - onClose={restoreFocus} - > - <Show when={local.model.current()?.provider?.id}> - <ProviderIcon - id={local.model.current()?.provider?.id ?? ""} - class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150" - style={{ "will-change": "opacity", transform: "translateZ(0)" }} - /> - </Show> - <span class="truncate"> - {local.model.current()?.name ?? language.t("dialog.model.select.title")} - </span> - <Icon name="chevron-down" size="small" class="shrink-0" /> - </ModelSelectorPopover> + class="capitalize max-w-[160px] text-text-base" + valueClass="truncate text-13-regular text-text-base" + triggerStyle={control()} + triggerProps={{ "data-action": "prompt-model-variant" }} + variant="ghost" + /> </TooltipKeybind> - </Show> - </div> - <div data-component="prompt-variant-control"> - <TooltipKeybind - placement="top" - gutter={4} - title={language.t("command.model.variant.cycle")} - keybind={command.keybind("model.variant.cycle")} - > - <Select - size="normal" - options={variants()} - current={local.model.variant.current() ?? "default"} - label={(x) => (x === "default" ? language.t("common.default") : x)} - onSelect={(value) => { - local.model.variant.set(value === "default" ? undefined : value) - restoreFocus() - }} - class="capitalize max-w-[160px] text-text-base" - valueClass="truncate text-13-regular text-text-base" - triggerStyle={control()} - triggerProps={{ "data-action": "prompt-model-variant" }} - variant="ghost" - /> - </TooltipKeybind> - </div> + </div> + </Show> </Show> </div> </div> diff --git a/packages/app/src/components/prompt-input/build-request-parts.ts b/packages/app/src/components/prompt-input/build-request-parts.ts index a1076e60c..c268af35e 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.ts @@ -1,4 +1,4 @@ -import { getFilename } from "@opencode-ai/util/path" +import { getFilename } from "@opencode-ai/shared/util/path" import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client" import type { FileSelection } from "@/context/file" import { encodeFilePath } from "@/context/file/path" diff --git a/packages/app/src/components/prompt-input/context-items.tsx b/packages/app/src/components/prompt-input/context-items.tsx index b138fe3ef..9f20f1c04 100644 --- a/packages/app/src/components/prompt-input/context-items.tsx +++ b/packages/app/src/components/prompt-input/context-items.tsx @@ -2,7 +2,7 @@ import { Component, For, Show } from "solid-js" import { FileIcon } from "@opencode-ai/ui/file-icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip } from "@opencode-ai/ui/tooltip" -import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path" +import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/shared/util/path" import type { ContextItem } from "@/context/prompt" type PromptContextItem = ContextItem & { key: string } diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx index 65eb01c79..0c8c95923 100644 --- a/packages/app/src/components/prompt-input/slash-popover.tsx +++ b/packages/app/src/components/prompt-input/slash-popover.tsx @@ -1,7 +1,7 @@ import { Component, For, Match, Show, Switch } from "solid-js" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" -import { getDirectory, getFilename } from "@opencode-ai/util/path" +import { getDirectory, getFilename } from "@opencode-ai/shared/util/path" export type AtOption = | { type: "agent"; name: string; display: string } diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 03bece2e3..cf9949723 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -74,7 +74,7 @@ beforeAll(async () => { showToast: () => 0, })) - mock.module("@opencode-ai/util/encode", () => ({ + mock.module("@opencode-ai/shared/util/encode", () => ({ base64Encode: (value: string) => value, })) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 2a3a3d0e9..6805f619c 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -1,9 +1,9 @@ import type { Message, Session } from "@opencode-ai/sdk/v2/client" import { showToast } from "@opencode-ai/ui/toast" -import { base64Encode } from "@opencode-ai/util/encode" -import { Binary } from "@opencode-ai/util/binary" +import { base64Encode } from "@opencode-ai/shared/util/encode" +import { Binary } from "@opencode-ai/shared/util/binary" import { useNavigate, useParams } from "@solidjs/router" -import type { Accessor } from "solid-js" +import { batch, type Accessor } from "solid-js" import type { FileSelection } from "@/context/file" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" @@ -13,7 +13,6 @@ import { usePermission } from "@/context/permission" import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" -import { promptProbe } from "@/testing/prompt" import { Identifier } from "@/utils/id" import { Worktree as WorktreeState } from "@/utils/worktree" import { buildRequestParts } from "./build-request-parts" @@ -139,13 +138,17 @@ export async function sendFollowupDraft(input: FollowupSendInput) { messageID, }) - setBusy() - add() + batch(() => { + setBusy() + add() + }) try { if (!(await wait())) { - setIdle() - remove() + batch(() => { + setIdle() + remove() + }) return false } @@ -159,8 +162,10 @@ export async function sendFollowupDraft(input: FollowupSendInput) { }) return true } catch (err) { - setIdle() - remove() + batch(() => { + setIdle() + remove() + }) throw err } } @@ -290,7 +295,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { const mode = input.mode() if (text.trim().length === 0 && images.length === 0 && input.commentCount() === 0) { - if (input.working()) abort() + if (input.working()) void abort() return } @@ -307,7 +312,6 @@ export function createPromptSubmit(input: PromptSubmitInput) { input.addToHistory(currentPrompt, mode) input.resetHistoryNavigation() - promptProbe.start() const projectDirectory = sdk.directory const isNewSession = !params.id @@ -427,7 +431,6 @@ export function createPromptSubmit(input: PromptSubmitInput) { return } - promptProbe.submit({ sessionID: session.id, directory: sessionDirectory }) input.onSubmit?.() if (mode === "shell") { diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index d7c249ab0..6b7fe4ef7 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -24,7 +24,7 @@ function openSessionContext(args: { }) { if (!args.view.reviewPanel.opened()) args.view.reviewPanel.open() if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all") - args.tabs.open("context") + void args.tabs.open("context") args.tabs.setActive("context") } diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index 4e7dc8e78..abf4c9334 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -1,8 +1,8 @@ import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js" import type { JSX } from "solid-js" import { useSync } from "@/context/sync" -import { checksum } from "@opencode-ai/util/encode" -import { findLast } from "@opencode-ai/util/array" +import { checksum } from "@opencode-ai/shared/util/encode" +import { findLast } from "@opencode-ai/shared/util/array" import { same } from "@/utils/same" import { Icon } from "@opencode-ai/ui/icon" import { Accordion } from "@opencode-ai/ui/accordion" diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 495b32340..7acfdfc37 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -7,8 +7,8 @@ import { Keybind } from "@opencode-ai/ui/keybind" import { Spinner } from "@opencode-ai/ui/spinner" import { showToast } from "@opencode-ai/ui/toast" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" -import { getFilename } from "@opencode-ai/util/path" -import { createEffect, createMemo, For, onCleanup, Show } from "solid-js" +import { getFilename } from "@opencode-ai/shared/util/path" +import { createEffect, createMemo, For, Show } from "solid-js" import { createStore } from "solid-js/store" import { Portal } from "solid-js/web" import { useCommand } from "@/context/command" diff --git a/packages/app/src/components/session/session-new-view.tsx b/packages/app/src/components/session/session-new-view.tsx index e4ef36393..d2cac28fc 100644 --- a/packages/app/src/components/session/session-new-view.tsx +++ b/packages/app/src/components/session/session-new-view.tsx @@ -5,7 +5,7 @@ import { useSDK } from "@/context/sdk" import { useLanguage } from "@/context/language" import { Icon } from "@opencode-ai/ui/icon" import { Mark } from "@opencode-ai/ui/logo" -import { getDirectory, getFilename } from "@opencode-ai/util/path" +import { getDirectory, getFilename } from "@opencode-ai/shared/util/path" const MAIN_WORKTREE = "main" const CREATE_WORKTREE = "create" diff --git a/packages/app/src/components/session/session-sortable-tab.tsx b/packages/app/src/components/session/session-sortable-tab.tsx index dfda91c16..fb2275c44 100644 --- a/packages/app/src/components/session/session-sortable-tab.tsx +++ b/packages/app/src/components/session/session-sortable-tab.tsx @@ -5,7 +5,7 @@ import { FileIcon } from "@opencode-ai/ui/file-icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tabs } from "@opencode-ai/ui/tabs" -import { getFilename } from "@opencode-ai/util/path" +import { getFilename } from "@opencode-ai/shared/util/path" import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" import { useCommand } from "@/context/command" diff --git a/packages/app/src/components/session/session-sortable-terminal-tab.tsx b/packages/app/src/components/session/session-sortable-terminal-tab.tsx index ba697f91a..2d88ed180 100644 --- a/packages/app/src/components/session/session-sortable-terminal-tab.tsx +++ b/packages/app/src/components/session/session-sortable-terminal-tab.tsx @@ -44,7 +44,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => const close = () => { const count = terminal.all().length - terminal.close(props.terminal.id) + void terminal.close(props.terminal.id) if (count === 1) { props.onClose?.() } diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index c8430d8bb..57e91d6d3 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -13,7 +13,6 @@ import { useSDK } from "@/context/sdk" import { useServer } from "@/context/server" import { monoFontFamily, useSettings } from "@/context/settings" import type { LocalPTY } from "@/context/terminal" -import { terminalAttr, terminalProbe } from "@/testing/terminal" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" import { terminalWriter } from "@/utils/terminal-writer" @@ -178,7 +177,6 @@ export const Terminal = (props: TerminalProps) => { let container!: HTMLDivElement const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"]) const id = local.pty.id - const probe = terminalProbe(id) const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : "" const restoreSize = restore && @@ -193,7 +191,7 @@ export const Terminal = (props: TerminalProps) => { const scrollY = typeof local.pty.scrollY === "number" ? local.pty.scrollY : undefined let ws: WebSocket | undefined let term: Term | undefined - let ghostty: Ghostty + let _ghostty: Ghostty let serializeAddon: SerializeAddon let fitAddon: FitAddon let handleResize: () => void @@ -349,9 +347,6 @@ export const Terminal = (props: TerminalProps) => { } onMount(() => { - probe.init() - cleanups.push(() => probe.drop()) - const run = async () => { const loaded = await loadGhostty() if (disposed) return @@ -377,12 +372,10 @@ export const Terminal = (props: TerminalProps) => { cleanup() return } - ghostty = g + _ghostty = g term = t output = terminalWriter((data, done) => t.write(data, () => { - probe.render(data) - probe.settle() done?.() }), ) @@ -422,7 +415,7 @@ export const Terminal = (props: TerminalProps) => { if (local.autoFocus !== false) focusTerminal() if (typeof document !== "undefined" && document.fonts) { - document.fonts.ready.then(scheduleFit) + void document.fonts.ready.then(scheduleFit) } const onResize = t.onResize((size) => { @@ -534,7 +527,6 @@ export const Terminal = (props: TerminalProps) => { const handleOpen = () => { if (disposed) return tries = 0 - probe.connect() local.onConnect?.() scheduleSize(t.cols, t.rows) } @@ -599,13 +591,6 @@ export const Terminal = (props: TerminalProps) => { socket.addEventListener("close", handleClose) } - probe.control({ - disconnect: () => { - if (!ws) return - ws.close(4_000, "e2e") - }, - }) - open() } @@ -645,12 +630,11 @@ export const Terminal = (props: TerminalProps) => { <div ref={container} data-component="terminal" - {...{ [terminalAttr]: id }} data-prevent-autofocus tabIndex={-1} style={{ "background-color": terminalColors().background }} classList={{ - ...(local.classList ?? {}), + ...local.classList, "select-text": true, "size-full px-6 py-3 font-mono relative overflow-hidden": true, [local.class ?? ""]: !!local.class, diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 0a41f3119..b7edb85ed 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, onCleanup, Show, untrack } from "solid-js" +import { createEffect, createMemo, Show, untrack } from "solid-js" import { createStore } from "solid-js/store" import { useLocation, useNavigate, useParams } from "@solidjs/router" import { IconButton } from "@opencode-ai/ui/icon-button" @@ -252,41 +252,48 @@ export function Titlebar() { </div> </div> </Show> - <Show when={hasProjects()}> - <div - class="flex items-center gap-0 transition-transform" - classList={{ - "translate-x-0": !layout.sidebar.opened(), - "-translate-x-[36px]": layout.sidebar.opened(), - "duration-180 ease-out": !layout.sidebar.opened(), - "duration-180 ease-in": layout.sidebar.opened(), - }} - > - <Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}> - <Button - variant="ghost" - icon="chevron-left" - class="titlebar-icon w-6 h-6 p-0 box-border" - disabled={!canBack()} - onClick={back} - aria-label={language.t("common.goBack")} - /> - </Tooltip> - <Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}> - <Button - variant="ghost" - icon="chevron-right" - class="titlebar-icon w-6 h-6 p-0 box-border" - disabled={!canForward()} - onClick={forward} - aria-label={language.t("common.goForward")} - /> - </Tooltip> - </div> - </Show> + <div + class="flex items-center shrink-0" + classList={{ + "translate-x-0": !layout.sidebar.opened(), + "-translate-x-[36px]": layout.sidebar.opened(), + "duration-180 ease-out": !layout.sidebar.opened(), + "duration-180 ease-in": layout.sidebar.opened(), + }} + > + <Show when={hasProjects()}> + <div class="flex items-center gap-0 transition-transform"> + <Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}> + <Button + variant="ghost" + icon="chevron-left" + class="titlebar-icon w-6 h-6 p-0 box-border" + disabled={!canBack()} + onClick={back} + aria-label={language.t("common.goBack")} + /> + </Tooltip> + <Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}> + <Button + variant="ghost" + icon="chevron-right" + class="titlebar-icon w-6 h-6 p-0 box-border" + disabled={!canForward()} + onClick={forward} + aria-label={language.t("common.goForward")} + /> + </Tooltip> + </div> + </Show> + <div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" /> + {["beta", "dev"].includes(import.meta.env.VITE_OPENCODE_CHANNEL) && ( + <div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono"> + {import.meta.env.VITE_OPENCODE_CHANNEL.toUpperCase()} + </div> + )} + </div> </div> </div> - <div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" /> </div> <div class="min-w-0 flex items-center justify-center pointer-events-none"> diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index f8fec7142..8998731a6 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -3,7 +3,7 @@ import { createStore, produce, reconcile } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { showToast } from "@opencode-ai/ui/toast" import { useParams } from "@solidjs/router" -import { getFilename } from "@opencode-ai/util/path" +import { getFilename } from "@opencode-ai/shared/util/path" import { useSDK } from "./sdk" import { useSync } from "./sync" import { useLanguage } from "@/context/language" diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx index 1205a8fa8..e53d60d5a 100644 --- a/packages/app/src/context/global-sdk.tsx +++ b/packages/app/src/context/global-sdk.tsx @@ -128,6 +128,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo if (started) return run started = true run = (async () => { + // oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit while (!abort.signal.aborted && started) { attempt = new AbortController() lastEventAt = Date.now() @@ -155,7 +156,12 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo resetHeartbeat() streamErrorLogged = false const directory = event.directory ?? "global" - const payload = event.payload + if (event.payload.type === "sync") { + continue + } + + const payload = event.payload as Event + const k = key(directory, payload) if (k) { const i = coalesced.get(k) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 0cf3570a8..6ff60f161 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -8,7 +8,7 @@ import type { Todo, } from "@opencode-ai/sdk/v2/client" import { showToast } from "@opencode-ai/ui/toast" -import { getFilename } from "@opencode-ai/util/path" +import { getFilename } from "@opencode-ai/shared/util/path" import { createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import { useLanguage } from "@/context/language" @@ -26,6 +26,7 @@ import type { ProjectMeta } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types" import { sanitizeProject } from "./global-sync/utils" import { formatServerError } from "@/utils/server-errors" +import { queryOptions, skipToken, useQueryClient } from "@tanstack/solid-query" type GlobalStore = { ready: boolean @@ -41,6 +42,9 @@ type GlobalStore = { reload: undefined | "pending" | "complete" } +export const loadSessionsQuery = (directory: string) => + queryOptions<null>({ queryKey: [directory, "loadSessions"], queryFn: skipToken }) + function createGlobalSync() { const globalSDK = useGlobalSDK() const language = useLanguage() @@ -67,6 +71,7 @@ function createGlobalSync() { config: {}, reload: undefined, }) + const queryClient = useQueryClient() let active = true let projectWritten = false @@ -198,46 +203,53 @@ function createGlobalSync() { } const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT) - const promise = loadRootSessionsWithFallback({ - directory, - limit, - list: (query) => globalSDK.client.session.list(query), - }) - .then((x) => { - const nonArchived = (x.data ?? []) - .filter((s) => !!s?.id) - .filter((s) => !s.time?.archived) - .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) - const limit = store.limit - const childSessions = store.session.filter((s) => !!s.parentID) - const sessions = trimSessions([...nonArchived, ...childSessions], { - limit, - permission: store.permission, - }) - setStore( - "sessionTotal", - estimateRootSessionTotal({ - count: nonArchived.length, - limit: x.limit, - limited: x.limited, - }), - ) - setStore("session", reconcile(sessions, { key: "id" })) - cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo) - sessionMeta.set(directory, { limit }) - }) - .catch((err) => { - console.error("Failed to load sessions", err) - const project = getFilename(directory) - showToast({ - variant: "error", - title: language.t("toast.session.listFailed.title", { project }), - description: formatServerError(err, language.t), - }) + const promise = queryClient + .ensureQueryData({ + ...loadSessionsQuery(directory), + queryFn: () => + loadRootSessionsWithFallback({ + directory, + limit, + list: (query) => globalSDK.client.session.list(query), + }) + .then((x) => { + const nonArchived = (x.data ?? []) + .filter((s) => !!s?.id) + .filter((s) => !s.time?.archived) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + const limit = store.limit + const childSessions = store.session.filter((s) => !!s.parentID) + const sessions = trimSessions([...nonArchived, ...childSessions], { + limit, + permission: store.permission, + }) + setStore( + "sessionTotal", + estimateRootSessionTotal({ + count: nonArchived.length, + limit: x.limit, + limited: x.limited, + }), + ) + setStore("session", reconcile(sessions, { key: "id" })) + cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo) + sessionMeta.set(directory, { limit }) + }) + .catch((err) => { + console.error("Failed to load sessions", err) + const project = getFilename(directory) + showToast({ + variant: "error", + title: language.t("toast.session.listFailed.title", { project }), + description: formatServerError(err, language.t), + }) + }) + .then(() => null), }) + .then(() => {}) sessionLoads.set(directory, promise) - promise.finally(() => { + void promise.finally(() => { sessionLoads.delete(directory) children.unpin(directory) }) @@ -250,8 +262,9 @@ function createGlobalSync() { if (pending) return pending children.pin(directory) - const promise = (async () => { + const promise = Promise.resolve().then(async () => { const child = children.ensureChild(directory) + child[1]("bootstrapPromise", promise!) const cache = children.vcsCache.get(directory) if (!cache) return const sdk = sdkFor(directory) @@ -269,11 +282,12 @@ function createGlobalSync() { vcsCache: cache, loadSessions, translate: language.t, + queryClient, }) - })() + }) booting.set(directory, promise) - promise.finally(() => { + void promise.finally(() => { booting.delete(directory) children.unpin(directory) }) @@ -317,7 +331,7 @@ function createGlobalSync() { setSessionTodo, vcsCache: children.vcsCache.get(directory), loadLsp: () => { - sdkFor(directory) + void sdkFor(directory) .lsp.status() .then((x) => { setStore("lsp", x.data ?? []) @@ -346,6 +360,7 @@ function createGlobalSync() { translate: language.t, formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }), setGlobalStore: setBootStore, + queryClient, }) bootedAt = Date.now() } finally { @@ -359,13 +374,13 @@ function createGlobalSync() { eventFrame = undefined eventTimer = setTimeout(() => { eventTimer = undefined - globalSDK.event.start() + void globalSDK.event.start() }, 0) }) } else { eventTimer = setTimeout(() => { eventTimer = undefined - globalSDK.event.start() + void globalSDK.event.start() }, 0) } void bootstrap() diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 7edd5a1ce..17fe726f9 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -11,13 +11,15 @@ import type { Todo, } from "@opencode-ai/sdk/v2/client" import { showToast } from "@opencode-ai/ui/toast" -import { getFilename } from "@opencode-ai/util/path" -import { retry } from "@opencode-ai/util/retry" +import { getFilename } from "@opencode-ai/shared/util/path" +import { retry } from "@opencode-ai/shared/util/retry" import { batch } from "solid-js" import { reconcile, type SetStoreFunction, type Store } from "solid-js/store" import type { State, VcsCache } from "./types" import { cmp, normalizeAgentList, normalizeProviderList } from "./utils" import { formatServerError } from "@/utils/server-errors" +import { QueryClient, queryOptions, skipToken } from "@tanstack/solid-query" +import { loadSessionsQuery } from "../global-sync" type GlobalStore = { ready: boolean @@ -65,28 +67,13 @@ function runAll(list: Array<() => Promise<unknown>>) { return Promise.allSettled(list.map((item) => item())) } -function showErrors(input: { - errors: unknown[] - title: string - translate: (key: string, vars?: Record<string, string | number>) => string - formatMoreCount: (count: number) => string -}) { - if (input.errors.length === 0) return - const message = formatServerError(input.errors[0], input.translate) - const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : "" - showToast({ - variant: "error", - title: input.title, - description: message + more, - }) -} - export async function bootstrapGlobal(input: { globalSDK: OpencodeClient requestFailedTitle: string translate: (key: string, vars?: Record<string, string | number>) => string formatMoreCount: (count: number) => string setGlobalStore: SetStoreFunction<GlobalStore> + queryClient: QueryClient }) { const fast = [ () => @@ -96,11 +83,16 @@ export async function bootstrapGlobal(input: { }), ), () => - retry(() => - input.globalSDK.provider.list().then((x) => { - input.setGlobalStore("provider", normalizeProviderList(x.data!)) - }), - ), + input.queryClient.fetchQuery({ + ...loadProvidersQuery(null), + queryFn: () => + retry(() => + input.globalSDK.provider.list().then((x) => { + input.setGlobalStore("provider", normalizeProviderList(x.data!)) + return null + }), + ), + }), ] const slow = [ @@ -122,20 +114,21 @@ export async function bootstrapGlobal(input: { }), ), ] - - showErrors({ - errors: errors(await runAll(fast)), - title: input.requestFailedTitle, - translate: input.translate, - formatMoreCount: input.formatMoreCount, - }) + await runAll(fast) + // showErrors({ + // errors: errors(await runAll(fast)), + // title: input.requestFailedTitle, + // translate: input.translate, + // formatMoreCount: input.formatMoreCount, + // }) await waitForPaint() - showErrors({ - errors: errors(await runAll(slow)), - title: input.requestFailedTitle, - translate: input.translate, - formatMoreCount: input.formatMoreCount, - }) + await runAll(slow) + // showErrors({ + // errors: errors(), + // title: input.requestFailedTitle, + // translate: input.translate, + // formatMoreCount: input.formatMoreCount, + // }) input.setGlobalStore("ready", true) } @@ -187,6 +180,12 @@ function warmSessions(input: { ).then(() => undefined) } +export const loadProvidersQuery = (directory: string | null) => + queryOptions<null>({ queryKey: [directory, "providers"], queryFn: skipToken }) + +export const loadAgentsQuery = (directory: string | null) => + queryOptions<null>({ queryKey: [directory, "agents"], queryFn: skipToken }) + export async function bootstrapDirectory(input: { directory: string sdk: OpencodeClient @@ -201,6 +200,7 @@ export async function bootstrapDirectory(input: { project: Project[] provider: ProviderListResponse } + queryClient: QueryClient }) { const loading = input.store.status !== "complete" const seededProject = projectID(input.directory, input.global.project) @@ -222,97 +222,7 @@ export async function bootstrapDirectory(input: { input.setStore("lsp", []) if (loading) input.setStore("status", "partial") - const fast = [ - () => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))), - () => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))), - () => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))), - ] - - const slow = [ - () => - seededProject - ? Promise.resolve() - : retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)), - () => - seededPath - ? Promise.resolve() - : retry(() => - input.sdk.path.get().then((x) => { - input.setStore("path", x.data!) - const next = projectID(x.data?.directory ?? input.directory, input.global.project) - if (next) input.setStore("project", next) - }), - ), - () => - retry(() => - input.sdk.vcs.get().then((x) => { - const next = x.data ?? input.store.vcs - input.setStore("vcs", next) - if (next) input.vcsCache.setStore("value", next) - }), - ), - () => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))), - () => - retry(() => - input.sdk.permission.list().then((x) => { - const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id) - const grouped = groupBySession( - (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), - ) - return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() => - batch(() => { - for (const sessionID of Object.keys(input.store.permission)) { - if (grouped[sessionID]) continue - input.setStore("permission", sessionID, []) - } - for (const [sessionID, permissions] of Object.entries(grouped)) { - input.setStore( - "permission", - sessionID, - reconcile( - permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), - { key: "id" }, - ), - ) - } - }), - ) - }), - ), - () => - retry(() => - input.sdk.question.list().then((x) => { - const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id) - const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) - return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() => - batch(() => { - for (const sessionID of Object.keys(input.store.question)) { - if (grouped[sessionID]) continue - input.setStore("question", sessionID, []) - } - for (const [sessionID, questions] of Object.entries(grouped)) { - input.setStore( - "question", - sessionID, - reconcile( - questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), - { key: "id" }, - ), - ) - } - }), - ) - }), - ), - () => Promise.resolve(input.loadSessions(input.directory)), - () => - retry(() => - input.sdk.mcp.status().then((x) => { - input.setStore("mcp", x.data!) - input.setStore("mcp_ready", true) - }), - ), - ] + const fast = [() => Promise.resolve(input.loadSessions(input.directory))] const errs = errors(await runAll(fast)) if (errs.length > 0) { @@ -325,36 +235,138 @@ export async function bootstrapDirectory(input: { }) } - await waitForPaint() - const slowErrs = errors(await runAll(slow)) - if (slowErrs.length > 0) { - console.error("Failed to finish bootstrap instance", slowErrs[0]) - const project = getFilename(input.directory) - showToast({ - variant: "error", - title: input.translate("toast.project.reloadFailed.title", { project }), - description: formatServerError(slowErrs[0], input.translate), - }) - } - - if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete") + ;(async () => { + const slow = [ + () => + input.queryClient.ensureQueryData({ + ...loadAgentsQuery(input.directory), + queryFn: () => + retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))).then( + () => null, + ), + }), + () => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))), + () => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))), + () => + seededProject + ? Promise.resolve() + : retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)), + () => + seededPath + ? Promise.resolve() + : retry(() => + input.sdk.path.get().then((x) => { + input.setStore("path", x.data!) + const next = projectID(x.data?.directory ?? input.directory, input.global.project) + if (next) input.setStore("project", next) + }), + ), + () => + retry(() => + input.sdk.vcs.get().then((x) => { + const next = x.data ?? input.store.vcs + input.setStore("vcs", next) + if (next) input.vcsCache.setStore("value", next) + }), + ), + () => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))), + () => + retry(() => + input.sdk.permission.list().then((x) => { + const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id) + const grouped = groupBySession( + (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), + ) + return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() => + batch(() => { + for (const sessionID of Object.keys(input.store.permission)) { + if (grouped[sessionID]) continue + input.setStore("permission", sessionID, []) + } + for (const [sessionID, permissions] of Object.entries(grouped)) { + input.setStore( + "permission", + sessionID, + reconcile( + permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ), + ) + } + }), + ) + }), + ), + () => + retry(() => + input.sdk.question.list().then((x) => { + const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id) + const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) + return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() => + batch(() => { + for (const sessionID of Object.keys(input.store.question)) { + if (grouped[sessionID]) continue + input.setStore("question", sessionID, []) + } + for (const [sessionID, questions] of Object.entries(grouped)) { + input.setStore( + "question", + sessionID, + reconcile( + questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ), + ) + } + }), + ) + }), + ), + () => Promise.resolve(input.loadSessions(input.directory)), + () => + retry(() => + input.sdk.mcp.status().then((x) => { + input.setStore("mcp", x.data!) + input.setStore("mcp_ready", true) + }), + ), + ] - const rev = (providerRev.get(input.directory) ?? 0) + 1 - providerRev.set(input.directory, rev) - void retry(() => input.sdk.provider.list()) - .then((x) => { - if (providerRev.get(input.directory) !== rev) return - input.setStore("provider", normalizeProviderList(x.data!)) - input.setStore("provider_ready", true) - }) - .catch((err) => { - if (providerRev.get(input.directory) !== rev) return - console.error("Failed to refresh provider list", err) + await waitForPaint() + const slowErrs = errors(await runAll(slow)) + if (slowErrs.length > 0) { + console.error("Failed to finish bootstrap instance", slowErrs[0]) const project = getFilename(input.directory) showToast({ variant: "error", title: input.translate("toast.project.reloadFailed.title", { project }), - description: formatServerError(err, input.translate), + description: formatServerError(slowErrs[0], input.translate), }) + } + + if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete") + + const rev = (providerRev.get(input.directory) ?? 0) + 1 + providerRev.set(input.directory, rev) + void input.queryClient.ensureQueryData({ + ...loadSessionsQuery(input.directory), + queryFn: () => + retry(() => input.sdk.provider.list()) + .then((x) => { + if (providerRev.get(input.directory) !== rev) return + input.setStore("provider", normalizeProviderList(x.data!)) + input.setStore("provider_ready", true) + }) + .catch((err) => { + if (providerRev.get(input.directory) !== rev) console.error("Failed to refresh provider list", err) + const project = getFilename(input.directory) + showToast({ + variant: "error", + title: input.translate("toast.project.reloadFailed.title", { project }), + description: formatServerError(err, input.translate), + }) + }) + .then(() => null), }) + })() } diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 5678491f8..6788e8cc5 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -182,6 +182,7 @@ export function createChildStoreManager(input: { limit: 5, message: {}, part: {}, + bootstrapPromise: Promise.resolve(), }) children[directory] = child disposers.set(directory, dispose) @@ -243,8 +244,8 @@ export function createChildStoreManager(input: { const cached = metaCache.get(directory) if (!cached) return const previous = store.projectMeta ?? {} - const icon = patch.icon ? { ...(previous.icon ?? {}), ...patch.icon } : previous.icon - const commands = patch.commands ? { ...(previous.commands ?? {}), ...patch.commands } : previous.commands + const icon = patch.icon ? { ...previous.icon, ...patch.icon } : previous.icon + const commands = patch.commands ? { ...previous.commands, ...patch.commands } : previous.commands const next = { ...previous, ...patch, diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 500013c1d..11a0cf83f 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -1,4 +1,4 @@ -import { Binary } from "@opencode-ai/util/binary" +import { Binary } from "@opencode-ai/shared/util/binary" import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import type { Message, diff --git a/packages/app/src/context/global-sync/queue.ts b/packages/app/src/context/global-sync/queue.ts index c3468583b..5c228dac0 100644 --- a/packages/app/src/context/global-sync/queue.ts +++ b/packages/app/src/context/global-sync/queue.ts @@ -63,6 +63,7 @@ export function createRefreshQueue(input: QueueInput) { } } finally { running = false + // oxlint-disable-next-line no-unsafe-finally -- intentional: early return skips schedule() when paused if (input.paused()) return if (root || queued.size) schedule() } diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index b0f340a90..28b3705d1 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -8,7 +8,6 @@ import type { Part, Path, PermissionRequest, - Project, ProviderListResponse, QuestionRequest, Session, @@ -73,6 +72,7 @@ export type State = { part: { [messageID: string]: Part[] } + bootstrapPromise: Promise<void> } export type VcsCache = { diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index bab3d39f3..74ea28531 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -344,7 +344,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( return } - setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...(prev ?? {}), ...next })) + setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...prev, ...next })) prune(keep) }, }) @@ -399,7 +399,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( local?.icon?.color !== undefined const base = { - ...(metadata ?? {}), + ...metadata, ...project, icon: { url: metadata?.icon?.url, @@ -582,7 +582,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( open(directory: string) { const root = rootFor(directory) if (server.projects.list().find((x) => x.worktree === root)) return - globalSync.project.loadSessions(root) + void globalSync.project.loadSessions(root) server.projects.open(root) }, close(directory: string) { diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index 1633607de..0b0972ee6 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -1,11 +1,10 @@ import { createSimpleContext } from "@opencode-ai/ui/context" -import { base64Encode } from "@opencode-ai/util/encode" +import { base64Encode } from "@opencode-ai/shared/util/encode" import { useParams } from "@solidjs/router" -import { batch, createEffect, createMemo, onCleanup } from "solid-js" +import { batch, createEffect, createMemo } from "solid-js" import { createStore } from "solid-js/store" import { useModels } from "@/context/models" import { useProviders } from "@/hooks/use-providers" -import { modelEnabled, modelProbe } from "@/testing/model-selection" import { Persist, persisted } from "@/utils/persist" import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant" import { useSDK } from "./sdk" @@ -388,53 +387,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }, }, } - - if (modelEnabled()) { - const probe = Symbol("model-probe") - - modelProbe.bind(probe, { - setAgent: agent.set, - setModel: model.set, - setVariant: model.variant.set, - }) - - createEffect(() => { - const agent = result.agent.current() - const model = result.model.current() - modelProbe.set(probe, { - dir: sdk.directory, - sessionID: id(), - last: store.last, - agent: agent?.name, - model: model - ? { - providerID: model.provider.id, - modelID: model.id, - name: model.name, - } - : undefined, - variant: result.model.variant.current() ?? null, - selected: result.model.variant.selected(), - configured: result.model.variant.configured(), - pick: scope(), - base: undefined, - current: store.current, - variants: result.model.variant.list(), - models: result.model - .list() - .filter((item) => result.model.visible({ providerID: item.provider.id, modelID: item.id })) - .map((item) => ({ - providerID: item.provider.id, - modelID: item.id, - name: item.name, - })), - agents: result.agent.list().map((item) => ({ name: item.name })), - }) - }) - - onCleanup(() => modelProbe.clear(probe)) - } - return result }, }) diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 281a1ef33..251b67b06 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -7,8 +7,8 @@ import { useGlobalSync } from "./global-sync" import { usePlatform } from "@/context/platform" import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" -import { Binary } from "@opencode-ai/util/binary" -import { base64Encode } from "@opencode-ai/util/encode" +import { Binary } from "@opencode-ai/shared/util/binary" +import { base64Encode } from "@opencode-ai/shared/util/encode" import { decode64 } from "@/utils/base64" import { EventSessionError } from "@opencode-ai/sdk/v2" import { Persist, persisted } from "@/utils/persist" diff --git a/packages/app/src/context/permission-auto-respond.test.ts b/packages/app/src/context/permission-auto-respond.test.ts index 755611300..2f8ca6265 100644 --- a/packages/app/src/context/permission-auto-respond.test.ts +++ b/packages/app/src/context/permission-auto-respond.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" -import { base64Encode } from "@opencode-ai/util/encode" +import { base64Encode } from "@opencode-ai/shared/util/encode" import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond" const session = (input: { id: string; parentID?: string }) => diff --git a/packages/app/src/context/permission-auto-respond.ts b/packages/app/src/context/permission-auto-respond.ts index b206deedf..2ebca3434 100644 --- a/packages/app/src/context/permission-auto-respond.ts +++ b/packages/app/src/context/permission-auto-respond.ts @@ -1,4 +1,4 @@ -import { base64Encode } from "@opencode-ai/util/encode" +import { base64Encode } from "@opencode-ai/shared/util/encode" export function acceptKey(sessionID: string, directory?: string) { if (!directory) return sessionID diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 831fdbca8..9b666e5e7 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -1,5 +1,5 @@ import { createSimpleContext } from "@opencode-ai/ui/context" -import { checksum } from "@opencode-ai/util/encode" +import { checksum } from "@opencode-ai/shared/util/encode" import { useParams } from "@solidjs/router" import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js" import { createStore, type SetStoreFunction } from "solid-js/store" diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index fb02a2d2d..29b7fe68c 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -1,7 +1,7 @@ import { batch, createMemo } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" -import { Binary } from "@opencode-ai/util/binary" -import { retry } from "@opencode-ai/util/retry" +import { Binary } from "@opencode-ai/shared/util/binary" +import { retry } from "@opencode-ai/shared/util/retry" import { createSimpleContext } from "@opencode-ai/ui/context" import { clearSessionPrefetch, diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 17355aab9..31d2d6e04 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -117,7 +117,7 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat entry?.value.clear() } - removePersisted(Persist.workspace(dir, "terminal"), platform) + void removePersisted(Persist.workspace(dir, "terminal"), platform) const legacy = new Set(getLegacyTerminalStorageKeys(dir)) for (const id of sessionIDs ?? []) { @@ -126,7 +126,7 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat } } for (const key of legacy) { - removePersisted({ key }, platform) + void removePersisted({ key }, platform) } } diff --git a/packages/app/src/env.d.ts b/packages/app/src/env.d.ts index 89721f34f..22e52f991 100644 --- a/packages/app/src/env.d.ts +++ b/packages/app/src/env.d.ts @@ -3,6 +3,7 @@ import "solid-js" interface ImportMetaEnv { readonly VITE_OPENCODE_SERVER_HOST: string readonly VITE_OPENCODE_SERVER_PORT: string + readonly OPENCODE_CHANNEL?: "dev" | "beta" | "prod" } interface ImportMeta { diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 0f2f7647a..2b5ccd43d 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -1,7 +1,3 @@ -import { dict as en } from "./en" - -type Keys = keyof typeof en - export const dict = { "command.category.suggested": "추천", "command.category.view": "보기", diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 427b4823b..f604dd6c5 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -1,6 +1,6 @@ import { DataProvider } from "@opencode-ai/ui/context" import { showToast } from "@opencode-ai/ui/toast" -import { base64Encode } from "@opencode-ai/util/encode" +import { base64Encode } from "@opencode-ai/shared/util/encode" import { useLocation, useNavigate, useParams } from "@solidjs/router" import { createEffect, createMemo, type ParentProps, Show } from "solid-js" import { useLanguage } from "@/context/language" diff --git a/packages/app/src/pages/error.tsx b/packages/app/src/pages/error.tsx index 1cdc06116..11284b3d2 100644 --- a/packages/app/src/pages/error.tsx +++ b/packages/app/src/pages/error.tsx @@ -1,12 +1,11 @@ import { TextField } from "@opencode-ai/ui/text-field" import { Logo } from "@opencode-ai/ui/logo" import { Button } from "@opencode-ai/ui/button" -import { Component, Show, onMount } from "solid-js" +import { Component, Show } from "solid-js" import { createStore } from "solid-js/store" import { usePlatform } from "@/context/platform" import { useLanguage } from "@/context/language" import { Icon } from "@opencode-ai/ui/icon" -import type { E2EWindow } from "@/testing/terminal" export type InitError = { name: string @@ -227,13 +226,6 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => { actionError: undefined as string | undefined, }) - onMount(() => { - const win = window as E2EWindow - if (!win.__opencode_e2e) return - const detail = formatError(props.error, language.t) - console.error(`[e2e:error-boundary] ${window.location.pathname}\n${detail}`) - }) - async function checkForUpdates() { if (!platform.checkUpdate) return setStore("checking", true) diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 4c795b968..46cacdf62 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -3,7 +3,7 @@ import { Button } from "@opencode-ai/ui/button" import { Logo } from "@opencode-ai/ui/logo" import { useLayout } from "@/context/layout" import { useNavigate } from "@solidjs/router" -import { base64Encode } from "@opencode-ai/util/encode" +import { base64Encode } from "@opencode-ai/shared/util/encode" import { Icon } from "@opencode-ai/ui/icon" import { usePlatform } from "@/context/platform" import { DateTime } from "luxon" diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index f402f4bc0..12a2bf763 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -17,7 +17,7 @@ import { useNavigate, useParams } from "@solidjs/router" import { useLayout, LocalProject } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" import { Persist, persisted } from "@/utils/persist" -import { base64Encode } from "@opencode-ai/util/encode" +import { base64Encode } from "@opencode-ai/shared/util/encode" import { decode64 } from "@/utils/base64" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Button } from "@opencode-ai/ui/button" @@ -25,7 +25,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip } from "@opencode-ai/ui/tooltip" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Dialog } from "@opencode-ai/ui/dialog" -import { getFilename } from "@opencode-ai/util/path" +import { getFilename } from "@opencode-ai/shared/util/path" import { Session, type Message } from "@opencode-ai/sdk/v2/client" import { usePlatform } from "@/context/platform" import { useSettings } from "@/context/settings" @@ -48,8 +48,8 @@ import { } from "@/context/global-sync/session-prefetch" import { useNotification } from "@/context/notification" import { usePermission } from "@/context/permission" -import { Binary } from "@opencode-ai/util/binary" -import { retry } from "@opencode-ai/util/retry" +import { Binary } from "@opencode-ai/shared/util/binary" +import { retry } from "@opencode-ai/shared/util/retry" import { playSoundById } from "@/utils/sound" import { createAim } from "@/utils/aim" import { setNavigate } from "@/utils/notification-click" @@ -132,9 +132,11 @@ export default function Layout(props: ParentProps) { if (!slug) return { slug, dir: "" } const dir = decode64(slug) if (!dir) return { slug, dir: "" } + const store = globalSync.peek(dir, { bootstrap: false }) return { slug, - dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir, + store, + dir: store[0].path.directory || dir, } }) const availableThemeEntries = createMemo(() => theme.ids().map((id) => [id, theme.themes()[id]] as const)) @@ -704,7 +706,7 @@ export default function Layout(props: ParentProps) { createEffect(() => { const active = new Set(visibleSessionDirs()) - for (const directory of [...prefetchedByDir.keys()]) { + for (const directory of prefetchedByDir.keys()) { if (active.has(directory)) continue prefetchedByDir.delete(directory) } @@ -956,7 +958,7 @@ export default function Layout(props: ParentProps) { // warm up child store to prevent flicker globalSync.child(target.worktree) - openProject(target.worktree) + void openProject(target.worktree) } function navigateSessionByUnseen(offset: number) { @@ -1094,7 +1096,7 @@ export default function Layout(props: ParentProps) { disabled: !params.dir || !params.id, onSelect: () => { const session = currentSessions().find((s) => s.id === params.id) - if (session) archiveSession(session) + if (session) void archiveSession(session) }, }, { @@ -1360,11 +1362,11 @@ export default function Layout(props: ParentProps) { if (!server.isLocal()) return for (const directory of collectOpenProjectDeepLinks(urls)) { - openProject(directory) + void openProject(directory) } for (const link of collectNewSessionDeepLinks(urls)) { - openProject(link.directory, false) + void openProject(link.directory, false) const slug = base64Encode(link.directory) if (link.prompt) { setSessionHandoff(slug, { prompt: link.prompt }) @@ -1453,11 +1455,11 @@ export default function Layout(props: ParentProps) { function resolve(result: string | string[] | null) { if (Array.isArray(result)) { for (const directory of result) { - openProject(directory, false) + void openProject(directory, false) } - navigateToProject(result[0]) + void navigateToProject(result[0]) } else if (result) { - openProject(result) + void openProject(result) } } @@ -1825,7 +1827,7 @@ export default function Layout(props: ParentProps) { const next = new Set(dirs) for (const directory of next) { if (loadedSessionDirs.has(directory)) continue - globalSync.project.loadSessions(directory) + void globalSync.project.loadSessions(directory) } loadedSessionDirs.clear() @@ -2110,7 +2112,7 @@ export default function Layout(props: ParentProps) { onSave={(next) => { const item = project() if (!item) return - renameProject(item, next) + void renameProject(item, next) }} class="text-14-medium text-text-strong truncate" displayClass="text-14-medium text-text-strong truncate" @@ -2242,7 +2244,7 @@ export default function Layout(props: ParentProps) { onClick={() => { const item = project() if (!item) return - createWorkspace(item) + void createWorkspace(item) }} > {language.t("workspace.new")} @@ -2353,8 +2355,14 @@ export default function Layout(props: ParentProps) { /> ) + const [loading] = createResource( + () => route()?.store?.[0]?.bootstrapPromise, + (p) => p, + ) + return ( <div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"> + {(autoselecting(), loading()) ?? ""} <Titlebar /> <div class="flex-1 min-h-0 min-w-0 flex"> <div class="flex-1 min-h-0 relative"> diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index 48158debb..26b66d166 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -1,4 +1,4 @@ -import { getFilename } from "@opencode-ai/util/path" +import { getFilename } from "@opencode-ai/shared/util/path" import { type Session } from "@opencode-ai/sdk/v2/client" type SessionStore = { diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index e56accfc8..b0f45859a 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -4,7 +4,7 @@ import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Spinner } from "@opencode-ai/ui/spinner" import { Tooltip } from "@opencode-ai/ui/tooltip" -import { getFilename } from "@opencode-ai/util/path" +import { getFilename } from "@opencode-ai/shared/util/path" import { A, useParams } from "@solidjs/router" import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js" import { useGlobalSync } from "@/context/global-sync" diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx index 7c9ae1aaf..076e1ef88 100644 --- a/packages/app/src/pages/layout/sidebar-project.tsx +++ b/packages/app/src/pages/layout/sidebar-project.tsx @@ -1,6 +1,6 @@ import { createMemo, For, Show, type Accessor, type JSX } from "solid-js" import { createStore } from "solid-js/store" -import { base64Encode } from "@opencode-ai/util/encode" +import { base64Encode } from "@opencode-ai/shared/util/encode" import { Button } from "@opencode-ai/ui/button" import { ContextMenu } from "@opencode-ai/ui/context-menu" import { HoverCard } from "@opencode-ai/ui/hover-card" diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 68e36ff77..c1836fa8a 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -3,8 +3,8 @@ import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "so import { createStore } from "solid-js/store" import { createSortable } from "@thisbeyond/solid-dnd" import { createMediaQuery } from "@solid-primitives/media" -import { base64Encode } from "@opencode-ai/util/encode" -import { getFilename } from "@opencode-ai/util/path" +import { base64Encode } from "@opencode-ai/shared/util/encode" +import { getFilename } from "@opencode-ai/shared/util/path" import { Button } from "@opencode-ai/ui/button" import { Collapsible } from "@opencode-ai/ui/collapsible" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" @@ -14,10 +14,11 @@ import { Spinner } from "@opencode-ai/ui/spinner" import { Tooltip } from "@opencode-ai/ui/tooltip" import { type Session } from "@opencode-ai/sdk/v2/client" import { type LocalProject } from "@/context/layout" -import { useGlobalSync } from "@/context/global-sync" +import { loadSessionsQuery, useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items" import { sortedRootSessions, workspaceKey } from "./helpers" +import { useQuery } from "@tanstack/solid-query" type InlineEditorComponent = (props: { id: string @@ -274,10 +275,10 @@ const WorkspaceSessionList = (props: { <div class="relative w-full py-1"> <Button variant="ghost" - class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10" + class="flex w-full text-left justify-start text-14-regular text-text-weak pl-2 pr-10" size="large" onClick={(e: MouseEvent) => { - props.loadMore() + void props.loadMore() ;(e.currentTarget as HTMLButtonElement).blur() }} > @@ -454,7 +455,8 @@ export const LocalWorkspace = (props: { const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow())) const booted = createMemo((prev) => prev || workspace().store.status === "complete", false) const count = createMemo(() => sessions()?.length ?? 0) - const loading = createMemo(() => !booted() && count() === 0) + const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) })) + const loading = createMemo(() => query.isPending && count() === 0) const hasMore = createMemo(() => workspace().store.sessionTotal > count()) const loadMore = async () => { workspace().setStore("limit", (limit) => (limit ?? 0) + 5) @@ -471,7 +473,7 @@ export const LocalWorkspace = (props: { mobile={props.mobile} ctx={props.ctx} showNew={() => false} - loading={loading} + loading={() => query.isLoading} sessions={sessions} hasMore={hasMore} loadMore={loadMore} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index eb6a49411..c4d642bf8 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -13,6 +13,7 @@ import { on, onMount, untrack, + createResource, } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" import { createMediaQuery } from "@solid-primitives/media" @@ -27,7 +28,7 @@ import { createAutoScroll } from "@opencode-ai/ui/hooks" import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" import { Button } from "@opencode-ai/ui/button" import { showToast } from "@opencode-ai/ui/toast" -import { checksum } from "@opencode-ai/util/encode" +import { checksum } from "@opencode-ai/shared/util/encode" import { useSearchParams } from "@solidjs/router" import { NewSessionView, SessionHeader } from "@/components/session" import { useComments } from "@/context/comments" @@ -432,8 +433,6 @@ export default function Page() { const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const isChildSession = createMemo(() => !!info()?.parentID) const diffs = createMemo(() => (params.id ? list(sync.data.session_diff[params.id]) : [])) - const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) - const hasSessionReview = createMemo(() => sessionCount() > 0) const canReview = createMemo(() => !!sync.project) const reviewTab = createMemo(() => isDesktop()) const tabState = createSessionTabs({ @@ -443,8 +442,6 @@ export default function Page() { review: reviewTab, hasReview: canReview, }) - const contextOpen = tabState.contextOpen - const openedTabs = tabState.openedTabs const activeTab = tabState.activeTab const activeFileTab = tabState.activeFileTab const revertMessageID = createMemo(() => info()?.revert?.messageID) @@ -487,7 +484,7 @@ export default function Page() { if (!tab) return const path = file.pathFromTab(tab) - if (path) file.load(path) + if (path) void file.load(path) }) createEffect( @@ -808,8 +805,9 @@ export default function Page() { const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs - createEffect( - on([() => sdk.directory, () => params.id] as const, ([, id]) => { + const [sessionSync] = createResource( + () => [sdk.directory, params.id] as const, + ([directory, id]) => { if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame) if (refreshTimer !== undefined) window.clearTimeout(refreshTimer) refreshFrame = undefined @@ -820,13 +818,10 @@ export default function Page() { const stale = !cached ? false : (() => { - const info = getSessionPrefetch(sdk.directory, id) + const info = getSessionPrefetch(directory, id) if (!info) return true return Date.now() - info.at > SESSION_PREFETCH_TTL })() - untrack(() => { - void sync.session.sync(id) - }) refreshFrame = requestAnimationFrame(() => { refreshFrame = undefined @@ -838,7 +833,9 @@ export default function Page() { }) }, 0) }) - }), + + return sync.session.sync(id) + }, ) createEffect( @@ -1885,6 +1882,7 @@ export default function Page() { return ( <div class="relative bg-background-base size-full overflow-hidden flex flex-col"> + {sessionSync() ?? ""} <SessionHeader /> <div class="flex-1 min-h-0 flex flex-col md:flex-row"> <Show when={!isDesktop() && !!params.id}> diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts index eab210868..525766dcf 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -1,6 +1,5 @@ -import { createEffect, createMemo, on, onCleanup, onMount } from "solid-js" +import { createEffect, createMemo, on, onCleanup } from "solid-js" import { createStore } from "solid-js/store" -import { makeEventListener } from "@solid-primitives/event-listener" import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2" import { useParams } from "@solidjs/router" import { showToast } from "@opencode-ai/ui/toast" @@ -9,7 +8,6 @@ import { useLanguage } from "@/context/language" import { usePermission } from "@/context/permission" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" -import { composerDriver, composerEnabled, composerEvent } from "@/testing/session-composer" import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree" export const todoState = (input: { @@ -49,49 +47,7 @@ export function createSessionComposerState(options?: { closeMs?: number | (() => return !!permissionRequest() || !!questionRequest() }) - const [test, setTest] = createStore({ - on: false, - live: undefined as boolean | undefined, - todos: undefined as Todo[] | undefined, - }) - - const pull = () => { - const id = params.id - if (!id) { - setTest({ on: false, live: undefined, todos: undefined }) - return - } - - const next = composerDriver(id) - if (!next) { - setTest({ on: false, live: undefined, todos: undefined }) - return - } - - setTest({ - on: true, - live: next.live, - todos: next.todos?.map((todo) => ({ ...todo })), - }) - } - - onMount(() => { - if (!composerEnabled()) return - - pull() - createEffect(on(() => params.id, pull, { defer: true })) - - const onEvent = (event: Event) => { - const detail = (event as CustomEvent<{ sessionID?: string }>).detail - if (detail?.sessionID !== params.id) return - pull() - } - - makeEventListener(window, composerEvent, onEvent) - }) - const todos = createMemo((): Todo[] => { - if (test.on && test.todos !== undefined) return test.todos const id = params.id if (!id) return [] return globalSync.data.session_todo[id] ?? [] @@ -108,10 +64,7 @@ export function createSessionComposerState(options?: { closeMs?: number | (() => }) const busy = createMemo(() => status().type !== "idle") - const live = createMemo(() => { - if (test.on && test.live !== undefined) return test.live - return busy() || blocked() - }) + const live = createMemo(() => busy() || blocked()) const [store, setStore] = createStore({ responding: undefined as string | undefined, @@ -163,10 +116,6 @@ export function createSessionComposerState(options?: { closeMs?: number | (() => // Keep stale turn todos from reopening if the model never clears them. const clear = () => { - if (test.on && test.todos !== undefined) { - setTest("todos", []) - return - } const id = params.id if (!id) return globalSync.todo.set(id, []) diff --git a/packages/app/src/pages/session/composer/session-todo-dock.tsx b/packages/app/src/pages/session/composer/session-todo-dock.tsx index 2214248b1..fa8c17734 100644 --- a/packages/app/src/pages/session/composer/session-todo-dock.tsx +++ b/packages/app/src/pages/session/composer/session-todo-dock.tsx @@ -7,9 +7,8 @@ import { useSpring } from "@opencode-ai/ui/motion-spring" import { TextReveal } from "@opencode-ai/ui/text-reveal" import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough" import { createResizeObserver } from "@solid-primitives/resize-observer" -import { Index, createEffect, createMemo, onCleanup } from "solid-js" +import { Index, createEffect, createMemo } from "solid-js" import { createStore } from "solid-js/store" -import { composerEnabled, composerProbe } from "@/testing/session-composer" import { useLanguage } from "@/context/language" const doneToken = "\u0000done\u0000" @@ -81,8 +80,6 @@ export function SessionTodoDock(props: { const off = createMemo(() => hide() > 0.98) const turn = createMemo(() => Math.max(0, Math.min(1, value()))) const full = createMemo(() => Math.max(78, store.height)) - const e2e = composerEnabled() - const probe = composerProbe(props.sessionID) let contentRef: HTMLDivElement | undefined createEffect(() => { @@ -95,23 +92,6 @@ export function SessionTodoDock(props: { createResizeObserver(el, update) }) - createEffect(() => { - if (!e2e) return - - probe.set({ - mounted: true, - collapsed: store.collapsed, - hidden: store.collapsed || off(), - count: props.todos.length, - states: props.todos.map((todo) => todo.status), - }) - }) - - onCleanup(() => { - if (!e2e) return - probe.drop() - }) - return ( <DockTray data-component="session-todo-dock" diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index cb7617523..37bffcd2f 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -6,7 +6,7 @@ import type { FileSearchHandle } from "@opencode-ai/ui/file" import { useFileComponent } from "@opencode-ai/ui/context/file" import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" import { createLineCommentController } from "@opencode-ai/ui/line-comment-annotations" -import { sampledChecksum } from "@opencode-ai/util/encode" +import { sampledChecksum } from "@opencode-ai/shared/util/encode" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { IconButton } from "@opencode-ai/ui/icon-button" import { Tabs } from "@opencode-ai/ui/tabs" @@ -378,12 +378,6 @@ export function FileTabContent(props: { tab: string }) { requestAnimationFrame(() => comments.clearFocus()) }) - const cancelCommenting = () => { - const p = path() - if (p) file.setSelectedLines(p, null) - setNote("commenting", null) - } - let prev = { loaded: false, ready: false, diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index f3215f685..e136ba999 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -117,7 +117,7 @@ export const createOpenReviewFile = (input: { input.openTab(tab) input.setActive(tab) } - if (maybePromise instanceof Promise) maybePromise.then(open) + if (maybePromise instanceof Promise) void maybePromise.then(open) else open() }) } diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index fe6447c2e..978f188b6 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -15,8 +15,8 @@ import { ScrollView } from "@opencode-ai/ui/scroll-view" import { TextField } from "@opencode-ai/ui/text-field" import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2" import { showToast } from "@opencode-ai/ui/toast" -import { Binary } from "@opencode-ai/util/binary" -import { getFilename } from "@opencode-ai/util/path" +import { Binary } from "@opencode-ai/shared/util/binary" +import { getFilename } from "@opencode-ai/shared/util/path" import { Popover as KobaltePopover } from "@kobalte/core/popover" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" import { SessionContextUsage } from "@/components/session-context-usage" @@ -642,10 +642,10 @@ export function MessageTimeline(props: { onClick={props.onResumeScroll} > <div - class="flex items-center justify-center w-8 h-6 rounded-[6px] border border-[var(--gray-dark-7)] bg-[color-mix(in_srgb,var(--gray-dark-3)_80%,transparent)] backdrop-blur-[0.75px] transition-colors group-hover:border-[var(--gray-dark-8)] [--icon-base:var(--gray-dark-10)] group-hover:[--icon-base:var(--gray-dark-11)]" + class="flex items-center justify-center w-8 h-6 rounded-[6px] border border-border-weaker-base bg-[color-mix(in_srgb,var(--surface-raised-stronger-non-alpha)_80%,transparent)] backdrop-blur-[0.75px] transition-colors group-hover:border-[var(--border-weak-base)] group-hover:[--icon-base:var(--icon-hover)]" style={{ "box-shadow": - "0 51px 60px 0 rgba(0,0,0,0.13), 0 15.375px 18.088px 0 rgba(0,0,0,0.19), 0 6.386px 7.513px 0 rgba(0,0,0,0.25), 0 2.31px 2.717px 0 rgba(0,0,0,0.38)", + "0 51px 60px 0 rgba(0,0,0,0.10), 0 15px 18px 0 rgba(0,0,0,0.12), 0 6.386px 7.513px 0 rgba(0,0,0,0.12), 0 2.31px 2.717px 0 rgba(0,0,0,0.20)", }} > <Icon name="arrow-down-to-line" size="small" /> @@ -791,7 +791,7 @@ export function MessageTimeline(props: { data-slot="session-title-child" value={title.draft} disabled={titleMutation.isPending} - class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]" + class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px] pl-1 -ml-1" style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }} onInput={(event) => setTitle("draft", event.currentTarget.value)} onKeyDown={(event) => { diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx index 71dfe375e..5719fce31 100644 --- a/packages/app/src/pages/session/review-tab.tsx +++ b/packages/app/src/pages/session/review-tab.tsx @@ -1,4 +1,4 @@ -import { createEffect, createSignal, onCleanup, type JSX } from "solid-js" +import { createEffect, onCleanup, type JSX } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" import { SessionReview } from "@opencode-ai/ui/session-review" diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index 1161d565a..2c2d9817f 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -19,7 +19,6 @@ import { terminalTabLabel } from "@/pages/session/terminal-label" import { createSizing, focusTerminalById } from "@/pages/session/helpers" import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff" import { useSessionLayout } from "@/pages/session/session-layout" -import { terminalProbe } from "@/testing/terminal" export function TerminalPanel() { const delays = [120, 240] @@ -78,12 +77,9 @@ export function TerminalPanel() { ) const focus = (id: string) => { - const probe = terminalProbe(id) - probe.focus(delays.length + 1) focusTerminalById(id) const frame = requestAnimationFrame(() => { - probe.step() if (!opened()) return if (terminal.active() !== id) return focusTerminalById(id) @@ -91,7 +87,6 @@ export function TerminalPanel() { const timers = delays.map((ms) => window.setTimeout(() => { - probe.step() if (!opened()) return if (terminal.active() !== id) return focusTerminalById(id) @@ -99,7 +94,6 @@ export function TerminalPanel() { ) return () => { - probe.focus(0) cancelAnimationFrame(frame) for (const timer of timers) clearTimeout(timer) } diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 239795373..b5d254463 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -12,7 +12,7 @@ import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" import { showToast } from "@opencode-ai/ui/toast" -import { findLast } from "@opencode-ai/util/array" +import { findLast } from "@opencode-ai/shared/util/array" import { createSessionTabs } from "@/pages/session/helpers" import { extractPromptFromParts } from "@/utils/prompt" import { UserMessage } from "@opencode-ai/sdk/v2" diff --git a/packages/app/src/testing/model-selection.ts b/packages/app/src/testing/model-selection.ts deleted file mode 100644 index d2770fe28..000000000 --- a/packages/app/src/testing/model-selection.ts +++ /dev/null @@ -1,109 +0,0 @@ -type ModelKey = { - providerID: string - modelID: string -} - -type ModelItem = ModelKey & { - name: string -} - -type AgentItem = { - name: string -} - -type State = { - agent?: string - model?: ModelKey | null - variant?: string | null -} - -export type ModelProbeState = { - dir?: string - sessionID?: string - last?: { - type: "agent" | "model" | "variant" - agent?: string - model?: ModelKey | null - variant?: string | null - } - agent?: string - model?: (ModelKey & { name?: string }) | undefined - variant?: string | null - selected?: string | null - configured?: string - pick?: State - base?: State - current?: string - variants?: string[] - models?: ModelItem[] - agents?: AgentItem[] -} - -export type ModelWindow = Window & { - __opencode_e2e?: { - model?: { - enabled?: boolean - current?: ModelProbeState - controls?: { - setAgent?: (name: string | undefined) => void - setModel?: (value: ModelKey | undefined) => void - setVariant?: (value: string | undefined) => void - } - } - } -} - -const clone = (state?: State) => { - if (!state) return undefined - return { - ...state, - model: state.model ? { ...state.model } : state.model, - } -} - -let active: symbol | undefined - -export const modelEnabled = () => { - if (typeof window === "undefined") return false - return (window as ModelWindow).__opencode_e2e?.model?.enabled === true -} - -const root = () => { - if (!modelEnabled()) return - return (window as ModelWindow).__opencode_e2e?.model -} - -export const modelProbe = { - bind(id: symbol, input: NonNullable<NonNullable<ModelWindow["__opencode_e2e"]>["model"]>["controls"]) { - const state = root() - if (!state) return - active = id - state.controls = input - }, - set(id: symbol, input: ModelProbeState) { - const state = root() - if (!state || active !== id) return - state.current = { - ...input, - model: input.model ? { ...input.model } : undefined, - last: input.last - ? { - ...input.last, - model: input.last.model ? { ...input.last.model } : input.last.model, - } - : undefined, - pick: clone(input.pick), - base: clone(input.base), - variants: input.variants?.slice(), - models: input.models?.map((item) => ({ ...item })), - agents: input.agents?.map((item) => ({ ...item })), - } - }, - clear(id: symbol) { - const state = root() - if (!state || active !== id) return - active = undefined - state.current = undefined - state.controls = undefined - }, -} diff --git a/packages/app/src/testing/prompt.ts b/packages/app/src/testing/prompt.ts deleted file mode 100644 index 5102ed825..000000000 --- a/packages/app/src/testing/prompt.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { E2EWindow } from "./terminal" - -export type PromptProbeState = { - popover: "at" | "slash" | null - slash: { - active: string | null - ids: string[] - } - selected: string | null - selects: number -} - -export type PromptSendState = { - started: number - count: number - sessionID?: string - directory?: string -} - -export const promptEnabled = () => { - if (typeof window === "undefined") return false - return (window as E2EWindow).__opencode_e2e?.prompt?.enabled === true -} - -const root = () => { - if (!promptEnabled()) return - return (window as E2EWindow).__opencode_e2e?.prompt -} - -export const promptProbe = { - set(input: Omit<PromptProbeState, "selected" | "selects">) { - const state = root() - if (!state) return - state.current = { - popover: input.popover, - slash: { - active: input.slash.active, - ids: [...input.slash.ids], - }, - selected: state.current?.selected ?? null, - selects: state.current?.selects ?? 0, - } - }, - select(id: string) { - const state = root() - if (!state) return - const prev = state.current - state.current = { - popover: prev?.popover ?? null, - slash: { - active: prev?.slash.active ?? null, - ids: [...(prev?.slash.ids ?? [])], - }, - selected: id, - selects: (prev?.selects ?? 0) + 1, - } - }, - clear() { - const state = root() - if (!state) return - state.current = undefined - }, - start() { - const state = root() - if (!state) return - state.sent = { - started: (state.sent?.started ?? 0) + 1, - count: state.sent?.count ?? 0, - sessionID: state.sent?.sessionID, - directory: state.sent?.directory, - } - }, - submit(input: { sessionID: string; directory: string }) { - const state = root() - if (!state) return - state.sent = { - started: state.sent?.started ?? 0, - count: (state.sent?.count ?? 0) + 1, - sessionID: input.sessionID, - directory: input.directory, - } - }, -} diff --git a/packages/app/src/testing/session-composer.ts b/packages/app/src/testing/session-composer.ts deleted file mode 100644 index 01c809e4c..000000000 --- a/packages/app/src/testing/session-composer.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { Todo } from "@opencode-ai/sdk/v2" - -export const composerEvent = "opencode:e2e:composer" - -export type ComposerDriverState = { - live?: boolean - todos?: Array<Pick<Todo, "content" | "status" | "priority">> -} - -export type ComposerProbeState = { - mounted: boolean - collapsed: boolean - hidden: boolean - count: number - states: Todo["status"][] -} - -type ComposerState = { - driver?: ComposerDriverState - probe?: ComposerProbeState -} - -export type ComposerWindow = Window & { - __opencode_e2e?: { - composer?: { - enabled?: boolean - sessions?: Record<string, ComposerState> - } - } -} - -const clone = (driver: ComposerDriverState) => ({ - live: driver.live, - todos: driver.todos?.map((todo) => ({ ...todo })), -}) - -export const composerEnabled = () => { - if (typeof window === "undefined") return false - return (window as ComposerWindow).__opencode_e2e?.composer?.enabled === true -} - -const root = () => { - if (!composerEnabled()) return - const state = (window as ComposerWindow).__opencode_e2e?.composer - if (!state) return - state.sessions ??= {} - return state.sessions -} - -export const composerDriver = (sessionID?: string) => { - if (!sessionID) return - const state = root()?.[sessionID]?.driver - if (!state) return - return clone(state) -} - -export const composerProbe = (sessionID?: string) => { - const set = (next: ComposerProbeState) => { - if (!sessionID) return - const sessions = root() - if (!sessions) return - const prev = sessions[sessionID] ?? {} - sessions[sessionID] = { - ...prev, - probe: { - ...next, - states: [...next.states], - }, - } - } - - return { - set, - drop() { - set({ - mounted: false, - collapsed: false, - hidden: true, - count: 0, - states: [], - }) - }, - } -} diff --git a/packages/app/src/testing/terminal.ts b/packages/app/src/testing/terminal.ts deleted file mode 100644 index db8001ddf..000000000 --- a/packages/app/src/testing/terminal.ts +++ /dev/null @@ -1,119 +0,0 @@ -import type { ModelProbeState } from "./model-selection" - -export const terminalAttr = "data-pty-id" - -export type TerminalProbeState = { - connected: boolean - connects: number - rendered: string - settled: number - focusing: number -} - -type TerminalProbeControl = { - disconnect?: VoidFunction -} - -export type E2EWindow = Window & { - __opencode_e2e?: { - model?: { - enabled?: boolean - current?: ModelProbeState - } - prompt?: { - enabled?: boolean - current?: import("./prompt").PromptProbeState - sent?: import("./prompt").PromptSendState - } - terminal?: { - enabled?: boolean - terminals?: Record<string, TerminalProbeState> - controls?: Record<string, TerminalProbeControl> - } - } -} - -const seed = (): TerminalProbeState => ({ - connected: false, - connects: 0, - rendered: "", - settled: 0, - focusing: 0, -}) - -const root = () => { - if (typeof window === "undefined") return - const state = (window as E2EWindow).__opencode_e2e?.terminal - if (!state?.enabled) return - return state -} - -const terms = () => { - const state = root() - if (!state) return - state.terminals ??= {} - return state.terminals -} - -const controls = () => { - const state = root() - if (!state) return - state.controls ??= {} - return state.controls -} - -export const terminalProbe = (id: string) => { - const set = (next: Partial<TerminalProbeState>) => { - const state = terms() - if (!state) return - state[id] = { ...(state[id] ?? seed()), ...next } - } - - return { - init() { - set(seed()) - }, - connect() { - const state = terms() - if (!state) return - const prev = state[id] ?? seed() - state[id] = { - ...prev, - connected: true, - connects: prev.connects + 1, - } - }, - render(data: string) { - const state = terms() - if (!state) return - const prev = state[id] ?? seed() - state[id] = { ...prev, rendered: prev.rendered + data } - }, - settle() { - const state = terms() - if (!state) return - const prev = state[id] ?? seed() - state[id] = { ...prev, settled: prev.settled + 1 } - }, - focus(count: number) { - set({ focusing: Math.max(0, count) }) - }, - step() { - const state = terms() - if (!state) return - const prev = state[id] ?? seed() - state[id] = { ...prev, focusing: Math.max(0, prev.focusing - 1) } - }, - control(next: Partial<TerminalProbeControl>) { - const state = controls() - if (!state) return - state[id] = { ...(state[id] ?? {}), ...next } - }, - drop() { - const state = terms() - if (state) delete state[id] - const control = controls() - if (control) delete control[id] - }, - } -} diff --git a/packages/app/src/utils/base64.ts b/packages/app/src/utils/base64.ts index c1f9d88c6..f60dff2b6 100644 --- a/packages/app/src/utils/base64.ts +++ b/packages/app/src/utils/base64.ts @@ -1,4 +1,4 @@ -import { base64Decode } from "@opencode-ai/util/encode" +import { base64Decode } from "@opencode-ai/shared/util/encode" export function decode64(value: string | undefined) { if (value === undefined) return diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts index 3dcbeb7d3..dce0e94c3 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -1,6 +1,6 @@ import { Platform, usePlatform } from "@/context/platform" import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage" -import { checksum } from "@opencode-ai/util/encode" +import { checksum } from "@opencode-ai/shared/util/encode" import { createResource, type Accessor } from "solid-js" import type { SetStoreFunction, Store } from "solid-js/store" diff --git a/packages/app/src/utils/runtime-adapters.test.ts b/packages/app/src/utils/runtime-adapters.test.ts index 9f408b8eb..49552e179 100644 --- a/packages/app/src/utils/runtime-adapters.test.ts +++ b/packages/app/src/utils/runtime-adapters.test.ts @@ -46,7 +46,9 @@ describe("runtime adapters", () => { }) test("resolves speech recognition constructor with webkit precedence", () => { + // oxlint-disable-next-line no-extraneous-class class SpeechCtor {} + // oxlint-disable-next-line no-extraneous-class class WebkitCtor {} const ctor = getSpeechRecognitionCtor({ SpeechRecognition: SpeechCtor, diff --git a/packages/app/src/utils/server.ts b/packages/app/src/utils/server.ts index 17f4a3adc..ae849b71e 100644 --- a/packages/app/src/utils/server.ts +++ b/packages/app/src/utils/server.ts @@ -16,7 +16,10 @@ export function createSdkForServer({ return createOpencodeClient({ ...config, - headers: { ...config.headers, ...auth }, + headers: { + ...(config.headers instanceof Headers ? Object.fromEntries(config.headers.entries()) : config.headers), + ...auth, + }, baseUrl: server.url, }) } |
