summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/app/src')
-rw-r--r--packages/app/src/addons/serialize.test.ts4
-rw-r--r--packages/app/src/addons/serialize.ts4
-rw-r--r--packages/app/src/app.tsx34
-rw-r--r--packages/app/src/components/dialog-connect-provider.tsx4
-rw-r--r--packages/app/src/components/dialog-edit-project.tsx2
-rw-r--r--packages/app/src/components/dialog-fork.tsx2
-rw-r--r--packages/app/src/components/dialog-select-directory.tsx2
-rw-r--r--packages/app/src/components/dialog-select-file.tsx8
-rw-r--r--packages/app/src/components/dialog-select-server.tsx6
-rw-r--r--packages/app/src/components/file-tree.tsx3
-rw-r--r--packages/app/src/components/prompt-input.tsx230
-rw-r--r--packages/app/src/components/prompt-input/build-request-parts.ts2
-rw-r--r--packages/app/src/components/prompt-input/context-items.tsx2
-rw-r--r--packages/app/src/components/prompt-input/slash-popover.tsx2
-rw-r--r--packages/app/src/components/prompt-input/submit.test.ts2
-rw-r--r--packages/app/src/components/prompt-input/submit.ts29
-rw-r--r--packages/app/src/components/session-context-usage.tsx2
-rw-r--r--packages/app/src/components/session/session-context-tab.tsx4
-rw-r--r--packages/app/src/components/session/session-header.tsx4
-rw-r--r--packages/app/src/components/session/session-new-view.tsx2
-rw-r--r--packages/app/src/components/session/session-sortable-tab.tsx2
-rw-r--r--packages/app/src/components/session/session-sortable-terminal-tab.tsx2
-rw-r--r--packages/app/src/components/terminal.tsx24
-rw-r--r--packages/app/src/components/titlebar.tsx75
-rw-r--r--packages/app/src/context/file.tsx2
-rw-r--r--packages/app/src/context/global-sdk.tsx8
-rw-r--r--packages/app/src/context/global-sync.tsx103
-rw-r--r--packages/app/src/context/global-sync/bootstrap.ts316
-rw-r--r--packages/app/src/context/global-sync/child-store.ts5
-rw-r--r--packages/app/src/context/global-sync/event-reducer.ts2
-rw-r--r--packages/app/src/context/global-sync/queue.ts1
-rw-r--r--packages/app/src/context/global-sync/types.ts2
-rw-r--r--packages/app/src/context/layout.tsx6
-rw-r--r--packages/app/src/context/local.tsx52
-rw-r--r--packages/app/src/context/notification.tsx4
-rw-r--r--packages/app/src/context/permission-auto-respond.test.ts2
-rw-r--r--packages/app/src/context/permission-auto-respond.ts2
-rw-r--r--packages/app/src/context/prompt.tsx2
-rw-r--r--packages/app/src/context/sync.tsx4
-rw-r--r--packages/app/src/context/terminal.tsx4
-rw-r--r--packages/app/src/env.d.ts1
-rw-r--r--packages/app/src/i18n/ko.ts4
-rw-r--r--packages/app/src/pages/directory-layout.tsx2
-rw-r--r--packages/app/src/pages/error.tsx10
-rw-r--r--packages/app/src/pages/home.tsx2
-rw-r--r--packages/app/src/pages/layout.tsx40
-rw-r--r--packages/app/src/pages/layout/helpers.ts2
-rw-r--r--packages/app/src/pages/layout/sidebar-items.tsx2
-rw-r--r--packages/app/src/pages/layout/sidebar-project.tsx2
-rw-r--r--packages/app/src/pages/layout/sidebar-workspace.tsx16
-rw-r--r--packages/app/src/pages/session.tsx24
-rw-r--r--packages/app/src/pages/session/composer/session-composer-state.ts55
-rw-r--r--packages/app/src/pages/session/composer/session-todo-dock.tsx22
-rw-r--r--packages/app/src/pages/session/file-tabs.tsx8
-rw-r--r--packages/app/src/pages/session/helpers.ts2
-rw-r--r--packages/app/src/pages/session/message-timeline.tsx10
-rw-r--r--packages/app/src/pages/session/review-tab.tsx2
-rw-r--r--packages/app/src/pages/session/terminal-panel.tsx6
-rw-r--r--packages/app/src/pages/session/use-session-commands.tsx2
-rw-r--r--packages/app/src/testing/model-selection.ts109
-rw-r--r--packages/app/src/testing/prompt.ts83
-rw-r--r--packages/app/src/testing/session-composer.ts84
-rw-r--r--packages/app/src/testing/terminal.ts119
-rw-r--r--packages/app/src/utils/base64.ts2
-rw-r--r--packages/app/src/utils/persist.ts2
-rw-r--r--packages/app/src/utils/runtime-adapters.test.ts2
-rw-r--r--packages/app/src/utils/server.ts5
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,
})
}