Merge remote-tracking branch 'origin/dev' into feat/fff-search-tools
# Conflicts: # bun.lock # packages/opencode/package.json # packages/opencode/src/file/index.ts
This commit is contained in:
commit
f977cbe2b8
247 changed files with 9967 additions and 5661 deletions
|
|
@ -175,9 +175,9 @@ export async function runTerminal(page: Page, input: { cmd: string; token: strin
|
|||
await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
|
||||
}
|
||||
|
||||
export async function openPalette(page: Page) {
|
||||
export async function openPalette(page: Page, key = "K") {
|
||||
await defocus(page)
|
||||
await page.keyboard.press(`${modKey}+P`)
|
||||
await page.keyboard.press(`${modKey}+${key}`)
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toBeVisible()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { test, expect } from "../fixtures"
|
||||
import { openPalette } from "../actions"
|
||||
import { closeDialog, openPalette } from "../actions"
|
||||
|
||||
test("search palette opens and closes", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
|
@ -9,3 +9,12 @@ test("search palette opens and closes", async ({ page, gotoSession }) => {
|
|||
await page.keyboard.press("Escape")
|
||||
await expect(dialog).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("search palette also opens with cmd+p", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openPalette(page, "P")
|
||||
|
||||
await closeDialog(page, dialog)
|
||||
await expect(dialog).toHaveCount(0)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -108,7 +108,10 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page
|
|||
await page.keyboard.type(draft)
|
||||
await wait(page, draft)
|
||||
|
||||
await edge(page, "start")
|
||||
// Clear the draft before navigating history (ArrowUp only works when prompt is empty)
|
||||
await prompt.fill("")
|
||||
await wait(page, "")
|
||||
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, second)
|
||||
|
||||
|
|
@ -119,7 +122,7 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page
|
|||
await wait(page, second)
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, draft)
|
||||
await wait(page, "")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -241,7 +241,7 @@ test("changing file open keybind works", async ({ page, gotoSession }) => {
|
|||
await expect(keybindButton).toBeVisible()
|
||||
|
||||
const initialKeybind = await keybindButton.textContent()
|
||||
expect(initialKeybind).toContain("P")
|
||||
expect(initialKeybind).toContain("K")
|
||||
|
||||
await keybindButton.click()
|
||||
await expect(keybindButton).toHaveText(/press/i)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,16 @@
|
|||
import { test, expect } from "../fixtures"
|
||||
import { cleanupSession, closeSidebar, hoverSessionItem } from "../actions"
|
||||
import {
|
||||
defocus,
|
||||
cleanupSession,
|
||||
cleanupTestProject,
|
||||
closeSidebar,
|
||||
createTestProject,
|
||||
hoverSessionItem,
|
||||
openSidebar,
|
||||
waitSession,
|
||||
} from "../actions"
|
||||
import { projectSwitchSelector } from "../selectors"
|
||||
import { dirSlug } from "../utils"
|
||||
|
||||
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
|
||||
const stamp = Date.now()
|
||||
|
|
@ -37,3 +47,72 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
|
|||
await cleanupSession({ sdk, sessionID: two.id })
|
||||
}
|
||||
})
|
||||
|
||||
test("open sidebar project popover stays closed after clicking avatar", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const other = await createTestProject()
|
||||
const slug = dirSlug(other)
|
||||
|
||||
try {
|
||||
await withProject(
|
||||
async () => {
|
||||
await openSidebar(page)
|
||||
|
||||
const project = page.locator(projectSwitchSelector(slug)).first()
|
||||
const card = page.locator('[data-component="hover-card-content"]')
|
||||
|
||||
await expect(project).toBeVisible()
|
||||
await project.hover()
|
||||
await expect(card.getByText(/recent sessions/i)).toBeVisible()
|
||||
|
||||
await page.mouse.down()
|
||||
await expect(card).toHaveCount(0)
|
||||
await page.mouse.up()
|
||||
|
||||
await waitSession(page, { directory: other })
|
||||
await expect(card).toHaveCount(0)
|
||||
},
|
||||
{ extra: [other] },
|
||||
)
|
||||
} finally {
|
||||
await cleanupTestProject(other)
|
||||
}
|
||||
})
|
||||
|
||||
test("open sidebar project switch activates on first tabbed enter", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const other = await createTestProject()
|
||||
const slug = dirSlug(other)
|
||||
|
||||
try {
|
||||
await withProject(
|
||||
async () => {
|
||||
await openSidebar(page)
|
||||
await defocus(page)
|
||||
|
||||
const project = page.locator(projectSwitchSelector(slug)).first()
|
||||
|
||||
await expect(project).toBeVisible()
|
||||
|
||||
let hit = false
|
||||
for (let i = 0; i < 20; i++) {
|
||||
hit = await project.evaluate((el) => {
|
||||
return el.matches(":focus") || !!el.parentElement?.matches(":focus")
|
||||
})
|
||||
if (hit) break
|
||||
await page.keyboard.press("Tab")
|
||||
}
|
||||
|
||||
expect(hit).toBe(true)
|
||||
|
||||
await page.keyboard.press("Enter")
|
||||
await waitSession(page, { directory: other })
|
||||
},
|
||||
{ extra: [other] },
|
||||
)
|
||||
} finally {
|
||||
await cleanupTestProject(other)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { Page } from "@playwright/test"
|
||||
import { runTerminal, waitTerminalReady } from "../actions"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { terminalSelector } from "../selectors"
|
||||
import { dropdownMenuContentSelector, terminalSelector } from "../selectors"
|
||||
import { terminalToggleKey, workspacePersistKey } from "../utils"
|
||||
|
||||
type State = {
|
||||
|
|
@ -130,3 +130,39 @@ test("closing the active terminal tab falls back to the previous tab", async ({
|
|||
.toEqual({ count: 1, first: true })
|
||||
})
|
||||
})
|
||||
|
||||
test("terminal tab can be renamed from the context menu", async ({ page, withProject }) => {
|
||||
await withProject(async ({ directory, gotoSession }) => {
|
||||
const key = workspacePersistKey(directory, "terminal")
|
||||
const rename = `E2E term ${Date.now()}`
|
||||
const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first()
|
||||
|
||||
await gotoSession()
|
||||
await open(page)
|
||||
|
||||
await expect(tab).toContainText(/Terminal 1/)
|
||||
await tab.click({ button: "right" })
|
||||
|
||||
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||
await expect(menu).toBeVisible()
|
||||
await menu.getByRole("menuitem", { name: /^Rename$/i }).click()
|
||||
await expect(menu).toHaveCount(0)
|
||||
|
||||
const input = page.locator('#terminal-panel input[type="text"]').first()
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill(rename)
|
||||
await input.press("Enter")
|
||||
|
||||
await expect(input).toHaveCount(0)
|
||||
await expect(tab).toContainText(rename)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const state = await store(page, key)
|
||||
return state?.all[0]?.title
|
||||
},
|
||||
{ timeout: 5_000 },
|
||||
)
|
||||
.toBe(rename)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.2",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
|
@ -51,9 +51,11 @@
|
|||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
"@solid-primitives/scroll": "2.1.3",
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
"@solid-primitives/timer": "1.4.4",
|
||||
"@solid-primitives/websocket": "1.3.1",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "catalog:",
|
||||
"@tanstack/solid-query": "5.91.4",
|
||||
"@thisbeyond/solid-dnd": "0.7.5",
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@ import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
|||
import { File } from "@opencode-ai/ui/file"
|
||||
import { Font } from "@opencode-ai/ui/font"
|
||||
import { Splash } from "@opencode-ai/ui/logo"
|
||||
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
||||
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 {
|
||||
type Component,
|
||||
|
|
@ -31,7 +32,7 @@ import { FileProvider } from "@/context/file"
|
|||
import { GlobalSDKProvider } from "@/context/global-sdk"
|
||||
import { GlobalSyncProvider } from "@/context/global-sync"
|
||||
import { HighlightsProvider } from "@/context/highlights"
|
||||
import { LanguageProvider, useLanguage } from "@/context/language"
|
||||
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||
import { LayoutProvider } from "@/context/layout"
|
||||
import { ModelsProvider } from "@/context/models"
|
||||
import { NotificationProvider } from "@/context/notification"
|
||||
|
|
@ -81,6 +82,11 @@ function MarkedProviderWithNativeParser(props: ParentProps) {
|
|||
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
|
||||
}
|
||||
|
||||
function QueryProvider(props: ParentProps) {
|
||||
const client = new QueryClient()
|
||||
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
|
||||
}
|
||||
|
||||
function AppShellProviders(props: ParentProps) {
|
||||
return (
|
||||
<SettingsProvider>
|
||||
|
|
@ -124,7 +130,7 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
|
|||
)
|
||||
}
|
||||
|
||||
export function AppBaseProviders(props: ParentProps) {
|
||||
export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
||||
return (
|
||||
<MetaProvider>
|
||||
<Font />
|
||||
|
|
@ -133,14 +139,16 @@ export function AppBaseProviders(props: ParentProps) {
|
|||
void window.api?.setTitlebar?.({ mode })
|
||||
}}
|
||||
>
|
||||
<LanguageProvider>
|
||||
<LanguageProvider locale={props.locale}>
|
||||
<UiI18nBridge>
|
||||
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
||||
<DialogProvider>
|
||||
<MarkedProviderWithNativeParser>
|
||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||
</MarkedProviderWithNativeParser>
|
||||
</DialogProvider>
|
||||
<QueryProvider>
|
||||
<DialogProvider>
|
||||
<MarkedProviderWithNativeParser>
|
||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||
</MarkedProviderWithNativeParser>
|
||||
</DialogProvider>
|
||||
</QueryProvider>
|
||||
</ErrorBoundary>
|
||||
</UiI18nBridge>
|
||||
</LanguageProvider>
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string;
|
|||
<Tooltip value={props.tip} placement="top">
|
||||
<div
|
||||
classList={{
|
||||
"flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] bg-white/5 px-0.5 py-1 text-center": true,
|
||||
"flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] px-0.5 py-1 text-center": true,
|
||||
"col-span-2": !!props.wide,
|
||||
}}
|
||||
>
|
||||
|
|
@ -363,11 +363,7 @@ export function DebugBar() {
|
|||
return (
|
||||
<aside
|
||||
aria-label={language.t("debugBar.ariaLabel")}
|
||||
class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border p-0.5 text-text-on-interactive-base shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
|
||||
style={{
|
||||
"background-color": "color-mix(in srgb, var(--icon-interactive-base) 42%, black)",
|
||||
"border-color": "color-mix(in srgb, white 14%, transparent)",
|
||||
}}
|
||||
class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 text-text-strong shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
|
||||
>
|
||||
<div class="grid grid-cols-5 gap-px font-mono">
|
||||
<Cell
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
|
||||
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
|
|
@ -9,13 +9,12 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
|||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
|
||||
import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Link } from "@/components/link"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { DialogSelectModel } from "./dialog-select-model"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
|
||||
export function DialogConnectProvider(props: { provider: string }) {
|
||||
|
|
@ -35,15 +34,25 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||
})
|
||||
|
||||
const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!)
|
||||
const methods = createMemo(
|
||||
() =>
|
||||
globalSync.data.provider_auth[props.provider] ?? [
|
||||
{
|
||||
type: "api",
|
||||
label: language.t("provider.connect.method.apiKey"),
|
||||
},
|
||||
],
|
||||
const fallback = createMemo<ProviderAuthMethod[]>(() => [
|
||||
{
|
||||
type: "api" as const,
|
||||
label: language.t("provider.connect.method.apiKey"),
|
||||
},
|
||||
])
|
||||
const [auth] = createResource(
|
||||
() => props.provider,
|
||||
async () => {
|
||||
const cached = globalSync.data.provider_auth[props.provider]
|
||||
if (cached) return cached
|
||||
const res = await globalSDK.client.provider.auth()
|
||||
if (!alive.value) return fallback()
|
||||
globalSync.set("provider_auth", res.data ?? {})
|
||||
return res.data?.[props.provider] ?? fallback()
|
||||
},
|
||||
)
|
||||
const loading = createMemo(() => auth.loading && !globalSync.data.provider_auth[props.provider])
|
||||
const methods = createMemo(() => auth.latest ?? globalSync.data.provider_auth[props.provider] ?? fallback())
|
||||
const [store, setStore] = createStore({
|
||||
methodIndex: undefined as undefined | number,
|
||||
authorization: undefined as undefined | ProviderAuthAuthorization,
|
||||
|
|
@ -178,7 +187,11 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||
index: 0,
|
||||
})
|
||||
|
||||
const prompts = createMemo(() => method()?.prompts ?? [])
|
||||
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
|
||||
const value = method()
|
||||
if (value?.type !== "oauth") return []
|
||||
return value.prompts ?? []
|
||||
})
|
||||
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
|
||||
if (!prompt.when) return true
|
||||
const actual = value[prompt.when.key]
|
||||
|
|
@ -297,8 +310,12 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||
listRef?.onKeyDown(e)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let auto = false
|
||||
createEffect(() => {
|
||||
if (auto) return
|
||||
if (loading()) return
|
||||
if (methods().length === 1) {
|
||||
auto = true
|
||||
selectMethod(0)
|
||||
}
|
||||
})
|
||||
|
|
@ -574,6 +591,14 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||
<div class="px-2.5 pb-10 flex flex-col gap-6">
|
||||
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
|
||||
<Switch>
|
||||
<Match when={loading()}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Spinner />
|
||||
<span>{language.t("provider.connect.status.inProgress")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={store.methodIndex === undefined}>
|
||||
<MethodSelection />
|
||||
</Match>
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ export type FormState = {
|
|||
apiKey: string
|
||||
models: ModelRow[]
|
||||
headers: HeaderRow[]
|
||||
saving: boolean
|
||||
err: {
|
||||
providerID?: string
|
||||
name?: string
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ describe("validateCustomProvider", () => {
|
|||
{ row: "h0", key: " X-Test ", value: " enabled ", err: {} },
|
||||
{ row: "h1", key: "", value: "", err: {} },
|
||||
],
|
||||
saving: false,
|
||||
err: {},
|
||||
},
|
||||
t,
|
||||
|
|
@ -60,7 +59,6 @@ describe("validateCustomProvider", () => {
|
|||
{ row: "h0", key: "Authorization", value: "one", err: {} },
|
||||
{ row: "h1", key: "authorization", value: "two", err: {} },
|
||||
],
|
||||
saving: false,
|
||||
err: {},
|
||||
},
|
||||
t,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { batch, For } from "solid-js"
|
||||
|
|
@ -31,7 +32,6 @@ export function DialogCustomProvider(props: Props) {
|
|||
apiKey: "",
|
||||
models: [modelRow()],
|
||||
headers: [headerRow()],
|
||||
saving: false,
|
||||
err: {},
|
||||
})
|
||||
|
||||
|
|
@ -116,48 +116,49 @@ export function DialogCustomProvider(props: Props) {
|
|||
return output.result
|
||||
}
|
||||
|
||||
const save = async (e: SubmitEvent) => {
|
||||
e.preventDefault()
|
||||
if (form.saving) return
|
||||
const saveMutation = useMutation(() => ({
|
||||
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
||||
const disabledProviders = globalSync.data.config.disabled_providers ?? []
|
||||
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
||||
|
||||
const result = validate()
|
||||
if (!result) return
|
||||
|
||||
setForm("saving", true)
|
||||
|
||||
const disabledProviders = globalSync.data.config.disabled_providers ?? []
|
||||
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
||||
|
||||
const auth = result.key
|
||||
? globalSDK.client.auth.set({
|
||||
if (result.key) {
|
||||
await globalSDK.client.auth.set({
|
||||
providerID: result.providerID,
|
||||
auth: {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
},
|
||||
})
|
||||
: Promise.resolve()
|
||||
}
|
||||
|
||||
auth
|
||||
.then(() =>
|
||||
globalSync.updateConfig({ provider: { [result.providerID]: result.config }, disabled_providers: nextDisabled }),
|
||||
)
|
||||
.then(() => {
|
||||
dialog.close()
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
|
||||
description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
|
||||
})
|
||||
await globalSync.updateConfig({
|
||||
provider: { [result.providerID]: result.config },
|
||||
disabled_providers: nextDisabled,
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
})
|
||||
.finally(() => {
|
||||
setForm("saving", false)
|
||||
return result
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
dialog.close()
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
|
||||
description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
|
||||
})
|
||||
},
|
||||
onError: (err) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
},
|
||||
}))
|
||||
|
||||
const save = (e: SubmitEvent) => {
|
||||
e.preventDefault()
|
||||
if (saveMutation.isPending) return
|
||||
|
||||
const result = validate()
|
||||
if (!result) return
|
||||
saveMutation.mutate(result)
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -312,8 +313,14 @@ export function DialogCustomProvider(props: Props) {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<Button class="w-auto self-start" type="submit" size="large" variant="primary" disabled={form.saving}>
|
||||
{form.saving ? language.t("common.saving") : language.t("common.submit")}
|
||||
<Button
|
||||
class="w-auto self-start"
|
||||
type="submit"
|
||||
size="large"
|
||||
variant="primary"
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Button } from "@opencode-ai/ui/button"
|
|||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { createMemo, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
|
@ -28,7 +29,6 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||
color: props.project.icon?.color || "pink",
|
||||
iconUrl: props.project.icon?.override || "",
|
||||
startup: props.project.commands?.start ?? "",
|
||||
saving: false,
|
||||
dragOver: false,
|
||||
iconHover: false,
|
||||
})
|
||||
|
|
@ -71,38 +71,37 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||
setStore("iconUrl", "")
|
||||
}
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
const saveMutation = useMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
||||
const start = store.startup.trim()
|
||||
|
||||
await Promise.resolve()
|
||||
.then(async () => {
|
||||
setStore("saving", true)
|
||||
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
||||
const start = store.startup.trim()
|
||||
|
||||
if (props.project.id && props.project.id !== "global") {
|
||||
await globalSDK.client.project.update({
|
||||
projectID: props.project.id,
|
||||
directory: props.project.worktree,
|
||||
name,
|
||||
icon: { color: store.color, override: store.iconUrl },
|
||||
commands: { start },
|
||||
})
|
||||
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
|
||||
dialog.close()
|
||||
return
|
||||
}
|
||||
|
||||
globalSync.project.meta(props.project.worktree, {
|
||||
if (props.project.id && props.project.id !== "global") {
|
||||
await globalSDK.client.project.update({
|
||||
projectID: props.project.id,
|
||||
directory: props.project.worktree,
|
||||
name,
|
||||
icon: { color: store.color, override: store.iconUrl || undefined },
|
||||
commands: { start: start || undefined },
|
||||
icon: { color: store.color, override: store.iconUrl },
|
||||
commands: { start },
|
||||
})
|
||||
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
|
||||
dialog.close()
|
||||
return
|
||||
}
|
||||
|
||||
globalSync.project.meta(props.project.worktree, {
|
||||
name,
|
||||
icon: { color: store.color, override: store.iconUrl || undefined },
|
||||
commands: { start: start || undefined },
|
||||
})
|
||||
.finally(() => {
|
||||
setStore("saving", false)
|
||||
})
|
||||
dialog.close()
|
||||
},
|
||||
}))
|
||||
|
||||
function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
if (saveMutation.isPending) return
|
||||
saveMutation.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -246,8 +245,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" size="large" disabled={store.saving}>
|
||||
{store.saving ? language.t("common.saving") : language.t("common.save")}
|
||||
<Button type="submit" variant="primary" size="large" disabled={saveMutation.isPending}>
|
||||
{saveMutation.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Component, createMemo, createSignal, Show } from "solid-js"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { Component, createMemo, Show } from "solid-js"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
|
|
@ -17,7 +18,6 @@ export const DialogSelectMcp: Component = () => {
|
|||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const language = useLanguage()
|
||||
const [loading, setLoading] = createSignal<string | null>(null)
|
||||
|
||||
const items = createMemo(() =>
|
||||
Object.entries(sync.data.mcp ?? {})
|
||||
|
|
@ -25,10 +25,8 @@ export const DialogSelectMcp: Component = () => {
|
|||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
)
|
||||
|
||||
const toggle = async (name: string) => {
|
||||
if (loading()) return
|
||||
setLoading(name)
|
||||
try {
|
||||
const toggle = useMutation(() => ({
|
||||
mutationFn: async (name: string) => {
|
||||
const status = sync.data.mcp[name]
|
||||
if (status?.status === "connected") {
|
||||
await sdk.client.mcp.disconnect({ name })
|
||||
|
|
@ -38,10 +36,8 @@ export const DialogSelectMcp: Component = () => {
|
|||
|
||||
const result = await sdk.client.mcp.status()
|
||||
if (result.data) sync.set("mcp", result.data)
|
||||
} finally {
|
||||
setLoading(null)
|
||||
}
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
|
||||
const totalCount = createMemo(() => items().length)
|
||||
|
|
@ -59,7 +55,8 @@ export const DialogSelectMcp: Component = () => {
|
|||
filterKeys={["name", "status"]}
|
||||
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
||||
onSelect={(x) => {
|
||||
if (x) toggle(x.name)
|
||||
if (!x || toggle.isPending) return
|
||||
toggle.mutate(x.name)
|
||||
}}
|
||||
>
|
||||
{(i) => {
|
||||
|
|
@ -83,7 +80,7 @@ export const DialogSelectMcp: Component = () => {
|
|||
<Show when={statusLabel()}>
|
||||
<span class="text-11-regular text-text-weaker">{statusLabel()}</span>
|
||||
</Show>
|
||||
<Show when={loading() === i.name}>
|
||||
<Show when={toggle.isPending && toggle.variables === i.name}>
|
||||
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
|
@ -92,7 +89,14 @@ export const DialogSelectMcp: Component = () => {
|
|||
</Show>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<Switch checked={enabled()} disabled={loading() === i.name} onChange={() => toggle(i.name)} />
|
||||
<Switch
|
||||
checked={enabled()}
|
||||
disabled={toggle.isPending && toggle.variables === i.name}
|
||||
onChange={() => {
|
||||
if (toggle.isPending) return
|
||||
toggle.mutate(i.name)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Icon } from "@opencode-ai/ui/icon"
|
|||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
|
||||
|
|
@ -186,7 +187,6 @@ export function DialogSelectServer() {
|
|||
name: "",
|
||||
username: DEFAULT_USERNAME,
|
||||
password: "",
|
||||
adding: false,
|
||||
error: "",
|
||||
showForm: false,
|
||||
status: undefined as boolean | undefined,
|
||||
|
|
@ -198,7 +198,6 @@ export function DialogSelectServer() {
|
|||
username: "",
|
||||
password: "",
|
||||
error: "",
|
||||
busy: false,
|
||||
status: undefined as boolean | undefined,
|
||||
},
|
||||
})
|
||||
|
|
@ -209,7 +208,6 @@ export function DialogSelectServer() {
|
|||
name: "",
|
||||
username: DEFAULT_USERNAME,
|
||||
password: "",
|
||||
adding: false,
|
||||
error: "",
|
||||
showForm: false,
|
||||
status: undefined,
|
||||
|
|
@ -224,10 +222,78 @@ export function DialogSelectServer() {
|
|||
password: "",
|
||||
error: "",
|
||||
status: undefined,
|
||||
busy: false,
|
||||
})
|
||||
}
|
||||
|
||||
const addMutation = useMutation(() => ({
|
||||
mutationFn: async (value: string) => {
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) {
|
||||
resetAdd()
|
||||
return
|
||||
}
|
||||
|
||||
const conn: ServerConnection.Http = {
|
||||
type: "http",
|
||||
http: { url: normalized },
|
||||
}
|
||||
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
|
||||
if (store.addServer.password) conn.http.password = store.addServer.password
|
||||
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
|
||||
const result = await checkServerHealth(conn.http)
|
||||
if (!result.healthy) {
|
||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
|
||||
resetAdd()
|
||||
await select(conn, true)
|
||||
},
|
||||
}))
|
||||
|
||||
const editMutation = useMutation(() => ({
|
||||
mutationFn: async (input: { original: ServerConnection.Any; value: string }) => {
|
||||
if (input.original.type !== "http") return
|
||||
const normalized = normalizeServerUrl(input.value)
|
||||
if (!normalized) {
|
||||
resetEdit()
|
||||
return
|
||||
}
|
||||
|
||||
const name = store.editServer.name.trim() || undefined
|
||||
const username = store.editServer.username || undefined
|
||||
const password = store.editServer.password || undefined
|
||||
const existingName = input.original.displayName
|
||||
if (
|
||||
normalized === input.original.http.url &&
|
||||
name === existingName &&
|
||||
username === input.original.http.username &&
|
||||
password === input.original.http.password
|
||||
) {
|
||||
resetEdit()
|
||||
return
|
||||
}
|
||||
|
||||
const conn: ServerConnection.Http = {
|
||||
type: "http",
|
||||
displayName: name,
|
||||
http: { url: normalized, username, password },
|
||||
}
|
||||
const result = await checkServerHealth(conn.http)
|
||||
if (!result.healthy) {
|
||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
if (normalized === input.original.http.url) {
|
||||
server.add(conn)
|
||||
} else {
|
||||
replaceServer(input.original, conn)
|
||||
}
|
||||
|
||||
resetEdit()
|
||||
},
|
||||
}))
|
||||
|
||||
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
|
||||
const active = server.key
|
||||
const newConn = server.add(next)
|
||||
|
|
@ -296,7 +362,7 @@ export function DialogSelectServer() {
|
|||
}
|
||||
|
||||
const handleAddChange = (value: string) => {
|
||||
if (store.addServer.adding) return
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { url: value, error: "" })
|
||||
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
|
||||
setStore("addServer", { status: next }),
|
||||
|
|
@ -304,12 +370,12 @@ export function DialogSelectServer() {
|
|||
}
|
||||
|
||||
const handleAddNameChange = (value: string) => {
|
||||
if (store.addServer.adding) return
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { name: value, error: "" })
|
||||
}
|
||||
|
||||
const handleAddUsernameChange = (value: string) => {
|
||||
if (store.addServer.adding) return
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { username: value, error: "" })
|
||||
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
|
||||
setStore("addServer", { status: next }),
|
||||
|
|
@ -317,7 +383,7 @@ export function DialogSelectServer() {
|
|||
}
|
||||
|
||||
const handleAddPasswordChange = (value: string) => {
|
||||
if (store.addServer.adding) return
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { password: value, error: "" })
|
||||
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
|
||||
setStore("addServer", { status: next }),
|
||||
|
|
@ -325,7 +391,7 @@ export function DialogSelectServer() {
|
|||
}
|
||||
|
||||
const handleEditChange = (value: string) => {
|
||||
if (store.editServer.busy) return
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { value, error: "" })
|
||||
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
|
||||
setStore("editServer", { status: next }),
|
||||
|
|
@ -333,12 +399,12 @@ export function DialogSelectServer() {
|
|||
}
|
||||
|
||||
const handleEditNameChange = (value: string) => {
|
||||
if (store.editServer.busy) return
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { name: value, error: "" })
|
||||
}
|
||||
|
||||
const handleEditUsernameChange = (value: string) => {
|
||||
if (store.editServer.busy) return
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { username: value, error: "" })
|
||||
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
|
||||
setStore("editServer", { status: next }),
|
||||
|
|
@ -346,85 +412,13 @@ export function DialogSelectServer() {
|
|||
}
|
||||
|
||||
const handleEditPasswordChange = (value: string) => {
|
||||
if (store.editServer.busy) return
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { password: value, error: "" })
|
||||
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
|
||||
setStore("editServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
async function handleAdd(value: string) {
|
||||
if (store.addServer.adding) return
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) {
|
||||
resetAdd()
|
||||
return
|
||||
}
|
||||
|
||||
setStore("addServer", { adding: true, error: "" })
|
||||
|
||||
const conn: ServerConnection.Http = {
|
||||
type: "http",
|
||||
http: { url: normalized },
|
||||
}
|
||||
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
|
||||
if (store.addServer.password) conn.http.password = store.addServer.password
|
||||
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
|
||||
const result = await checkServerHealth(conn.http)
|
||||
setStore("addServer", { adding: false })
|
||||
if (!result.healthy) {
|
||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
|
||||
resetAdd()
|
||||
await select(conn, true)
|
||||
}
|
||||
|
||||
async function handleEdit(original: ServerConnection.Any, value: string) {
|
||||
if (store.editServer.busy || original.type !== "http") return
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) {
|
||||
resetEdit()
|
||||
return
|
||||
}
|
||||
|
||||
const name = store.editServer.name.trim() || undefined
|
||||
const username = store.editServer.username || undefined
|
||||
const password = store.editServer.password || undefined
|
||||
const existingName = original.displayName
|
||||
if (
|
||||
normalized === original.http.url &&
|
||||
name === existingName &&
|
||||
username === original.http.username &&
|
||||
password === original.http.password
|
||||
) {
|
||||
resetEdit()
|
||||
return
|
||||
}
|
||||
|
||||
setStore("editServer", { busy: true, error: "" })
|
||||
|
||||
const conn: ServerConnection.Http = {
|
||||
type: "http",
|
||||
displayName: name,
|
||||
http: { url: normalized, username, password },
|
||||
}
|
||||
const result = await checkServerHealth(conn.http)
|
||||
setStore("editServer", { busy: false })
|
||||
if (!result.healthy) {
|
||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
if (normalized === original.http.url) {
|
||||
server.add(conn)
|
||||
} else {
|
||||
replaceServer(original, conn)
|
||||
}
|
||||
|
||||
resetEdit()
|
||||
}
|
||||
|
||||
const mode = createMemo<"list" | "add" | "edit">(() => {
|
||||
if (store.editServer.id) return "edit"
|
||||
if (store.addServer.showForm) return "add"
|
||||
|
|
@ -464,23 +458,26 @@ export function DialogSelectServer() {
|
|||
password: conn.http.password ?? "",
|
||||
error: "",
|
||||
status: store.status[ServerConnection.key(conn)]?.healthy,
|
||||
busy: false,
|
||||
})
|
||||
}
|
||||
|
||||
const submitForm = () => {
|
||||
if (mode() === "add") {
|
||||
void handleAdd(store.addServer.url)
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { error: "" })
|
||||
addMutation.mutate(store.addServer.url)
|
||||
return
|
||||
}
|
||||
const original = editing()
|
||||
if (!original) return
|
||||
void handleEdit(original, store.editServer.value)
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { error: "" })
|
||||
editMutation.mutate({ original, value: store.editServer.value })
|
||||
}
|
||||
|
||||
const isFormMode = createMemo(() => mode() !== "list")
|
||||
const isAddMode = createMemo(() => mode() === "add")
|
||||
const formBusy = createMemo(() => (isAddMode() ? store.addServer.adding : store.editServer.busy))
|
||||
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
|
||||
|
||||
const formTitle = createMemo(() => {
|
||||
if (!isFormMode()) return language.t("dialog.server.title")
|
||||
|
|
|
|||
|
|
@ -572,6 +572,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
const open = recent()
|
||||
const seen = new Set(open)
|
||||
const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true }))
|
||||
if (!query.trim()) return [...agents, ...pinned]
|
||||
const paths = await files.searchFilesAndDirectories(query)
|
||||
const fileOptions: AtOption[] = paths
|
||||
.filter((path) => !seen.has(path))
|
||||
|
|
@ -1043,7 +1044,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
return true
|
||||
}
|
||||
|
||||
const { addAttachment, removeAttachment, handlePaste } = createPromptAttachments({
|
||||
const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
|
||||
editor: () => editorRef,
|
||||
isDialogActive: () => !!dialog.active,
|
||||
setDraggingType: (type) => setStore("draggingType", type),
|
||||
|
|
@ -1388,11 +1389,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
class="hidden"
|
||||
onChange={(e) => {
|
||||
const list = e.currentTarget.files
|
||||
if (list) {
|
||||
for (const file of Array.from(list)) {
|
||||
void addAttachment(file)
|
||||
}
|
||||
}
|
||||
if (list) void addAttachments(Array.from(list))
|
||||
e.currentTarget.value = ""
|
||||
}}
|
||||
/>
|
||||
|
|
@ -1501,7 +1498,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
id={local.model.current()!.provider.id}
|
||||
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)" }}
|
||||
/>
|
||||
|
|
@ -1533,7 +1530,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
id={local.model.current()!.provider.id}
|
||||
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)" }}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -71,6 +71,18 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||
|
||||
const addAttachment = (file: File) => add(file)
|
||||
|
||||
const addAttachments = async (files: File[], toast = true) => {
|
||||
let found = false
|
||||
|
||||
for (const file of files) {
|
||||
const ok = await add(file, false)
|
||||
if (ok) found = true
|
||||
}
|
||||
|
||||
if (!found && files.length > 0 && toast) warn()
|
||||
return found
|
||||
}
|
||||
|
||||
const removeAttachment = (id: string) => {
|
||||
const current = prompt.current()
|
||||
const next = current.filter((part) => part.type !== "image" || part.id !== id)
|
||||
|
|
@ -84,18 +96,14 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const items = Array.from(clipboardData.items)
|
||||
const fileItems = items.filter((item) => item.kind === "file")
|
||||
const files = Array.from(clipboardData.items).flatMap((item) => {
|
||||
if (item.kind !== "file") return []
|
||||
const file = item.getAsFile()
|
||||
return file ? [file] : []
|
||||
})
|
||||
|
||||
if (fileItems.length > 0) {
|
||||
let found = false
|
||||
for (const item of fileItems) {
|
||||
const file = item.getAsFile()
|
||||
if (!file) continue
|
||||
const ok = await add(file, false)
|
||||
if (ok) found = true
|
||||
}
|
||||
if (!found) warn()
|
||||
if (files.length > 0) {
|
||||
await addAttachments(files)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -169,12 +177,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||
const dropped = event.dataTransfer?.files
|
||||
if (!dropped) return
|
||||
|
||||
let found = false
|
||||
for (const file of Array.from(dropped)) {
|
||||
const ok = await add(file, false)
|
||||
if (ok) found = true
|
||||
}
|
||||
if (!found && dropped.length > 0) warn()
|
||||
await addAttachments(Array.from(dropped))
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
|
|
@ -191,6 +194,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||
|
||||
return {
|
||||
addAttachment,
|
||||
addAttachments,
|
||||
removeAttachment,
|
||||
handlePaste,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,32 @@ describe("buildRequestParts", () => {
|
|||
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
|
||||
})
|
||||
|
||||
test("keeps multiple uploaded attachments in order", () => {
|
||||
const result = buildRequestParts({
|
||||
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
|
||||
context: [],
|
||||
images: [
|
||||
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
|
||||
{
|
||||
type: "image",
|
||||
id: "img_2",
|
||||
filename: "b.pdf",
|
||||
mime: "application/pdf",
|
||||
dataUrl: "data:application/pdf;base64,BBB",
|
||||
},
|
||||
],
|
||||
text: "check these",
|
||||
messageID: "msg_multi",
|
||||
sessionID: "ses_multi",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
|
||||
const files = result.requestParts.filter((part) => part.type === "file" && part.url.startsWith("data:"))
|
||||
|
||||
expect(files).toHaveLength(2)
|
||||
expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"])
|
||||
})
|
||||
|
||||
test("deduplicates context files when prompt already includes same path", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
|
||||
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ describe("prompt-input history", () => {
|
|||
test("canNavigateHistoryAtCursor only allows prompt boundaries", () => {
|
||||
const value = "a\nb\nc"
|
||||
|
||||
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(false)
|
||||
expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false)
|
||||
|
||||
expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false)
|
||||
|
|
@ -135,11 +135,14 @@ describe("prompt-input history", () => {
|
|||
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
|
||||
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
|
||||
|
||||
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(false)
|
||||
expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(false)
|
||||
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(false)
|
||||
|
||||
expect(canNavigateHistoryAtCursor("up", "", 0)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("down", "", 0)).toBe(true)
|
||||
|
||||
expect(canNavigateHistoryAtCursor("up", "abc", 0, true)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("up", "abc", 3, true)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("down", "abc", 0, true)).toBe(true)
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export function canNavigateHistoryAtCursor(direction: "up" | "down", text: strin
|
|||
const atStart = position === 0
|
||||
const atEnd = position === text.length
|
||||
if (inHistory) return atStart || atEnd
|
||||
if (direction === "up") return position === 0
|
||||
if (direction === "up") return position === 0 && text.length === 0
|
||||
return position === text.length
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -267,14 +267,14 @@ export function SessionContextTab() {
|
|||
|
||||
return (
|
||||
<ScrollView
|
||||
class="@container h-full pb-10"
|
||||
class="@container h-full"
|
||||
viewportRef={(el) => {
|
||||
scroll = el
|
||||
restoreScroll()
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div class="px-6 pt-4 flex flex-col gap-10">
|
||||
<div class="px-6 pt-4 pb-10 flex flex-col gap-10">
|
||||
<div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
|
||||
<For each={stats}>
|
||||
{(stat) => <Stat label={language.t(stat.label as Parameters<typeof language.t>[0])} value={stat.value()} />}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
|||
})
|
||||
let input: HTMLInputElement | undefined
|
||||
let blurFrame: number | undefined
|
||||
let editRequested = false
|
||||
|
||||
const isDefaultTitle = () => {
|
||||
const number = props.terminal.titleNumber
|
||||
|
|
@ -168,8 +169,14 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
|||
left: `${store.menuPosition.x}px`,
|
||||
top: `${store.menuPosition.y}px`,
|
||||
}}
|
||||
onCloseAutoFocus={(e) => {
|
||||
if (!editRequested) return
|
||||
e.preventDefault()
|
||||
editRequested = false
|
||||
requestAnimationFrame(() => edit())
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Item onSelect={edit}>
|
||||
<DropdownMenu.Item onSelect={() => (editRequested = true)}>
|
||||
<Icon name="edit" class="w-4 h-4 mr-2" />
|
||||
{language.t("common.rename")}
|
||||
</DropdownMenu.Item>
|
||||
|
|
|
|||
|
|
@ -1,27 +1,41 @@
|
|||
import { Component, Show, createMemo, createResource, type JSX } from "solid-js"
|
||||
import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings, monoFontFamily } from "@/context/settings"
|
||||
import { playSound, SOUND_OPTIONS } from "@/utils/sound"
|
||||
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||
import { Link } from "./link"
|
||||
import { SettingsList } from "./settings-list"
|
||||
|
||||
let demoSoundState = {
|
||||
cleanup: undefined as (() => void) | undefined,
|
||||
timeout: undefined as NodeJS.Timeout | undefined,
|
||||
run: 0,
|
||||
}
|
||||
|
||||
type ThemeOption = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
let font: Promise<typeof import("@opencode-ai/ui/font-loader")> | undefined
|
||||
|
||||
function loadFont() {
|
||||
font ??= import("@opencode-ai/ui/font-loader")
|
||||
return font
|
||||
}
|
||||
|
||||
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
|
||||
// delay the playback by 100ms during quick selection changes and pause existing sounds.
|
||||
const stopDemoSound = () => {
|
||||
demoSoundState.run += 1
|
||||
if (demoSoundState.cleanup) {
|
||||
demoSoundState.cleanup()
|
||||
}
|
||||
|
|
@ -29,12 +43,19 @@ const stopDemoSound = () => {
|
|||
demoSoundState.cleanup = undefined
|
||||
}
|
||||
|
||||
const playDemoSound = (src: string | undefined) => {
|
||||
const playDemoSound = (id: string | undefined) => {
|
||||
stopDemoSound()
|
||||
if (!src) return
|
||||
if (!id) return
|
||||
|
||||
const run = ++demoSoundState.run
|
||||
demoSoundState.timeout = setTimeout(() => {
|
||||
demoSoundState.cleanup = playSound(src)
|
||||
void playSoundById(id).then((cleanup) => {
|
||||
if (demoSoundState.run !== run) {
|
||||
cleanup?.()
|
||||
return
|
||||
}
|
||||
demoSoundState.cleanup = cleanup
|
||||
})
|
||||
}, 100)
|
||||
}
|
||||
|
||||
|
|
@ -44,6 +65,10 @@ export const SettingsGeneral: Component = () => {
|
|||
const platform = usePlatform()
|
||||
const settings = useSettings()
|
||||
|
||||
onMount(() => {
|
||||
void theme.loadThemes()
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
checking: false,
|
||||
})
|
||||
|
|
@ -104,9 +129,7 @@ export const SettingsGeneral: Component = () => {
|
|||
.finally(() => setStore("checking", false))
|
||||
}
|
||||
|
||||
const themeOptions = createMemo(() =>
|
||||
Object.entries(theme.themes()).map(([id, def]) => ({ id, name: def.name ?? id })),
|
||||
)
|
||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||
|
||||
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
|
||||
{ value: "system", label: language.t("theme.scheme.system") },
|
||||
|
|
@ -143,7 +166,7 @@ export const SettingsGeneral: Component = () => {
|
|||
] as const
|
||||
const fontOptionsList = [...fontOptions]
|
||||
|
||||
const noneSound = { id: "none", label: "sound.option.none", src: undefined } as const
|
||||
const noneSound = { id: "none", label: "sound.option.none" } as const
|
||||
const soundOptions = [noneSound, ...SOUND_OPTIONS]
|
||||
|
||||
const soundSelectProps = (
|
||||
|
|
@ -158,7 +181,7 @@ export const SettingsGeneral: Component = () => {
|
|||
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
|
||||
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
|
||||
if (!option) return
|
||||
playDemoSound(option.src)
|
||||
playDemoSound(option.id === "none" ? undefined : option.id)
|
||||
},
|
||||
onSelect: (option: (typeof soundOptions)[number] | undefined) => {
|
||||
if (!option) return
|
||||
|
|
@ -169,7 +192,7 @@ export const SettingsGeneral: Component = () => {
|
|||
}
|
||||
setEnabled(true)
|
||||
set(option.id)
|
||||
playDemoSound(option.src)
|
||||
playDemoSound(option.id)
|
||||
},
|
||||
variant: "secondary" as const,
|
||||
size: "small" as const,
|
||||
|
|
@ -321,6 +344,9 @@ export const SettingsGeneral: Component = () => {
|
|||
current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
|
||||
value={(o) => o.value}
|
||||
label={(o) => language.t(o.label)}
|
||||
onHighlight={(option) => {
|
||||
void loadFont().then((x) => x.ensureMonoFont(option?.value))
|
||||
}}
|
||||
onSelect={(option) => option && settings.appearance.setFont(option.value)}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Icon } from "@opencode-ai/ui/icon"
|
|||
import { Popover } from "@opencode-ai/ui/popover"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||
|
|
@ -15,7 +16,6 @@ import { useSDK } from "@/context/sdk"
|
|||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||
import { DialogSelectServer } from "./dialog-select-server"
|
||||
|
||||
const pollMs = 10_000
|
||||
|
||||
|
|
@ -53,11 +53,15 @@ const listServersByHealth = (
|
|||
})
|
||||
}
|
||||
|
||||
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>) => {
|
||||
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
|
||||
|
||||
createEffect(() => {
|
||||
if (!enabled()) {
|
||||
setStatus(reconcile({}))
|
||||
return
|
||||
}
|
||||
const list = servers()
|
||||
let dead = false
|
||||
|
||||
|
|
@ -130,41 +134,30 @@ const useDefaultServerKey = (
|
|||
}
|
||||
}
|
||||
|
||||
const useMcpToggle = (input: {
|
||||
sync: ReturnType<typeof useSync>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) => {
|
||||
const [loading, setLoading] = createSignal<string | null>(null)
|
||||
const useMcpToggleMutation = () => {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const language = useLanguage()
|
||||
|
||||
const toggle = async (name: string) => {
|
||||
if (loading()) return
|
||||
setLoading(name)
|
||||
|
||||
try {
|
||||
const status = input.sync.data.mcp[name]
|
||||
await (status?.status === "connected"
|
||||
? input.sdk.client.mcp.disconnect({ name })
|
||||
: input.sdk.client.mcp.connect({ name }))
|
||||
const result = await input.sdk.client.mcp.status()
|
||||
if (result.data) input.sync.set("mcp", result.data)
|
||||
} catch (err) {
|
||||
return useMutation(() => ({
|
||||
mutationFn: async (name: string) => {
|
||||
const status = sync.data.mcp[name]
|
||||
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
|
||||
const result = await sdk.client.mcp.status()
|
||||
if (result.data) sync.set("mcp", result.data)
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.language.t("common.requestFailed"),
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
} finally {
|
||||
setLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
return { loading, toggle }
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
export function StatusPopover() {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
|
|
@ -172,6 +165,12 @@ export function StatusPopover() {
|
|||
const navigate = useNavigate()
|
||||
|
||||
const [shown, setShown] = createSignal(false)
|
||||
let dialogRun = 0
|
||||
let dialogDead = false
|
||||
onCleanup(() => {
|
||||
dialogDead = true
|
||||
dialogRun += 1
|
||||
})
|
||||
const servers = createMemo(() => {
|
||||
const current = server.current
|
||||
const list = server.list
|
||||
|
|
@ -179,9 +178,9 @@ export function StatusPopover() {
|
|||
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
|
||||
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
|
||||
})
|
||||
const health = useServerHealth(servers)
|
||||
const health = useServerHealth(servers, shown)
|
||||
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
|
||||
const mcp = useMcpToggle({ sync, sdk, language })
|
||||
const toggleMcp = useMcpToggleMutation()
|
||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
||||
|
|
@ -310,7 +309,13 @@ export function StatusPopover() {
|
|||
<Button
|
||||
variant="secondary"
|
||||
class="mt-3 self-start h-8 px-3 py-1.5"
|
||||
onClick={() => dialog.show(() => <DialogSelectServer />, defaultServer.refresh)}
|
||||
onClick={() => {
|
||||
const run = ++dialogRun
|
||||
void import("./dialog-select-server").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{language.t("status.popover.action.manageServers")}
|
||||
</Button>
|
||||
|
|
@ -337,8 +342,11 @@ export function StatusPopover() {
|
|||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
||||
onClick={() => mcp.toggle(name)}
|
||||
disabled={mcp.loading() === name}
|
||||
onClick={() => {
|
||||
if (toggleMcp.isPending) return
|
||||
toggleMcp.mutate(name)
|
||||
}}
|
||||
disabled={toggleMcp.isPending && toggleMcp.variables === name}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
|
|
@ -354,8 +362,11 @@ export function StatusPopover() {
|
|||
<div onClick={(event) => event.stopPropagation()}>
|
||||
<Switch
|
||||
checked={enabled()}
|
||||
disabled={mcp.loading() === name}
|
||||
onChange={() => mcp.toggle(name)}
|
||||
disabled={toggleMcp.isPending && toggleMcp.variables === name}
|
||||
onChange={() => {
|
||||
if (toggleMcp.isPending) return
|
||||
toggleMcp.mutate(name)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme"
|
||||
import { withAlpha } from "@opencode-ai/ui/theme/color"
|
||||
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
|
||||
import type { HexColor } from "@opencode-ai/ui/theme/types"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
|
||||
import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
|||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { useTheme } from "@opencode-ai/ui/theme"
|
||||
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
|
|
|
|||
|
|
@ -40,4 +40,11 @@ describe("command keybind helpers", () => {
|
|||
expect(display.includes("Alt") || display.includes("⌥")).toBe(true)
|
||||
expect(formatKeybind("none")).toBe("")
|
||||
})
|
||||
|
||||
test("formatKeybind prefers the first combo", () => {
|
||||
const display = formatKeybind("mod+k,mod+p")
|
||||
|
||||
expect(display.includes("K") || display.includes("k")).toBe(true)
|
||||
expect(display.includes("P") || display.includes("p")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,17 +9,7 @@ import type {
|
|||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import {
|
||||
createContext,
|
||||
getOwner,
|
||||
Match,
|
||||
onCleanup,
|
||||
onMount,
|
||||
type ParentProps,
|
||||
Switch,
|
||||
untrack,
|
||||
useContext,
|
||||
} from "solid-js"
|
||||
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"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
|
@ -80,6 +70,8 @@ function createGlobalSync() {
|
|||
|
||||
let active = true
|
||||
let projectWritten = false
|
||||
let bootedAt = 0
|
||||
let bootingRoot = false
|
||||
|
||||
onCleanup(() => {
|
||||
active = false
|
||||
|
|
@ -258,6 +250,11 @@ function createGlobalSync() {
|
|||
const sdk = sdkFor(directory)
|
||||
await bootstrapDirectory({
|
||||
directory,
|
||||
global: {
|
||||
config: globalStore.config,
|
||||
project: globalStore.project,
|
||||
provider: globalStore.provider,
|
||||
},
|
||||
sdk,
|
||||
store: child[0],
|
||||
setStore: child[1],
|
||||
|
|
@ -278,15 +275,20 @@ function createGlobalSync() {
|
|||
const unsub = globalSDK.event.listen((e) => {
|
||||
const directory = e.name
|
||||
const event = e.details
|
||||
const recent = bootingRoot || Date.now() - bootedAt < 1500
|
||||
|
||||
if (directory === "global") {
|
||||
applyGlobalEvent({
|
||||
event,
|
||||
project: globalStore.project,
|
||||
refresh: queue.refresh,
|
||||
refresh: () => {
|
||||
if (recent) return
|
||||
queue.refresh()
|
||||
},
|
||||
setGlobalProject: setProjects,
|
||||
})
|
||||
if (event.type === "server.connected" || event.type === "global.disposed") {
|
||||
if (recent) return
|
||||
for (const directory of Object.keys(children.children)) {
|
||||
queue.push(directory)
|
||||
}
|
||||
|
|
@ -325,17 +327,19 @@ function createGlobalSync() {
|
|||
})
|
||||
|
||||
async function bootstrap() {
|
||||
await bootstrapGlobal({
|
||||
globalSDK: globalSDK.client,
|
||||
connectErrorTitle: language.t("dialog.server.add.error"),
|
||||
connectErrorDescription: language.t("error.globalSync.connectFailed", {
|
||||
url: globalSDK.url,
|
||||
}),
|
||||
requestFailedTitle: language.t("common.requestFailed"),
|
||||
translate: language.t,
|
||||
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
|
||||
setGlobalStore: setBootStore,
|
||||
})
|
||||
bootingRoot = true
|
||||
try {
|
||||
await bootstrapGlobal({
|
||||
globalSDK: globalSDK.client,
|
||||
requestFailedTitle: language.t("common.requestFailed"),
|
||||
translate: language.t,
|
||||
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
|
||||
setGlobalStore: setBootStore,
|
||||
})
|
||||
bootedAt = Date.now()
|
||||
} finally {
|
||||
bootingRoot = false
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
|
|
@ -392,13 +396,7 @@ const GlobalSyncContext = createContext<ReturnType<typeof createGlobalSync>>()
|
|||
|
||||
export function GlobalSyncProvider(props: ParentProps) {
|
||||
const value = createGlobalSync()
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={value.ready}>
|
||||
<GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
return <GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
|
||||
}
|
||||
|
||||
export function useGlobalSync() {
|
||||
|
|
|
|||
|
|
@ -31,73 +31,102 @@ type GlobalStore = {
|
|||
reload: undefined | "pending" | "complete"
|
||||
}
|
||||
|
||||
function waitForPaint() {
|
||||
return new Promise<void>((resolve) => {
|
||||
let done = false
|
||||
const finish = () => {
|
||||
if (done) return
|
||||
done = true
|
||||
resolve()
|
||||
}
|
||||
const timer = setTimeout(finish, 50)
|
||||
if (typeof requestAnimationFrame !== "function") return
|
||||
requestAnimationFrame(() => {
|
||||
clearTimeout(timer)
|
||||
finish()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function errors(list: PromiseSettledResult<unknown>[]) {
|
||||
return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason)
|
||||
}
|
||||
|
||||
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
|
||||
connectErrorTitle: string
|
||||
connectErrorDescription: string
|
||||
requestFailedTitle: string
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
formatMoreCount: (count: number) => string
|
||||
setGlobalStore: SetStoreFunction<GlobalStore>
|
||||
}) {
|
||||
const health = await input.globalSDK.global
|
||||
.health()
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
if (!health?.healthy) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.connectErrorTitle,
|
||||
description: input.connectErrorDescription,
|
||||
})
|
||||
input.setGlobalStore("ready", true)
|
||||
return
|
||||
}
|
||||
|
||||
const tasks = [
|
||||
retry(() =>
|
||||
input.globalSDK.path.get().then((x) => {
|
||||
input.setGlobalStore("path", x.data!)
|
||||
}),
|
||||
),
|
||||
retry(() =>
|
||||
input.globalSDK.global.config.get().then((x) => {
|
||||
input.setGlobalStore("config", x.data!)
|
||||
}),
|
||||
),
|
||||
retry(() =>
|
||||
input.globalSDK.project.list().then((x) => {
|
||||
const projects = (x.data ?? [])
|
||||
.filter((p) => !!p?.id)
|
||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||
.slice()
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
input.setGlobalStore("project", projects)
|
||||
}),
|
||||
),
|
||||
retry(() =>
|
||||
input.globalSDK.provider.list().then((x) => {
|
||||
input.setGlobalStore("provider", normalizeProviderList(x.data!))
|
||||
}),
|
||||
),
|
||||
retry(() =>
|
||||
input.globalSDK.provider.auth().then((x) => {
|
||||
input.setGlobalStore("provider_auth", x.data ?? {})
|
||||
}),
|
||||
),
|
||||
const fast = [
|
||||
() =>
|
||||
retry(() =>
|
||||
input.globalSDK.path.get().then((x) => {
|
||||
input.setGlobalStore("path", x.data!)
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.globalSDK.global.config.get().then((x) => {
|
||||
input.setGlobalStore("config", x.data!)
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.globalSDK.provider.list().then((x) => {
|
||||
input.setGlobalStore("provider", normalizeProviderList(x.data!))
|
||||
}),
|
||||
),
|
||||
]
|
||||
|
||||
const results = await Promise.allSettled(tasks)
|
||||
const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason)
|
||||
if (errors.length) {
|
||||
const message = formatServerError(errors[0], input.translate)
|
||||
const more = errors.length > 1 ? input.formatMoreCount(errors.length - 1) : ""
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.requestFailedTitle,
|
||||
description: message + more,
|
||||
})
|
||||
}
|
||||
const slow = [
|
||||
() =>
|
||||
retry(() =>
|
||||
input.globalSDK.project.list().then((x) => {
|
||||
const projects = (x.data ?? [])
|
||||
.filter((p) => !!p?.id)
|
||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||
.slice()
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
input.setGlobalStore("project", projects)
|
||||
}),
|
||||
),
|
||||
]
|
||||
|
||||
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,
|
||||
})
|
||||
input.setGlobalStore("ready", true)
|
||||
}
|
||||
|
||||
|
|
@ -111,6 +140,10 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[])
|
|||
}, {})
|
||||
}
|
||||
|
||||
function projectID(directory: string, projects: Project[]) {
|
||||
return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
|
||||
}
|
||||
|
||||
export async function bootstrapDirectory(input: {
|
||||
directory: string
|
||||
sdk: OpencodeClient
|
||||
|
|
@ -119,88 +152,130 @@ export async function bootstrapDirectory(input: {
|
|||
vcsCache: VcsCache
|
||||
loadSessions: (directory: string) => Promise<void> | void
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
}) {
|
||||
if (input.store.status !== "complete") input.setStore("status", "loading")
|
||||
|
||||
const blockingRequests = {
|
||||
project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)),
|
||||
provider: () =>
|
||||
input.sdk.provider.list().then((x) => {
|
||||
input.setStore("provider", normalizeProviderList(x.data!))
|
||||
}),
|
||||
agent: () => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? [])),
|
||||
config: () => input.sdk.config.get().then((x) => input.setStore("config", x.data!)),
|
||||
global: {
|
||||
config: Config
|
||||
project: Project[]
|
||||
provider: ProviderListResponse
|
||||
}
|
||||
}) {
|
||||
const loading = input.store.status !== "complete"
|
||||
const seededProject = projectID(input.directory, input.global.project)
|
||||
if (seededProject) input.setStore("project", seededProject)
|
||||
if (input.store.provider.all.length === 0 && input.global.provider.all.length > 0) {
|
||||
input.setStore("provider", input.global.provider)
|
||||
}
|
||||
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
|
||||
input.setStore("config", input.global.config)
|
||||
}
|
||||
if (loading) input.setStore("status", "partial")
|
||||
|
||||
try {
|
||||
await Promise.all(Object.values(blockingRequests).map((p) => retry(p)))
|
||||
} catch (err) {
|
||||
console.error("Failed to bootstrap instance", err)
|
||||
const fast = [
|
||||
() =>
|
||||
seededProject
|
||||
? Promise.resolve()
|
||||
: retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
|
||||
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? []))),
|
||||
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
|
||||
() =>
|
||||
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.session.status().then((x) => input.setStore("session_status", x.data!))),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.vcs.get().then((x) => {
|
||||
const next = x.data ?? input.store.vcs
|
||||
input.setStore("vcs", next)
|
||||
if (next?.branch) 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 grouped = groupBySession(
|
||||
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
|
||||
)
|
||||
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 grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
|
||||
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" },
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}),
|
||||
),
|
||||
]
|
||||
|
||||
const slow = [
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.provider.list().then((x) => {
|
||||
input.setStore("provider", normalizeProviderList(x.data!))
|
||||
}),
|
||||
),
|
||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||
() => retry(() => input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!))),
|
||||
() => retry(() => input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!))),
|
||||
]
|
||||
|
||||
const errs = errors(await runAll(fast))
|
||||
if (errs.length > 0) {
|
||||
console.error("Failed to bootstrap instance", errs[0])
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||
description: formatServerError(err, input.translate),
|
||||
description: formatServerError(errs[0], input.translate),
|
||||
})
|
||||
input.setStore("status", "partial")
|
||||
return
|
||||
}
|
||||
|
||||
if (input.store.status !== "complete") input.setStore("status", "partial")
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
input.sdk.path.get().then((x) => input.setStore("path", x.data!)),
|
||||
input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])),
|
||||
input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)),
|
||||
input.loadSessions(input.directory),
|
||||
input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)),
|
||||
input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)),
|
||||
input.sdk.vcs.get().then((x) => {
|
||||
const next = x.data ?? input.store.vcs
|
||||
input.setStore("vcs", next)
|
||||
if (next?.branch) input.vcsCache.setStore("value", next)
|
||||
}),
|
||||
input.sdk.permission.list().then((x) => {
|
||||
const grouped = groupBySession(
|
||||
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
|
||||
)
|
||||
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" },
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}),
|
||||
input.sdk.question.list().then((x) => {
|
||||
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
|
||||
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" },
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}),
|
||||
]).then(() => {
|
||||
input.setStore("status", "complete")
|
||||
})
|
||||
if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import type { State, VcsCache } from "./types"
|
|||
import { trimSessions } from "./session-trim"
|
||||
import { dropSessionCaches } from "./session-cache"
|
||||
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
|
||||
export function applyGlobalEvent(input: {
|
||||
event: { type: string; properties?: unknown }
|
||||
project: Project[]
|
||||
|
|
@ -211,6 +213,7 @@ export function applyDirectoryEvent(input: {
|
|||
}
|
||||
case "message.part.updated": {
|
||||
const part = (event.properties as { part: Part }).part
|
||||
if (SKIP_PARTS.has(part.type)) break
|
||||
const parts = input.store.part[part.messageID]
|
||||
if (!parts) {
|
||||
input.setStore("part", part.messageID, [part])
|
||||
|
|
|
|||
|
|
@ -1,42 +1,10 @@
|
|||
import * as i18n from "@solid-primitives/i18n"
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
import { createEffect, createMemo, createResource } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { dict as en } from "@/i18n/en"
|
||||
import { dict as zh } from "@/i18n/zh"
|
||||
import { dict as zht } from "@/i18n/zht"
|
||||
import { dict as ko } from "@/i18n/ko"
|
||||
import { dict as de } from "@/i18n/de"
|
||||
import { dict as es } from "@/i18n/es"
|
||||
import { dict as fr } from "@/i18n/fr"
|
||||
import { dict as da } from "@/i18n/da"
|
||||
import { dict as ja } from "@/i18n/ja"
|
||||
import { dict as pl } from "@/i18n/pl"
|
||||
import { dict as ru } from "@/i18n/ru"
|
||||
import { dict as ar } from "@/i18n/ar"
|
||||
import { dict as no } from "@/i18n/no"
|
||||
import { dict as br } from "@/i18n/br"
|
||||
import { dict as th } from "@/i18n/th"
|
||||
import { dict as bs } from "@/i18n/bs"
|
||||
import { dict as tr } from "@/i18n/tr"
|
||||
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
|
||||
import { dict as uiZh } from "@opencode-ai/ui/i18n/zh"
|
||||
import { dict as uiZht } from "@opencode-ai/ui/i18n/zht"
|
||||
import { dict as uiKo } from "@opencode-ai/ui/i18n/ko"
|
||||
import { dict as uiDe } from "@opencode-ai/ui/i18n/de"
|
||||
import { dict as uiEs } from "@opencode-ai/ui/i18n/es"
|
||||
import { dict as uiFr } from "@opencode-ai/ui/i18n/fr"
|
||||
import { dict as uiDa } from "@opencode-ai/ui/i18n/da"
|
||||
import { dict as uiJa } from "@opencode-ai/ui/i18n/ja"
|
||||
import { dict as uiPl } from "@opencode-ai/ui/i18n/pl"
|
||||
import { dict as uiRu } from "@opencode-ai/ui/i18n/ru"
|
||||
import { dict as uiAr } from "@opencode-ai/ui/i18n/ar"
|
||||
import { dict as uiNo } from "@opencode-ai/ui/i18n/no"
|
||||
import { dict as uiBr } from "@opencode-ai/ui/i18n/br"
|
||||
import { dict as uiTh } from "@opencode-ai/ui/i18n/th"
|
||||
import { dict as uiBs } from "@opencode-ai/ui/i18n/bs"
|
||||
import { dict as uiTr } from "@opencode-ai/ui/i18n/tr"
|
||||
|
||||
export type Locale =
|
||||
| "en"
|
||||
|
|
@ -59,6 +27,7 @@ export type Locale =
|
|||
|
||||
type RawDictionary = typeof en & typeof uiEn
|
||||
type Dictionary = i18n.Flatten<RawDictionary>
|
||||
type Source = { dict: Record<string, string> }
|
||||
|
||||
function cookie(locale: Locale) {
|
||||
return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`
|
||||
|
|
@ -125,24 +94,43 @@ const LABEL_KEY: Record<Locale, keyof Dictionary> = {
|
|||
}
|
||||
|
||||
const base = i18n.flatten({ ...en, ...uiEn })
|
||||
const DICT: Record<Locale, Dictionary> = {
|
||||
en: base,
|
||||
zh: { ...base, ...i18n.flatten({ ...zh, ...uiZh }) },
|
||||
zht: { ...base, ...i18n.flatten({ ...zht, ...uiZht }) },
|
||||
ko: { ...base, ...i18n.flatten({ ...ko, ...uiKo }) },
|
||||
de: { ...base, ...i18n.flatten({ ...de, ...uiDe }) },
|
||||
es: { ...base, ...i18n.flatten({ ...es, ...uiEs }) },
|
||||
fr: { ...base, ...i18n.flatten({ ...fr, ...uiFr }) },
|
||||
da: { ...base, ...i18n.flatten({ ...da, ...uiDa }) },
|
||||
ja: { ...base, ...i18n.flatten({ ...ja, ...uiJa }) },
|
||||
pl: { ...base, ...i18n.flatten({ ...pl, ...uiPl }) },
|
||||
ru: { ...base, ...i18n.flatten({ ...ru, ...uiRu }) },
|
||||
ar: { ...base, ...i18n.flatten({ ...ar, ...uiAr }) },
|
||||
no: { ...base, ...i18n.flatten({ ...no, ...uiNo }) },
|
||||
br: { ...base, ...i18n.flatten({ ...br, ...uiBr }) },
|
||||
th: { ...base, ...i18n.flatten({ ...th, ...uiTh }) },
|
||||
bs: { ...base, ...i18n.flatten({ ...bs, ...uiBs }) },
|
||||
tr: { ...base, ...i18n.flatten({ ...tr, ...uiTr }) },
|
||||
const dicts = new Map<Locale, Dictionary>([["en", base]])
|
||||
|
||||
const merge = (app: Promise<Source>, ui: Promise<Source>) =>
|
||||
Promise.all([app, ui]).then(([a, b]) => ({ ...base, ...i18n.flatten({ ...a.dict, ...b.dict }) }) as Dictionary)
|
||||
|
||||
const loaders: Record<Exclude<Locale, "en">, () => Promise<Dictionary>> = {
|
||||
zh: () => merge(import("@/i18n/zh"), import("@opencode-ai/ui/i18n/zh")),
|
||||
zht: () => merge(import("@/i18n/zht"), import("@opencode-ai/ui/i18n/zht")),
|
||||
ko: () => merge(import("@/i18n/ko"), import("@opencode-ai/ui/i18n/ko")),
|
||||
de: () => merge(import("@/i18n/de"), import("@opencode-ai/ui/i18n/de")),
|
||||
es: () => merge(import("@/i18n/es"), import("@opencode-ai/ui/i18n/es")),
|
||||
fr: () => merge(import("@/i18n/fr"), import("@opencode-ai/ui/i18n/fr")),
|
||||
da: () => merge(import("@/i18n/da"), import("@opencode-ai/ui/i18n/da")),
|
||||
ja: () => merge(import("@/i18n/ja"), import("@opencode-ai/ui/i18n/ja")),
|
||||
pl: () => merge(import("@/i18n/pl"), import("@opencode-ai/ui/i18n/pl")),
|
||||
ru: () => merge(import("@/i18n/ru"), import("@opencode-ai/ui/i18n/ru")),
|
||||
ar: () => merge(import("@/i18n/ar"), import("@opencode-ai/ui/i18n/ar")),
|
||||
no: () => merge(import("@/i18n/no"), import("@opencode-ai/ui/i18n/no")),
|
||||
br: () => merge(import("@/i18n/br"), import("@opencode-ai/ui/i18n/br")),
|
||||
th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")),
|
||||
bs: () => merge(import("@/i18n/bs"), import("@opencode-ai/ui/i18n/bs")),
|
||||
tr: () => merge(import("@/i18n/tr"), import("@opencode-ai/ui/i18n/tr")),
|
||||
}
|
||||
|
||||
function loadDict(locale: Locale) {
|
||||
const hit = dicts.get(locale)
|
||||
if (hit) return Promise.resolve(hit)
|
||||
if (locale === "en") return Promise.resolve(base)
|
||||
const load = loaders[locale]
|
||||
return load().then((next: Dictionary) => {
|
||||
dicts.set(locale, next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export function loadLocaleDict(locale: Locale) {
|
||||
return loadDict(locale).then(() => undefined)
|
||||
}
|
||||
|
||||
const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
|
||||
|
|
@ -168,27 +156,6 @@ const localeMatchers: Array<{ locale: Locale; match: (language: string) => boole
|
|||
{ locale: "tr", match: (language) => language.startsWith("tr") },
|
||||
]
|
||||
|
||||
type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen"
|
||||
const PARITY_CHECK: Record<Exclude<Locale, "en">, Record<ParityKey, string>> = {
|
||||
zh,
|
||||
zht,
|
||||
ko,
|
||||
de,
|
||||
es,
|
||||
fr,
|
||||
da,
|
||||
ja,
|
||||
pl,
|
||||
ru,
|
||||
ar,
|
||||
no,
|
||||
br,
|
||||
th,
|
||||
bs,
|
||||
tr,
|
||||
}
|
||||
void PARITY_CHECK
|
||||
|
||||
function detectLocale(): Locale {
|
||||
if (typeof navigator !== "object") return "en"
|
||||
|
||||
|
|
@ -203,27 +170,48 @@ function detectLocale(): Locale {
|
|||
return "en"
|
||||
}
|
||||
|
||||
function normalizeLocale(value: string): Locale {
|
||||
export function normalizeLocale(value: string): Locale {
|
||||
return LOCALES.includes(value as Locale) ? (value as Locale) : "en"
|
||||
}
|
||||
|
||||
function readStoredLocale() {
|
||||
if (typeof localStorage !== "object") return
|
||||
try {
|
||||
const raw = localStorage.getItem("opencode.global.dat:language")
|
||||
if (!raw) return
|
||||
const next = JSON.parse(raw) as { locale?: string }
|
||||
if (typeof next?.locale !== "string") return
|
||||
return normalizeLocale(next.locale)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const warm = readStoredLocale() ?? detectLocale()
|
||||
if (warm !== "en") void loadDict(warm)
|
||||
|
||||
export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
|
||||
name: "Language",
|
||||
init: () => {
|
||||
init: (props: { locale?: Locale }) => {
|
||||
const initial = props.locale ?? readStoredLocale() ?? detectLocale()
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("language", ["language.v1"]),
|
||||
createStore({
|
||||
locale: detectLocale() as Locale,
|
||||
locale: initial,
|
||||
}),
|
||||
)
|
||||
|
||||
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
|
||||
console.log("locale", locale())
|
||||
const intl = createMemo(() => INTL[locale()])
|
||||
|
||||
const dict = createMemo<Dictionary>(() => DICT[locale()])
|
||||
const [dict] = createResource(locale, loadDict, {
|
||||
initialValue: dicts.get(initial) ?? base,
|
||||
})
|
||||
|
||||
const t = i18n.translator(dict, i18n.resolveTemplate)
|
||||
const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as (
|
||||
key: keyof Dictionary,
|
||||
params?: Record<string, string | number | boolean>,
|
||||
) => string
|
||||
|
||||
const label = (value: Locale) => t(LABEL_KEY[value])
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { base64Encode } from "@opencode-ai/util/encode"
|
|||
import { decode64 } from "@/utils/base64"
|
||||
import { EventSessionError } from "@opencode-ai/sdk/v2"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { playSound, soundSrc } from "@/utils/sound"
|
||||
import { playSoundById } from "@/utils/sound"
|
||||
|
||||
type NotificationBase = {
|
||||
directory?: string
|
||||
|
|
@ -234,7 +234,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||
if (session.parentID) return
|
||||
|
||||
if (settings.sounds.agentEnabled()) {
|
||||
playSound(soundSrc(settings.sounds.agent()))
|
||||
void playSoundById(settings.sounds.agent())
|
||||
}
|
||||
|
||||
append({
|
||||
|
|
@ -263,7 +263,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||
if (session?.parentID) return
|
||||
|
||||
if (settings.sounds.errorsEnabled()) {
|
||||
playSound(soundSrc(settings.sounds.errors()))
|
||||
void playSoundById(settings.sounds.errors())
|
||||
}
|
||||
|
||||
const error = "error" in event.properties ? event.properties.error : undefined
|
||||
|
|
|
|||
|
|
@ -104,6 +104,13 @@ function withFallback<T>(read: () => T | undefined, fallback: T) {
|
|||
return createMemo(() => read() ?? fallback)
|
||||
}
|
||||
|
||||
let font: Promise<typeof import("@opencode-ai/ui/font-loader")> | undefined
|
||||
|
||||
function loadFont() {
|
||||
font ??= import("@opencode-ai/ui/font-loader")
|
||||
return font
|
||||
}
|
||||
|
||||
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
|
||||
name: "Settings",
|
||||
init: () => {
|
||||
|
|
@ -111,7 +118,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font))
|
||||
const id = store.appearance?.font ?? defaultSettings.appearance.font
|
||||
if (id !== defaultSettings.appearance.font) {
|
||||
void loadFont().then((x) => x.ensureMonoFont(id))
|
||||
}
|
||||
document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(id))
|
||||
})
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import { useSDK } from "./sdk"
|
|||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
|
||||
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
|
||||
function sortParts(parts: Part[]) {
|
||||
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
|
|
@ -178,7 +180,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||
return globalSync.child(directory)
|
||||
}
|
||||
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
||||
const messagePageSize = 200
|
||||
const initialMessagePageSize = 80
|
||||
const historyMessagePageSize = 200
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
const inflightDiff = new Map<string, Promise<void>>()
|
||||
const inflightTodo = new Map<string, Promise<void>>()
|
||||
|
|
@ -336,7 +339,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||
batch(() => {
|
||||
input.setStore("message", input.sessionID, reconcile(message, { key: "id" }))
|
||||
for (const p of next.part) {
|
||||
input.setStore("part", p.id, p.part)
|
||||
const filtered = p.part.filter((x) => !SKIP_PARTS.has(x.type))
|
||||
if (filtered.length) input.setStore("part", p.id, filtered)
|
||||
}
|
||||
setMeta("limit", key, message.length)
|
||||
setMeta("cursor", key, next.cursor)
|
||||
|
|
@ -460,7 +464,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
|
||||
if (cached && hasSession && !opts?.force) return
|
||||
|
||||
const limit = meta.limit[key] ?? messagePageSize
|
||||
const limit = meta.limit[key] ?? initialMessagePageSize
|
||||
const sessionReq =
|
||||
hasSession && !opts?.force
|
||||
? Promise.resolve()
|
||||
|
|
@ -557,7 +561,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||
const [, setStore] = globalSync.child(directory)
|
||||
touch(directory, setStore, sessionID)
|
||||
const key = keyFor(directory, sessionID)
|
||||
const step = count ?? messagePageSize
|
||||
const step = count ?? historyMessagePageSize
|
||||
if (meta.loading[key]) return
|
||||
if (meta.complete[key]) return
|
||||
const before = meta.cursor[key]
|
||||
|
|
|
|||
|
|
@ -1,45 +1,18 @@
|
|||
import { dict as ar } from "@/i18n/ar"
|
||||
import { dict as br } from "@/i18n/br"
|
||||
import { dict as bs } from "@/i18n/bs"
|
||||
import { dict as da } from "@/i18n/da"
|
||||
import { dict as de } from "@/i18n/de"
|
||||
import { dict as en } from "@/i18n/en"
|
||||
import { dict as es } from "@/i18n/es"
|
||||
import { dict as fr } from "@/i18n/fr"
|
||||
import { dict as ja } from "@/i18n/ja"
|
||||
import { dict as ko } from "@/i18n/ko"
|
||||
import { dict as no } from "@/i18n/no"
|
||||
import { dict as pl } from "@/i18n/pl"
|
||||
import { dict as ru } from "@/i18n/ru"
|
||||
import { dict as th } from "@/i18n/th"
|
||||
import { dict as tr } from "@/i18n/tr"
|
||||
import { dict as zh } from "@/i18n/zh"
|
||||
import { dict as zht } from "@/i18n/zht"
|
||||
const template = "Terminal {{number}}"
|
||||
|
||||
const numbered = Array.from(
|
||||
new Set([
|
||||
en["terminal.title.numbered"],
|
||||
ar["terminal.title.numbered"],
|
||||
br["terminal.title.numbered"],
|
||||
bs["terminal.title.numbered"],
|
||||
da["terminal.title.numbered"],
|
||||
de["terminal.title.numbered"],
|
||||
es["terminal.title.numbered"],
|
||||
fr["terminal.title.numbered"],
|
||||
ja["terminal.title.numbered"],
|
||||
ko["terminal.title.numbered"],
|
||||
no["terminal.title.numbered"],
|
||||
pl["terminal.title.numbered"],
|
||||
ru["terminal.title.numbered"],
|
||||
th["terminal.title.numbered"],
|
||||
tr["terminal.title.numbered"],
|
||||
zh["terminal.title.numbered"],
|
||||
zht["terminal.title.numbered"],
|
||||
]),
|
||||
)
|
||||
const numbered = [
|
||||
template,
|
||||
"محطة طرفية {{number}}",
|
||||
"Терминал {{number}}",
|
||||
"ターミナル {{number}}",
|
||||
"터미널 {{number}}",
|
||||
"เทอร์มินัล {{number}}",
|
||||
"终端 {{number}}",
|
||||
"終端機 {{number}}",
|
||||
]
|
||||
|
||||
export function defaultTitle(number: number) {
|
||||
return en["terminal.title.numbered"].replace("{{number}}", String(number))
|
||||
return template.replace("{{number}}", String(number))
|
||||
}
|
||||
|
||||
export function isDefaultTitle(title: string, number: number) {
|
||||
|
|
|
|||
|
|
@ -97,10 +97,15 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) {
|
|||
throw new Error(getRootNotFoundError())
|
||||
}
|
||||
|
||||
const localUrl = () =>
|
||||
`http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
||||
|
||||
const isLocalHost = () => ["localhost", "127.0.0.1", "0.0.0.0"].includes(location.hostname)
|
||||
|
||||
const getCurrentUrl = () => {
|
||||
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
||||
if (import.meta.env.DEV)
|
||||
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
||||
if (location.hostname.includes("opencode.ai")) return localUrl()
|
||||
if (import.meta.env.DEV) return localUrl()
|
||||
if (isLocalHost()) return localUrl()
|
||||
return location.origin
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export function useProviders() {
|
|||
const providers = () => {
|
||||
if (dir()) {
|
||||
const [projectStore] = globalSync.child(dir())
|
||||
return projectStore.provider
|
||||
if (projectStore.provider.all.length > 0) return projectStore.provider
|
||||
}
|
||||
return globalSync.data.provider
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ export const dict = {
|
|||
|
||||
"command.sidebar.toggle": "Toggle sidebar",
|
||||
"command.project.open": "Open project",
|
||||
"command.project.previous": "Previous project",
|
||||
"command.project.next": "Next project",
|
||||
"command.provider.connect": "Connect provider",
|
||||
"command.server.switch": "Switch server",
|
||||
"command.settings.open": "Open settings",
|
||||
|
|
@ -274,7 +276,7 @@ export const dict = {
|
|||
"prompt.context.includeActiveFile": "Include active file",
|
||||
"prompt.context.removeActiveFile": "Remove active file from context",
|
||||
"prompt.context.removeFile": "Remove file from context",
|
||||
"prompt.action.attachFile": "Add file",
|
||||
"prompt.action.attachFile": "Add files",
|
||||
"prompt.attachment.remove": "Remove attachment",
|
||||
"prompt.action.send": "Send",
|
||||
"prompt.action.stop": "Stop",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export { AppBaseProviders, AppInterface } from "./app"
|
||||
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
|
||||
export { useCommand } from "./context/command"
|
||||
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
|
||||
export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
|
||||
export { ServerConnection } from "./context/server"
|
||||
export { handleNotificationClick } from "./utils/notification-click"
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@ import { DataProvider } from "@opencode-ai/ui/context"
|
|||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { createMemo, createResource, type ParentProps, Show } from "solid-js"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { createEffect, createMemo, type ParentProps, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { LocalProvider } from "@/context/local"
|
||||
import { SDKProvider } from "@/context/sdk"
|
||||
|
|
@ -11,10 +10,18 @@ import { SyncProvider, useSync } from "@/context/sync"
|
|||
import { decode64 } from "@/utils/base64"
|
||||
|
||||
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const sync = useSync()
|
||||
const slug = createMemo(() => base64Encode(props.directory))
|
||||
|
||||
createEffect(() => {
|
||||
const next = sync.data.path.directory
|
||||
if (!next || next === props.directory) return
|
||||
const path = location.pathname.slice(slug().length + 1)
|
||||
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
|
||||
})
|
||||
|
||||
return (
|
||||
<DataProvider
|
||||
data={sync.data}
|
||||
|
|
@ -29,50 +36,31 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
|
|||
|
||||
export default function Layout(props: ParentProps) {
|
||||
const params = useParams()
|
||||
const location = useLocation()
|
||||
const language = useLanguage()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const navigate = useNavigate()
|
||||
let invalid = ""
|
||||
|
||||
const [resolved] = createResource(
|
||||
() => {
|
||||
if (params.dir) return [location.pathname, params.dir] as const
|
||||
},
|
||||
async ([pathname, b64Dir]) => {
|
||||
const directory = decode64(b64Dir)
|
||||
const resolved = createMemo(() => {
|
||||
if (!params.dir) return ""
|
||||
return decode64(params.dir) ?? ""
|
||||
})
|
||||
|
||||
if (!directory) {
|
||||
if (invalid === params.dir) return
|
||||
invalid = b64Dir
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: language.t("directory.error.invalidUrl"),
|
||||
})
|
||||
navigate("/", { replace: true })
|
||||
return
|
||||
}
|
||||
|
||||
return await globalSDK
|
||||
.createClient({
|
||||
directory,
|
||||
throwOnError: true,
|
||||
})
|
||||
.path.get()
|
||||
.then((x) => {
|
||||
const next = x.data?.directory ?? directory
|
||||
invalid = ""
|
||||
if (next === directory) return next
|
||||
const path = pathname.slice(b64Dir.length + 1)
|
||||
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
|
||||
})
|
||||
.catch(() => {
|
||||
invalid = ""
|
||||
return directory
|
||||
})
|
||||
},
|
||||
)
|
||||
createEffect(() => {
|
||||
const dir = params.dir
|
||||
if (!dir) return
|
||||
if (resolved()) {
|
||||
invalid = ""
|
||||
return
|
||||
}
|
||||
if (invalid === dir) return
|
||||
invalid = dir
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: language.t("directory.error.invalidUrl"),
|
||||
})
|
||||
navigate("/", { replace: true })
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={resolved()} keyed>
|
||||
|
|
|
|||
|
|
@ -113,6 +113,14 @@ export default function Home() {
|
|||
</ul>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={!sync.ready}>
|
||||
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
|
||||
<div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
|
||||
<Button class="px-3" onClick={chooseProject}>
|
||||
{language.t("command.project.open")}
|
||||
</Button>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
|
||||
<Icon name="folder-add-left" size="large" />
|
||||
|
|
|
|||
|
|
@ -49,21 +49,16 @@ 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 { playSound, soundSrc } from "@/utils/sound"
|
||||
import { playSoundById } from "@/utils/sound"
|
||||
import { createAim } from "@/utils/aim"
|
||||
import { setNavigate } from "@/utils/notification-click"
|
||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||
import { setSessionHandoff } from "@/pages/session/handoff"
|
||||
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
||||
import { DialogSelectProvider } from "@/components/dialog-select-provider"
|
||||
import { DialogSelectServer } from "@/components/dialog-select-server"
|
||||
import { DialogSettings } from "@/components/dialog-settings"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { useCommand, type CommandOption } from "@/context/command"
|
||||
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
||||
import { DialogEditProject } from "@/components/dialog-edit-project"
|
||||
import { DebugBar } from "@/components/debug-bar"
|
||||
import { Titlebar } from "@/components/titlebar"
|
||||
import { useServer } from "@/context/server"
|
||||
|
|
@ -110,6 +105,8 @@ export default function Layout(props: ParentProps) {
|
|||
const pageReady = createMemo(() => ready())
|
||||
|
||||
let scrollContainerRef: HTMLDivElement | undefined
|
||||
let dialogRun = 0
|
||||
let dialogDead = false
|
||||
|
||||
const params = useParams()
|
||||
const globalSDK = useGlobalSDK()
|
||||
|
|
@ -139,7 +136,7 @@ export default function Layout(props: ParentProps) {
|
|||
dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir,
|
||||
}
|
||||
})
|
||||
const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
|
||||
const availableThemeEntries = createMemo(() => theme.ids().map((id) => [id, theme.themes()[id]] as const))
|
||||
const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
|
||||
const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
|
||||
system: "theme.scheme.system",
|
||||
|
|
@ -201,6 +198,8 @@ export default function Layout(props: ParentProps) {
|
|||
})
|
||||
|
||||
onCleanup(() => {
|
||||
dialogDead = true
|
||||
dialogRun += 1
|
||||
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
|
||||
clearTimeout(sortNowTimeout)
|
||||
if (sortNowInterval) clearInterval(sortNowInterval)
|
||||
|
|
@ -211,13 +210,22 @@ export default function Layout(props: ParentProps) {
|
|||
|
||||
onMount(() => {
|
||||
const stop = () => setState("sizing", false)
|
||||
const blur = () => reset()
|
||||
const hide = () => {
|
||||
if (document.visibilityState !== "hidden") return
|
||||
reset()
|
||||
}
|
||||
window.addEventListener("pointerup", stop)
|
||||
window.addEventListener("pointercancel", stop)
|
||||
window.addEventListener("blur", stop)
|
||||
window.addEventListener("blur", blur)
|
||||
document.addEventListener("visibilitychange", hide)
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("pointerup", stop)
|
||||
window.removeEventListener("pointercancel", stop)
|
||||
window.removeEventListener("blur", stop)
|
||||
window.removeEventListener("blur", blur)
|
||||
document.removeEventListener("visibilitychange", hide)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -237,6 +245,12 @@ export default function Layout(props: ParentProps) {
|
|||
navLeave.current = undefined
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
disarm()
|
||||
setState("hoverSession", undefined)
|
||||
setHoverProject(undefined)
|
||||
}
|
||||
|
||||
const arm = () => {
|
||||
if (layout.sidebar.opened()) return
|
||||
if (state.hoverProject === undefined) return
|
||||
|
|
@ -305,8 +319,7 @@ export default function Layout(props: ParentProps) {
|
|||
|
||||
const clearSidebarHoverState = () => {
|
||||
if (layout.sidebar.opened()) return
|
||||
setState("hoverSession", undefined)
|
||||
setHoverProject(undefined)
|
||||
reset()
|
||||
}
|
||||
|
||||
const navigateWithSidebarReset = (href: string) => {
|
||||
|
|
@ -322,10 +335,9 @@ export default function Layout(props: ParentProps) {
|
|||
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length
|
||||
const nextThemeId = ids[nextIndex]
|
||||
theme.setTheme(nextThemeId)
|
||||
const nextTheme = theme.themes()[nextThemeId]
|
||||
showToast({
|
||||
title: language.t("toast.theme.title"),
|
||||
description: nextTheme?.name ?? nextThemeId,
|
||||
description: theme.name(nextThemeId),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -480,7 +492,7 @@ export default function Layout(props: ParentProps) {
|
|||
|
||||
if (e.details.type === "permission.asked") {
|
||||
if (settings.sounds.permissionsEnabled()) {
|
||||
playSound(soundSrc(settings.sounds.permissions()))
|
||||
void playSoundById(settings.sounds.permissions())
|
||||
}
|
||||
if (settings.notifications.permissions()) {
|
||||
void platform.notify(title, description, href)
|
||||
|
|
@ -936,6 +948,26 @@ export default function Layout(props: ParentProps) {
|
|||
navigateToSession(session)
|
||||
}
|
||||
|
||||
function navigateProjectByOffset(offset: number) {
|
||||
const projects = layout.projects.list()
|
||||
if (projects.length === 0) return
|
||||
|
||||
const current = currentProject()?.worktree
|
||||
const fallback = currentDir() ? projectRoot(currentDir()) : undefined
|
||||
const active = current ?? fallback
|
||||
const index = active ? projects.findIndex((project) => project.worktree === active) : -1
|
||||
|
||||
const target =
|
||||
index === -1
|
||||
? offset > 0
|
||||
? projects[0]
|
||||
: projects[projects.length - 1]
|
||||
: projects[(index + offset + projects.length) % projects.length]
|
||||
if (!target) return
|
||||
|
||||
openProject(target.worktree)
|
||||
}
|
||||
|
||||
function navigateSessionByUnseen(offset: number) {
|
||||
const sessions = currentSessions()
|
||||
if (sessions.length === 0) return
|
||||
|
|
@ -1002,6 +1034,20 @@ export default function Layout(props: ParentProps) {
|
|||
keybind: "mod+o",
|
||||
onSelect: () => chooseProject(),
|
||||
},
|
||||
{
|
||||
id: "project.previous",
|
||||
title: language.t("command.project.previous"),
|
||||
category: language.t("command.category.project"),
|
||||
keybind: "mod+alt+arrowup",
|
||||
onSelect: () => navigateProjectByOffset(-1),
|
||||
},
|
||||
{
|
||||
id: "project.next",
|
||||
title: language.t("command.project.next"),
|
||||
category: language.t("command.category.project"),
|
||||
keybind: "mod+alt+arrowdown",
|
||||
onSelect: () => navigateProjectByOffset(1),
|
||||
},
|
||||
{
|
||||
id: "provider.connect",
|
||||
title: language.t("command.provider.connect"),
|
||||
|
|
@ -1104,10 +1150,10 @@ export default function Layout(props: ParentProps) {
|
|||
},
|
||||
]
|
||||
|
||||
for (const [id, definition] of availableThemeEntries()) {
|
||||
for (const [id] of availableThemeEntries()) {
|
||||
commands.push({
|
||||
id: `theme.set.${id}`,
|
||||
title: language.t("command.theme.set", { theme: definition.name ?? id }),
|
||||
title: language.t("command.theme.set", { theme: theme.name(id) }),
|
||||
category: language.t("command.category.theme"),
|
||||
onSelect: () => theme.commitPreview(),
|
||||
onHighlight: () => {
|
||||
|
|
@ -1158,15 +1204,27 @@ export default function Layout(props: ParentProps) {
|
|||
})
|
||||
|
||||
function connectProvider() {
|
||||
dialog.show(() => <DialogSelectProvider />)
|
||||
const run = ++dialogRun
|
||||
void import("@/components/dialog-select-provider").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(() => <x.DialogSelectProvider />)
|
||||
})
|
||||
}
|
||||
|
||||
function openServer() {
|
||||
dialog.show(() => <DialogSelectServer />)
|
||||
const run = ++dialogRun
|
||||
void import("@/components/dialog-select-server").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(() => <x.DialogSelectServer />)
|
||||
})
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
dialog.show(() => <DialogSettings />)
|
||||
const run = ++dialogRun
|
||||
void import("@/components/dialog-settings").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(() => <x.DialogSettings />)
|
||||
})
|
||||
}
|
||||
|
||||
function projectRoot(directory: string) {
|
||||
|
|
@ -1393,7 +1451,13 @@ export default function Layout(props: ParentProps) {
|
|||
layout.sidebar.toggleWorkspaces(project.worktree)
|
||||
}
|
||||
|
||||
const showEditProjectDialog = (project: LocalProject) => dialog.show(() => <DialogEditProject project={project} />)
|
||||
const showEditProjectDialog = (project: LocalProject) => {
|
||||
const run = ++dialogRun
|
||||
void import("@/components/dialog-edit-project").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(() => <x.DialogEditProject project={project} />)
|
||||
})
|
||||
}
|
||||
|
||||
async function chooseProject() {
|
||||
function resolve(result: string | string[] | null) {
|
||||
|
|
@ -1414,10 +1478,14 @@ export default function Layout(props: ParentProps) {
|
|||
})
|
||||
resolve(result)
|
||||
} else {
|
||||
dialog.show(
|
||||
() => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
|
||||
() => resolve(null),
|
||||
)
|
||||
const run = ++dialogRun
|
||||
void import("@/components/dialog-select-directory").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(
|
||||
() => <x.DialogSelectDirectory multiple={true} onSelect={resolve} />,
|
||||
() => resolve(null),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1750,6 +1818,9 @@ export default function Layout(props: ParentProps) {
|
|||
document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
|
||||
})
|
||||
|
||||
const side = createMemo(() => Math.max(layout.sidebar.width(), 244))
|
||||
const panel = createMemo(() => Math.max(side() - 64, 0))
|
||||
|
||||
const loadedSessionDirs = new Set<string>()
|
||||
|
||||
createEffect(
|
||||
|
|
@ -1941,6 +2012,10 @@ export default function Layout(props: ParentProps) {
|
|||
onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event),
|
||||
onProjectMouseLeave: (worktree) => aim.leave(worktree),
|
||||
onProjectFocus: (worktree) => aim.activate(worktree),
|
||||
onHoverOpenChanged: (worktree, hoverOpen) => {
|
||||
if (!hoverOpen && state.hoverProject && state.hoverProject !== worktree) return
|
||||
setState("hoverProject", hoverOpen ? worktree : undefined)
|
||||
},
|
||||
navigateToProject,
|
||||
openSidebar: () => layout.sidebar.open(),
|
||||
closeProject,
|
||||
|
|
@ -2022,7 +2097,7 @@ export default function Layout(props: ParentProps) {
|
|||
"max-w-full overflow-hidden": panelProps.mobile,
|
||||
}}
|
||||
style={{
|
||||
width: panelProps.mobile ? undefined : `${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px`,
|
||||
width: panelProps.mobile ? undefined : `${panel()}px`,
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
|
|
@ -2085,9 +2160,11 @@ export default function Layout(props: ParentProps) {
|
|||
variant="ghost"
|
||||
data-action="project-menu"
|
||||
data-project={slug()}
|
||||
class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
||||
class="shrink-0 size-6 rounded-md transition-opacity data-[expanded]:bg-surface-base-active"
|
||||
classList={{
|
||||
"opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile,
|
||||
"opacity-100": panelProps.mobile || merged(),
|
||||
"opacity-0 group-hover/project:opacity-100 group-focus-within/project:opacity-100 data-[expanded]:opacity-100":
|
||||
!panelProps.mobile && !merged(),
|
||||
}}
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
/>
|
||||
|
|
@ -2312,7 +2389,7 @@ export default function Layout(props: ParentProps) {
|
|||
"absolute inset-y-0 left-0": true,
|
||||
"z-10": true,
|
||||
}}
|
||||
style={{ width: `${Math.max(layout.sidebar.width(), 244)}px` }}
|
||||
style={{ width: `${side()}px` }}
|
||||
ref={(el) => {
|
||||
setState("nav", el)
|
||||
}}
|
||||
|
|
@ -2327,26 +2404,29 @@ export default function Layout(props: ParentProps) {
|
|||
}}
|
||||
>
|
||||
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
|
||||
<Show when={layout.sidebar.opened()}>
|
||||
<div onPointerDown={() => setState("sizing", true)}>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
size={layout.sidebar.width()}
|
||||
min={244}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
||||
collapseThreshold={244}
|
||||
onResize={(w) => {
|
||||
setState("sizing", true)
|
||||
if (sizet !== undefined) clearTimeout(sizet)
|
||||
sizet = window.setTimeout(() => setState("sizing", false), 120)
|
||||
layout.sidebar.resize(w)
|
||||
}}
|
||||
onCollapse={layout.sidebar.close}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</nav>
|
||||
|
||||
<Show when={layout.sidebar.opened()}>
|
||||
<div
|
||||
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
|
||||
style={{ left: `${side()}px` }}
|
||||
onPointerDown={() => setState("sizing", true)}
|
||||
>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
size={layout.sidebar.width()}
|
||||
min={244}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
||||
onResize={(w) => {
|
||||
setState("sizing", true)
|
||||
if (sizet !== undefined) clearTimeout(sizet)
|
||||
sizet = window.setTimeout(() => setState("sizing", false), 120)
|
||||
layout.sidebar.resize(w)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
|
||||
style={{ left: "calc(4rem + 12px)" }}
|
||||
|
|
@ -2386,7 +2466,7 @@ export default function Layout(props: ParentProps) {
|
|||
!state.sizing,
|
||||
}}
|
||||
style={{
|
||||
"--main-left": layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "4rem",
|
||||
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
|
||||
}}
|
||||
>
|
||||
<main
|
||||
|
|
@ -2433,7 +2513,7 @@ export default function Layout(props: ParentProps) {
|
|||
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
|
||||
}}
|
||||
style={{ left: `calc(4rem + ${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px)` }}
|
||||
style={{ left: `calc(4rem + ${panel()}px)` }}
|
||||
>
|
||||
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ const SessionRow = (props: {
|
|||
}): JSX.Element => (
|
||||
<A
|
||||
href={`/${props.slug}/session/${props.session.id}`}
|
||||
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
|
||||
class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
|
||||
onPointerDown={props.warmPress}
|
||||
onPointerEnter={props.warmHover}
|
||||
onPointerLeave={props.cancelHoverPrefetch}
|
||||
|
|
@ -115,30 +115,26 @@ const SessionRow = (props: {
|
|||
props.clearHoverProjectSoon()
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-1 w-full">
|
||||
<div
|
||||
class="shrink-0 size-6 flex items-center justify-center"
|
||||
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
|
||||
>
|
||||
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
|
||||
<Match when={props.isWorking()}>
|
||||
<Spinner class="size-[15px]" />
|
||||
</Match>
|
||||
<Match when={props.hasPermissions()}>
|
||||
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
|
||||
</Match>
|
||||
<Match when={props.hasError()}>
|
||||
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
|
||||
</Match>
|
||||
<Match when={props.unseenCount() > 0}>
|
||||
<div class="size-1.5 rounded-full bg-text-interactive-base" />
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
|
||||
{props.session.title}
|
||||
</span>
|
||||
<div
|
||||
class="shrink-0 size-6 flex items-center justify-center"
|
||||
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
|
||||
>
|
||||
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
|
||||
<Match when={props.isWorking()}>
|
||||
<Spinner class="size-[15px]" />
|
||||
</Match>
|
||||
<Match when={props.hasPermissions()}>
|
||||
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
|
||||
</Match>
|
||||
<Match when={props.hasError()}>
|
||||
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
|
||||
</Match>
|
||||
<Match when={props.unseenCount() > 0}>
|
||||
<div class="size-1.5 rounded-full bg-text-interactive-base" />
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{props.session.title}</span>
|
||||
</A>
|
||||
)
|
||||
|
||||
|
|
@ -157,34 +153,49 @@ const SessionHoverPreview = (props: {
|
|||
messageLabel: (message: Message) => string | undefined
|
||||
onMessageSelect: (message: Message) => void
|
||||
trigger: JSX.Element
|
||||
}): JSX.Element => (
|
||||
<HoverCard
|
||||
openDelay={1000}
|
||||
closeDelay={props.sidebarHovering() ? 600 : 0}
|
||||
placement="right-start"
|
||||
gutter={16}
|
||||
shift={-2}
|
||||
trigger={props.trigger}
|
||||
open={props.hoverSession() === props.session.id}
|
||||
onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)}
|
||||
>
|
||||
<Show
|
||||
when={props.hoverReady()}
|
||||
fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
|
||||
}): JSX.Element => {
|
||||
let ref: HTMLDivElement | undefined
|
||||
|
||||
return (
|
||||
<HoverCard
|
||||
openDelay={1000}
|
||||
closeDelay={props.sidebarHovering() ? 600 : 0}
|
||||
placement="right-start"
|
||||
gutter={16}
|
||||
shift={-2}
|
||||
trigger={
|
||||
<div ref={ref} class="min-w-0 w-full">
|
||||
{props.trigger}
|
||||
</div>
|
||||
}
|
||||
open={props.hoverSession() === props.session.id}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
props.setHoverSession(undefined)
|
||||
return
|
||||
}
|
||||
if (!ref?.matches(":hover")) return
|
||||
props.setHoverSession(props.session.id)
|
||||
}}
|
||||
>
|
||||
<div class="overflow-y-auto overflow-x-hidden max-h-72 h-full">
|
||||
<MessageNav
|
||||
messages={props.hoverMessages() ?? []}
|
||||
current={undefined}
|
||||
getLabel={props.messageLabel}
|
||||
onMessageSelect={props.onMessageSelect}
|
||||
size="normal"
|
||||
class="w-60"
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</HoverCard>
|
||||
)
|
||||
<Show
|
||||
when={props.hoverReady()}
|
||||
fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
|
||||
>
|
||||
<div class="overflow-y-auto overflow-x-hidden max-h-72 h-full">
|
||||
<MessageNav
|
||||
messages={props.hoverMessages() ?? []}
|
||||
current={undefined}
|
||||
getLabel={props.messageLabel}
|
||||
onMessageSelect={props.onMessageSelect}
|
||||
size="normal"
|
||||
class="w-60"
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</HoverCard>
|
||||
)
|
||||
}
|
||||
|
||||
export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||
const params = useParams()
|
||||
|
|
@ -298,62 +309,71 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
|||
return (
|
||||
<div
|
||||
data-session-id={props.session.id}
|
||||
class="group/session relative w-full rounded-md cursor-default pl-2 pr-3 transition-colors
|
||||
class="group/session relative w-full min-w-0 rounded-md cursor-default pl-2 pr-3 transition-colors
|
||||
hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
|
||||
>
|
||||
<Show
|
||||
when={hoverEnabled()}
|
||||
fallback={
|
||||
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
|
||||
{item}
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<SessionHoverPreview
|
||||
mobile={props.mobile}
|
||||
nav={props.nav}
|
||||
hoverSession={props.hoverSession}
|
||||
session={props.session}
|
||||
sidebarHovering={props.sidebarHovering}
|
||||
hoverReady={hoverReady}
|
||||
hoverMessages={hoverMessages}
|
||||
language={language}
|
||||
isActive={isActive}
|
||||
slug={props.slug}
|
||||
setHoverSession={props.setHoverSession}
|
||||
messageLabel={messageLabel}
|
||||
onMessageSelect={(message) => {
|
||||
if (!isActive())
|
||||
layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id)
|
||||
<div class="flex min-w-0 items-center gap-1">
|
||||
<div class="min-w-0 flex-1">
|
||||
<Show
|
||||
when={hoverEnabled()}
|
||||
fallback={
|
||||
<Tooltip
|
||||
placement={props.mobile ? "bottom" : "right"}
|
||||
value={props.session.title}
|
||||
gutter={10}
|
||||
class="min-w-0 w-full"
|
||||
>
|
||||
{item}
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<SessionHoverPreview
|
||||
mobile={props.mobile}
|
||||
nav={props.nav}
|
||||
hoverSession={props.hoverSession}
|
||||
session={props.session}
|
||||
sidebarHovering={props.sidebarHovering}
|
||||
hoverReady={hoverReady}
|
||||
hoverMessages={hoverMessages}
|
||||
language={language}
|
||||
isActive={isActive}
|
||||
slug={props.slug}
|
||||
setHoverSession={props.setHoverSession}
|
||||
messageLabel={messageLabel}
|
||||
onMessageSelect={(message) => {
|
||||
if (!isActive())
|
||||
layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id)
|
||||
|
||||
navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`)
|
||||
navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`)
|
||||
}}
|
||||
trigger={item}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="shrink-0 overflow-hidden transition-[width,opacity]"
|
||||
classList={{
|
||||
"w-6 opacity-100 pointer-events-auto": !!props.mobile,
|
||||
"w-0 opacity-0 pointer-events-none": !props.mobile,
|
||||
"group-hover/session:w-6 group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
|
||||
"group-focus-within/session:w-6 group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
|
||||
}}
|
||||
trigger={item}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`}
|
||||
classList={{
|
||||
"opacity-100 pointer-events-auto": !!props.mobile,
|
||||
"opacity-0 pointer-events-none": !props.mobile,
|
||||
"group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
|
||||
"group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
|
||||
}}
|
||||
>
|
||||
<Tooltip value={language.t("common.archive")} placement="top">
|
||||
<IconButton
|
||||
icon="archive"
|
||||
variant="ghost"
|
||||
class="size-6 rounded-md"
|
||||
aria-label={language.t("common.archive")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void props.archiveSession(props.session)
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
>
|
||||
<Tooltip value={language.t("common.archive")} placement="top">
|
||||
<IconButton
|
||||
icon="archive"
|
||||
variant="ghost"
|
||||
class="size-6 rounded-md"
|
||||
aria-label={language.t("common.archive")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void props.archiveSession(props.session)
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -375,30 +395,26 @@ export const NewSessionItem = (props: {
|
|||
<A
|
||||
href={`/${props.slug}/session`}
|
||||
end
|
||||
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
|
||||
class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
|
||||
onClick={() => {
|
||||
props.setHoverSession(undefined)
|
||||
if (layout.sidebar.opened()) return
|
||||
props.clearHoverProjectSoon()
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-1 w-full">
|
||||
<div class="shrink-0 size-6 flex items-center justify-center">
|
||||
<Icon name="new-session" size="small" class="text-icon-weak" />
|
||||
</div>
|
||||
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
|
||||
{label}
|
||||
</span>
|
||||
<div class="shrink-0 size-6 flex items-center justify-center">
|
||||
<Icon name="new-session" size="small" class="text-icon-weak" />
|
||||
</div>
|
||||
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{label}</span>
|
||||
</A>
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3 hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active">
|
||||
<div class="group/session relative w-full min-w-0 rounded-md cursor-default transition-colors pl-2 pr-3 hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active">
|
||||
<Show
|
||||
when={!tooltip()}
|
||||
fallback={
|
||||
<Tooltip placement={props.mobile ? "bottom" : "right"} value={label} gutter={10}>
|
||||
<Tooltip placement={props.mobile ? "bottom" : "right"} value={label} gutter={10} class="min-w-0 w-full">
|
||||
{item}
|
||||
</Tooltip>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export type ProjectSidebarContext = {
|
|||
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
|
||||
onProjectMouseLeave: (worktree: string) => void
|
||||
onProjectFocus: (worktree: string) => void
|
||||
onHoverOpenChanged: (worktree: string, hovered: boolean) => void
|
||||
navigateToProject: (directory: string) => void
|
||||
openSidebar: () => void
|
||||
closeProject: (directory: string) => void
|
||||
|
|
@ -109,8 +110,14 @@ const ProjectTile = (props: {
|
|||
"bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(),
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button === 0 && !event.ctrlKey) {
|
||||
props.setOpen(false)
|
||||
props.setSuppressHover(true)
|
||||
return
|
||||
}
|
||||
if (!props.overlay()) return
|
||||
if (event.button !== 2 && !(event.button === 0 && event.ctrlKey)) return
|
||||
props.setOpen(false)
|
||||
props.setSuppressHover(true)
|
||||
event.preventDefault()
|
||||
}}
|
||||
|
|
@ -130,12 +137,11 @@ const ProjectTile = (props: {
|
|||
props.onProjectFocus(props.project.worktree)
|
||||
}}
|
||||
onClick={() => {
|
||||
props.setOpen(false)
|
||||
if (props.selected()) {
|
||||
props.setSuppressHover(true)
|
||||
layout.sidebar.toggle()
|
||||
return
|
||||
}
|
||||
props.setSuppressHover(false)
|
||||
props.navigateToProject(props.project.worktree)
|
||||
}}
|
||||
onBlur={() => props.setOpen(false)}
|
||||
|
|
@ -192,7 +198,6 @@ const ProjectPreviewPanel = (props: {
|
|||
projectChildren: Accessor<Map<string, string[]>>
|
||||
workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions>
|
||||
workspaceChildren: (directory: string) => Map<string, string[]>
|
||||
setOpen: (value: boolean) => void
|
||||
ctx: ProjectSidebarContext
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}): JSX.Element => (
|
||||
|
|
@ -259,7 +264,7 @@ const ProjectPreviewPanel = (props: {
|
|||
class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
|
||||
onClick={() => {
|
||||
props.ctx.openSidebar()
|
||||
props.setOpen(false)
|
||||
props.ctx.onHoverOpenChanged(props.project.worktree, false)
|
||||
if (props.selected()) return
|
||||
props.ctx.navigateToProject(props.project.worktree)
|
||||
}}
|
||||
|
|
@ -284,28 +289,16 @@ export const SortableProject = (props: {
|
|||
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
|
||||
const dirs = createMemo(() => props.ctx.workspaceIds(props.project))
|
||||
const [state, setState] = createStore({
|
||||
open: false,
|
||||
menu: false,
|
||||
suppressHover: false,
|
||||
})
|
||||
|
||||
const isHoverProject = () => props.ctx.hoverProject() === props.project.worktree
|
||||
const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened())
|
||||
const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened())
|
||||
const active = createMemo(
|
||||
() => state.menu || (preview() ? state.open : overlay() && props.ctx.hoverProject() === props.project.worktree),
|
||||
)
|
||||
const active = createMemo(() => state.menu || (preview() ? isHoverProject() : overlay() && isHoverProject()))
|
||||
|
||||
createEffect(() => {
|
||||
if (preview()) return
|
||||
if (!state.open) return
|
||||
setState("open", false)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!selected()) return
|
||||
if (!state.open) return
|
||||
setState("open", false)
|
||||
})
|
||||
const hoverOpen = () => isHoverProject() && preview() && !selected() && !state.menu
|
||||
|
||||
const label = (directory: string) => {
|
||||
const [data] = globalSync.child(directory, { bootstrap: false })
|
||||
|
|
@ -346,7 +339,7 @@ export const SortableProject = (props: {
|
|||
workspacesEnabled={props.ctx.workspacesEnabled}
|
||||
closeProject={props.ctx.closeProject}
|
||||
setMenu={(value) => setState("menu", value)}
|
||||
setOpen={(value) => setState("open", value)}
|
||||
setOpen={(value) => props.ctx.onHoverOpenChanged(props.project.worktree, value)}
|
||||
setSuppressHover={(value) => setState("suppressHover", value)}
|
||||
language={language}
|
||||
/>
|
||||
|
|
@ -357,7 +350,7 @@ export const SortableProject = (props: {
|
|||
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
|
||||
<Show when={preview() && !selected()} fallback={tile()}>
|
||||
<HoverCard
|
||||
open={!state.suppressHover && state.open && !state.menu}
|
||||
open={!state.suppressHover && hoverOpen() && !state.menu}
|
||||
openDelay={0}
|
||||
closeDelay={0}
|
||||
placement="right-start"
|
||||
|
|
@ -366,7 +359,7 @@ export const SortableProject = (props: {
|
|||
onOpenChange={(value) => {
|
||||
if (state.menu) return
|
||||
if (value && state.suppressHover) return
|
||||
setState("open", value)
|
||||
props.ctx.onHoverOpenChanged(props.project.worktree, value)
|
||||
if (value) props.ctx.setHoverSession(undefined)
|
||||
}}
|
||||
>
|
||||
|
|
@ -381,7 +374,6 @@ export const SortableProject = (props: {
|
|||
projectChildren={projectChildren}
|
||||
workspaceSessions={workspaceSessions}
|
||||
workspaceChildren={workspaceChildren}
|
||||
setOpen={(value) => setState("open", value)}
|
||||
ctx={props.ctx}
|
||||
language={language}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import {
|
||||
batch,
|
||||
onCleanup,
|
||||
|
|
@ -40,7 +41,13 @@ import { useSync } from "@/context/sync"
|
|||
import { useTerminal } from "@/context/terminal"
|
||||
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
|
||||
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
|
||||
import { createOpenReviewFile, createSessionTabs, createSizing, focusTerminalById } from "@/pages/session/helpers"
|
||||
import {
|
||||
createOpenReviewFile,
|
||||
createSessionTabs,
|
||||
createSizing,
|
||||
focusTerminalById,
|
||||
shouldFocusTerminalOnKeyDown,
|
||||
} from "@/pages/session/helpers"
|
||||
import { MessageTimeline } from "@/pages/session/message-timeline"
|
||||
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
|
|
@ -239,14 +246,19 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
|
|||
|
||||
if (added <= 0) return
|
||||
if (growth <= 0) return
|
||||
|
||||
if (opts?.prefetch) {
|
||||
const current = turnStart()
|
||||
preserveScroll(() => setTurnStart(current + growth))
|
||||
return
|
||||
}
|
||||
|
||||
if (turnStart() !== start) return
|
||||
|
||||
const reveal = !opts?.prefetch
|
||||
const currentRendered = renderedUserMessages().length
|
||||
const base = Math.max(beforeRendered, currentRendered)
|
||||
const target = reveal ? Math.min(afterVisible, base + turnBatch) : base
|
||||
const nextStart = Math.max(0, afterVisible - target)
|
||||
preserveScroll(() => setTurnStart(nextStart))
|
||||
const target = Math.min(afterVisible, base + turnBatch)
|
||||
preserveScroll(() => setTurnStart(Math.max(0, afterVisible - target)))
|
||||
}
|
||||
|
||||
const onScrollerScroll = () => {
|
||||
|
|
@ -327,10 +339,7 @@ export default function Page() {
|
|||
})
|
||||
|
||||
const [ui, setUi] = createStore({
|
||||
git: false,
|
||||
pendingMessage: undefined as string | undefined,
|
||||
restoring: undefined as string | undefined,
|
||||
reverting: false,
|
||||
reviewSnap: false,
|
||||
scrollGesture: 0,
|
||||
scroll: {
|
||||
|
|
@ -506,7 +515,6 @@ export default function Page() {
|
|||
|
||||
const [followup, setFollowup] = createStore({
|
||||
items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
|
||||
sending: {} as Record<string, string | undefined>,
|
||||
failed: {} as Record<string, string | undefined>,
|
||||
paused: {} as Record<string, boolean | undefined>,
|
||||
edit: {} as Record<
|
||||
|
|
@ -644,25 +652,24 @@ export default function Page() {
|
|||
globalSync.set("project", [...list, next])
|
||||
}
|
||||
|
||||
const gitMutation = useMutation(() => ({
|
||||
mutationFn: () => sdk.client.project.initGit(),
|
||||
onSuccess: (x) => {
|
||||
if (!x.data) return
|
||||
upsert(x.data)
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: formatServerError(err, language.t),
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
function initGit() {
|
||||
if (ui.git) return
|
||||
setUi("git", true)
|
||||
void sdk.client.project
|
||||
.initGit()
|
||||
.then((x) => {
|
||||
if (!x.data) return
|
||||
upsert(x.data)
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: formatServerError(err, language.t),
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
setUi("git", false)
|
||||
})
|
||||
if (gitMutation.isPending) return
|
||||
gitMutation.mutate()
|
||||
}
|
||||
|
||||
let inputRef!: HTMLDivElement
|
||||
|
|
@ -854,7 +861,7 @@ export default function Page() {
|
|||
// Prefer the open terminal over the composer when it can take focus
|
||||
if (view().terminal.opened()) {
|
||||
const id = terminal.active()
|
||||
if (id && focusTerminalById(id)) return
|
||||
if (id && shouldFocusTerminalOnKeyDown(event) && focusTerminalById(id)) return
|
||||
}
|
||||
|
||||
// Only treat explicit scroll keys as potential "user scroll" gestures.
|
||||
|
|
@ -961,8 +968,8 @@ export default function Page() {
|
|||
{language.t("session.review.noVcs.createGit.description")}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="large" disabled={ui.git} onClick={initGit}>
|
||||
{ui.git
|
||||
<Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
|
||||
{gitMutation.isPending
|
||||
? language.t("session.review.noVcs.createGit.actionLoading")
|
||||
: language.t("session.review.noVcs.createGit.action")}
|
||||
</Button>
|
||||
|
|
@ -1177,8 +1184,6 @@ export default function Page() {
|
|||
on(
|
||||
() => sdk.directory,
|
||||
() => {
|
||||
void file.tree.list("")
|
||||
|
||||
const tab = activeFileTab()
|
||||
if (!tab) return
|
||||
const path = file.pathFromTab(tab)
|
||||
|
|
@ -1379,10 +1384,40 @@ export default function Page() {
|
|||
return followup.edit[id]
|
||||
})
|
||||
|
||||
const followupMutation = useMutation(() => ({
|
||||
mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => {
|
||||
const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id)
|
||||
if (!item) return
|
||||
|
||||
if (input.manual) setFollowup("paused", input.sessionID, undefined)
|
||||
setFollowup("failed", input.sessionID, undefined)
|
||||
|
||||
const ok = await sendFollowupDraft({
|
||||
client: sdk.client,
|
||||
sync,
|
||||
globalSync,
|
||||
draft: item,
|
||||
optimisticBusy: item.sessionDirectory === sdk.directory,
|
||||
}).catch((err) => {
|
||||
setFollowup("failed", input.sessionID, input.id)
|
||||
fail(err)
|
||||
return false
|
||||
})
|
||||
if (!ok) return
|
||||
|
||||
setFollowup("items", input.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== input.id))
|
||||
if (input.manual) resumeScroll()
|
||||
},
|
||||
}))
|
||||
|
||||
const followupBusy = (sessionID: string) =>
|
||||
followupMutation.isPending && followupMutation.variables?.sessionID === sessionID
|
||||
|
||||
const sendingFollowup = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
return followup.sending[id]
|
||||
if (!followupBusy(id)) return
|
||||
return followupMutation.variables?.id
|
||||
})
|
||||
|
||||
const queueEnabled = createMemo(() => {
|
||||
|
|
@ -1422,37 +1457,15 @@ export default function Page() {
|
|||
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
|
||||
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
|
||||
if (!item) return Promise.resolve()
|
||||
if (followup.sending[sessionID]) return Promise.resolve()
|
||||
if (followupBusy(sessionID)) return Promise.resolve()
|
||||
|
||||
if (opts?.manual) setFollowup("paused", sessionID, undefined)
|
||||
setFollowup("sending", sessionID, id)
|
||||
setFollowup("failed", sessionID, undefined)
|
||||
|
||||
return sendFollowupDraft({
|
||||
client: sdk.client,
|
||||
sync,
|
||||
globalSync,
|
||||
draft: item,
|
||||
optimisticBusy: item.sessionDirectory === sdk.directory,
|
||||
})
|
||||
.then((ok) => {
|
||||
if (ok === false) return
|
||||
setFollowup("items", sessionID, (items) => (items ?? []).filter((entry) => entry.id !== id))
|
||||
if (opts?.manual) resumeScroll()
|
||||
})
|
||||
.catch((err) => {
|
||||
setFollowup("failed", sessionID, id)
|
||||
fail(err)
|
||||
})
|
||||
.finally(() => {
|
||||
setFollowup("sending", sessionID, (value) => (value === id ? undefined : value))
|
||||
})
|
||||
return followupMutation.mutateAsync({ sessionID, id, manual: opts?.manual })
|
||||
}
|
||||
|
||||
const editFollowup = (id: string) => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
if (followup.sending[sessionID]) return
|
||||
if (followupBusy(sessionID)) return
|
||||
|
||||
const item = queuedFollowups().find((entry) => entry.id === id)
|
||||
if (!item) return
|
||||
|
|
@ -1475,6 +1488,74 @@ export default function Page() {
|
|||
const halt = (sessionID: string) =>
|
||||
busy(sessionID) ? sdk.client.session.abort({ sessionID }).catch(() => {}) : Promise.resolve()
|
||||
|
||||
const revertMutation = useMutation(() => ({
|
||||
mutationFn: async (input: { sessionID: string; messageID: string }) => {
|
||||
const prev = prompt.current().slice()
|
||||
const last = info()?.revert
|
||||
const value = draft(input.messageID)
|
||||
batch(() => {
|
||||
roll(input.sessionID, { messageID: input.messageID })
|
||||
prompt.set(value)
|
||||
})
|
||||
await halt(input.sessionID)
|
||||
.then(() => sdk.client.session.revert(input))
|
||||
.then((result) => {
|
||||
if (result.data) merge(result.data)
|
||||
})
|
||||
.catch((err) => {
|
||||
batch(() => {
|
||||
roll(input.sessionID, last)
|
||||
prompt.set(prev)
|
||||
})
|
||||
fail(err)
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
const restoreMutation = useMutation(() => ({
|
||||
mutationFn: async (id: string) => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
|
||||
const next = userMessages().find((item) => item.id > id)
|
||||
const prev = prompt.current().slice()
|
||||
const last = info()?.revert
|
||||
|
||||
batch(() => {
|
||||
roll(sessionID, next ? { messageID: next.id } : undefined)
|
||||
if (next) {
|
||||
prompt.set(draft(next.id))
|
||||
return
|
||||
}
|
||||
prompt.reset()
|
||||
})
|
||||
|
||||
const task = !next
|
||||
? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID }))
|
||||
: halt(sessionID).then(() =>
|
||||
sdk.client.session.revert({
|
||||
sessionID,
|
||||
messageID: next.id,
|
||||
}),
|
||||
)
|
||||
|
||||
await task
|
||||
.then((result) => {
|
||||
if (result.data) merge(result.data)
|
||||
})
|
||||
.catch((err) => {
|
||||
batch(() => {
|
||||
roll(sessionID, last)
|
||||
prompt.set(prev)
|
||||
})
|
||||
fail(err)
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
const reverting = createMemo(() => revertMutation.isPending || restoreMutation.isPending)
|
||||
const restoring = createMemo(() => (restoreMutation.isPending ? restoreMutation.variables : undefined))
|
||||
|
||||
const fork = (input: { sessionID: string; messageID: string }) => {
|
||||
const value = draft(input.messageID)
|
||||
const dir = base64Encode(sdk.directory)
|
||||
|
|
@ -1496,77 +1577,13 @@ export default function Page() {
|
|||
}
|
||||
|
||||
const revert = (input: { sessionID: string; messageID: string }) => {
|
||||
if (ui.reverting || ui.restoring) return
|
||||
const prev = prompt.current().slice()
|
||||
const last = info()?.revert
|
||||
const value = draft(input.messageID)
|
||||
batch(() => {
|
||||
setUi("reverting", true)
|
||||
roll(input.sessionID, { messageID: input.messageID })
|
||||
prompt.set(value)
|
||||
})
|
||||
return halt(input.sessionID)
|
||||
.then(() => sdk.client.session.revert(input))
|
||||
.then((result) => {
|
||||
if (result.data) merge(result.data)
|
||||
})
|
||||
.catch((err) => {
|
||||
batch(() => {
|
||||
roll(input.sessionID, last)
|
||||
prompt.set(prev)
|
||||
})
|
||||
fail(err)
|
||||
})
|
||||
.finally(() => {
|
||||
setUi("reverting", false)
|
||||
})
|
||||
if (reverting()) return
|
||||
return revertMutation.mutateAsync(input)
|
||||
}
|
||||
|
||||
const restore = (id: string) => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID || ui.restoring || ui.reverting) return
|
||||
|
||||
const next = userMessages().find((item) => item.id > id)
|
||||
const prev = prompt.current().slice()
|
||||
const last = info()?.revert
|
||||
|
||||
batch(() => {
|
||||
setUi("restoring", id)
|
||||
setUi("reverting", true)
|
||||
roll(sessionID, next ? { messageID: next.id } : undefined)
|
||||
if (next) {
|
||||
prompt.set(draft(next.id))
|
||||
return
|
||||
}
|
||||
prompt.reset()
|
||||
})
|
||||
|
||||
const task = !next
|
||||
? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID }))
|
||||
: halt(sessionID).then(() =>
|
||||
sdk.client.session.revert({
|
||||
sessionID,
|
||||
messageID: next.id,
|
||||
}),
|
||||
)
|
||||
|
||||
return task
|
||||
.then((result) => {
|
||||
if (result.data) merge(result.data)
|
||||
})
|
||||
.catch((err) => {
|
||||
batch(() => {
|
||||
roll(sessionID, last)
|
||||
prompt.set(prev)
|
||||
})
|
||||
fail(err)
|
||||
})
|
||||
.finally(() => {
|
||||
batch(() => {
|
||||
setUi("restoring", (value) => (value === id ? undefined : value))
|
||||
setUi("reverting", false)
|
||||
})
|
||||
})
|
||||
if (!params.id || reverting()) return
|
||||
return restoreMutation.mutateAsync(id)
|
||||
}
|
||||
|
||||
const rolled = createMemo(() => {
|
||||
|
|
@ -1585,7 +1602,7 @@ export default function Page() {
|
|||
|
||||
const item = queuedFollowups()[0]
|
||||
if (!item) return
|
||||
if (followup.sending[sessionID]) return
|
||||
if (followupBusy(sessionID)) return
|
||||
if (followup.failed[sessionID] === item.id) return
|
||||
if (followup.paused[sessionID]) return
|
||||
if (composer.blocked()) return
|
||||
|
|
@ -1621,6 +1638,9 @@ export default function Page() {
|
|||
sessionID: () => params.id,
|
||||
messagesReady,
|
||||
visibleUserMessages,
|
||||
historyMore,
|
||||
historyLoading,
|
||||
loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
|
||||
turnStart: historyWindow.turnStart,
|
||||
currentMessageId: () => store.messageId,
|
||||
pendingMessage: () => ui.pendingMessage,
|
||||
|
|
@ -1692,7 +1712,7 @@ export default function Page() {
|
|||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<Switch>
|
||||
<Match when={params.id}>
|
||||
<Show when={lastUserMessage()}>
|
||||
<Show when={messagesReady()}>
|
||||
<MessageTimeline
|
||||
mobileChanges={mobileChanges()}
|
||||
mobileFallback={reviewContent({
|
||||
|
|
@ -1780,8 +1800,8 @@ export default function Page() {
|
|||
rolled().length > 0
|
||||
? {
|
||||
items: rolled(),
|
||||
restoring: ui.restoring,
|
||||
disabled: ui.reverting,
|
||||
restoring: restoring(),
|
||||
disabled: reverting(),
|
||||
onRestore: restore,
|
||||
}
|
||||
: undefined
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
|
|
@ -24,7 +25,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||
custom: cached?.custom ?? ([] as string[]),
|
||||
customOn: cached?.customOn ?? ([] as boolean[]),
|
||||
editing: false,
|
||||
sending: false,
|
||||
})
|
||||
|
||||
let root: HTMLDivElement | undefined
|
||||
|
|
@ -126,36 +126,40 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
}
|
||||
|
||||
const reply = async (answers: QuestionAnswer[]) => {
|
||||
if (store.sending) return
|
||||
|
||||
props.onSubmit()
|
||||
setStore("sending", true)
|
||||
try {
|
||||
await sdk.client.question.reply({ requestID: props.request.id, answers })
|
||||
const replyMutation = useMutation(() => ({
|
||||
mutationFn: (answers: QuestionAnswer[]) => sdk.client.question.reply({ requestID: props.request.id, answers }),
|
||||
onMutate: () => {
|
||||
props.onSubmit()
|
||||
},
|
||||
onSuccess: () => {
|
||||
replied = true
|
||||
cache.delete(props.request.id)
|
||||
} catch (err) {
|
||||
fail(err)
|
||||
} finally {
|
||||
setStore("sending", false)
|
||||
}
|
||||
},
|
||||
onError: fail,
|
||||
}))
|
||||
|
||||
const rejectMutation = useMutation(() => ({
|
||||
mutationFn: () => sdk.client.question.reject({ requestID: props.request.id }),
|
||||
onMutate: () => {
|
||||
props.onSubmit()
|
||||
},
|
||||
onSuccess: () => {
|
||||
replied = true
|
||||
cache.delete(props.request.id)
|
||||
},
|
||||
onError: fail,
|
||||
}))
|
||||
|
||||
const sending = createMemo(() => replyMutation.isPending || rejectMutation.isPending)
|
||||
|
||||
const reply = async (answers: QuestionAnswer[]) => {
|
||||
if (sending()) return
|
||||
await replyMutation.mutateAsync(answers)
|
||||
}
|
||||
|
||||
const reject = async () => {
|
||||
if (store.sending) return
|
||||
|
||||
props.onSubmit()
|
||||
setStore("sending", true)
|
||||
try {
|
||||
await sdk.client.question.reject({ requestID: props.request.id })
|
||||
replied = true
|
||||
cache.delete(props.request.id)
|
||||
} catch (err) {
|
||||
fail(err)
|
||||
} finally {
|
||||
setStore("sending", false)
|
||||
}
|
||||
if (sending()) return
|
||||
await rejectMutation.mutateAsync()
|
||||
}
|
||||
|
||||
const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? []))
|
||||
|
|
@ -175,7 +179,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||
}
|
||||
|
||||
const customToggle = () => {
|
||||
if (store.sending) return
|
||||
if (sending()) return
|
||||
|
||||
if (!multi()) {
|
||||
setStore("customOn", store.tab, true)
|
||||
|
|
@ -198,14 +202,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||
}
|
||||
|
||||
const customOpen = () => {
|
||||
if (store.sending) return
|
||||
if (sending()) return
|
||||
if (!on()) setStore("customOn", store.tab, true)
|
||||
setStore("editing", true)
|
||||
customUpdate(input(), true)
|
||||
}
|
||||
|
||||
const selectOption = (optIndex: number) => {
|
||||
if (store.sending) return
|
||||
if (sending()) return
|
||||
|
||||
if (optIndex === options().length) {
|
||||
customOpen()
|
||||
|
|
@ -227,7 +231,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||
}
|
||||
|
||||
const next = () => {
|
||||
if (store.sending) return
|
||||
if (sending()) return
|
||||
if (store.editing) commitCustom()
|
||||
|
||||
if (store.tab >= total() - 1) {
|
||||
|
|
@ -240,14 +244,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||
}
|
||||
|
||||
const back = () => {
|
||||
if (store.sending) return
|
||||
if (sending()) return
|
||||
if (store.tab <= 0) return
|
||||
setStore("tab", store.tab - 1)
|
||||
setStore("editing", false)
|
||||
}
|
||||
|
||||
const jump = (tab: number) => {
|
||||
if (store.sending) return
|
||||
if (sending()) return
|
||||
setStore("tab", tab)
|
||||
setStore("editing", false)
|
||||
}
|
||||
|
|
@ -270,7 +274,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||
(store.answers[i()]?.length ?? 0) > 0 ||
|
||||
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
|
||||
}
|
||||
disabled={store.sending}
|
||||
disabled={sending()}
|
||||
onClick={() => jump(i())}
|
||||
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
||||
/>
|
||||
|
|
@ -281,16 +285,16 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||
}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" size="large" disabled={store.sending} onClick={reject}>
|
||||
<Button variant="ghost" size="large" disabled={sending()} onClick={reject}>
|
||||
{language.t("ui.common.dismiss")}
|
||||
</Button>
|
||||
<div data-slot="question-footer-actions">
|
||||
<Show when={store.tab > 0}>
|
||||
<Button variant="secondary" size="large" disabled={store.sending} onClick={back}>
|
||||
<Button variant="secondary" size="large" disabled={sending()} onClick={back}>
|
||||
{language.t("ui.common.back")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={store.sending} onClick={next}>
|
||||
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={sending()} onClick={next}>
|
||||
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -311,7 +315,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||
data-picked={picked()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={picked()}
|
||||
disabled={store.sending}
|
||||
disabled={sending()}
|
||||
onClick={() => selectOption(i())}
|
||||
>
|
||||
<span data-slot="question-option-check" aria-hidden="true">
|
||||
|
|
@ -345,7 +349,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
disabled={store.sending}
|
||||
disabled={sending()}
|
||||
onClick={customOpen}
|
||||
>
|
||||
<span
|
||||
|
|
@ -377,7 +381,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
onMouseDown={(e) => {
|
||||
if (store.sending) {
|
||||
if (sending()) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
|
@ -419,7 +423,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||
placeholder={language.t("ui.question.custom.placeholder")}
|
||||
value={input()}
|
||||
rows={1}
|
||||
disabled={store.sending}
|
||||
disabled={sending()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
createSessionTabs,
|
||||
focusTerminalById,
|
||||
getTabReorderIndex,
|
||||
shouldFocusTerminalOnKeyDown,
|
||||
} from "./helpers"
|
||||
|
||||
describe("createOpenReviewFile", () => {
|
||||
|
|
@ -86,6 +87,26 @@ describe("focusTerminalById", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("shouldFocusTerminalOnKeyDown", () => {
|
||||
test("skips pure modifier keys", () => {
|
||||
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Meta", metaKey: true }))).toBe(false)
|
||||
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Control", ctrlKey: true }))).toBe(false)
|
||||
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Alt", altKey: true }))).toBe(false)
|
||||
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Shift", shiftKey: true }))).toBe(false)
|
||||
})
|
||||
|
||||
test("skips shortcut key combos", () => {
|
||||
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "c", metaKey: true }))).toBe(false)
|
||||
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "c", ctrlKey: true }))).toBe(false)
|
||||
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true }))).toBe(false)
|
||||
})
|
||||
|
||||
test("keeps plain typing focused on terminal", () => {
|
||||
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "a" }))).toBe(true)
|
||||
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "A", shiftKey: true }))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getTabReorderIndex", () => {
|
||||
test("returns target index for valid drag reorder", () => {
|
||||
expect(getTabReorderIndex(["a", "b", "c"], "a", "c")).toBe(2)
|
||||
|
|
|
|||
|
|
@ -93,6 +93,13 @@ export const focusTerminalById = (id: string) => {
|
|||
return true
|
||||
}
|
||||
|
||||
const skip = new Set(["Alt", "Control", "Meta", "Shift"])
|
||||
|
||||
export const shouldFocusTerminalOnKeyDown = (event: Pick<KeyboardEvent, "key" | "ctrlKey" | "metaKey" | "altKey">) => {
|
||||
if (skip.has(event.key)) return false
|
||||
return !(event.ctrlKey || event.metaKey || event.altKey)
|
||||
}
|
||||
|
||||
export const createOpenReviewFile = (input: {
|
||||
showAllFiles: () => void
|
||||
tabForPath: (path: string) => string
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js"
|
||||
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX, createSignal } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
|
|
@ -29,6 +30,7 @@ import { useSDK } from "@/context/sdk"
|
|||
import { useSync } from "@/context/sync"
|
||||
import { messageAgentColor } from "@/utils/agent"
|
||||
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
|
||||
import { makeTimer } from "@solid-primitives/timer"
|
||||
|
||||
type MessageComment = {
|
||||
path: string
|
||||
|
|
@ -249,38 +251,21 @@ export function MessageTimeline(props: {
|
|||
const working = createMemo(() => !!pending() || sessionStatus().type !== "idle")
|
||||
const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent))
|
||||
|
||||
const [slot, setSlot] = createStore({
|
||||
open: false,
|
||||
show: false,
|
||||
fade: false,
|
||||
const [timeoutDone, setTimeoutDone] = createSignal(true)
|
||||
|
||||
const workingStatus = createMemo<"hidden" | "showing" | "hiding">((prev) => {
|
||||
if (working()) return "showing"
|
||||
if (prev === "showing" || !timeoutDone()) return "hiding"
|
||||
return "hidden"
|
||||
})
|
||||
|
||||
let f: number | undefined
|
||||
const clear = () => {
|
||||
if (f !== undefined) window.clearTimeout(f)
|
||||
f = undefined
|
||||
}
|
||||
createEffect(() => {
|
||||
if (workingStatus() !== "hiding") return
|
||||
|
||||
setTimeoutDone(false)
|
||||
makeTimer(() => setTimeoutDone(true), 260, setTimeout)
|
||||
})
|
||||
|
||||
onCleanup(clear)
|
||||
createEffect(
|
||||
on(
|
||||
working,
|
||||
(on, prev) => {
|
||||
clear()
|
||||
if (on) {
|
||||
setSlot({ open: true, show: true, fade: false })
|
||||
return
|
||||
}
|
||||
if (prev) {
|
||||
setSlot({ open: false, show: true, fade: true })
|
||||
f = window.setTimeout(() => setSlot({ show: false, fade: false }), 260)
|
||||
return
|
||||
}
|
||||
setSlot({ open: false, show: false, fade: false })
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
const activeMessageID = createMemo(() => {
|
||||
const parentID = pending()?.parentID
|
||||
if (parentID) {
|
||||
|
|
@ -321,7 +306,6 @@ export function MessageTimeline(props: {
|
|||
const [title, setTitle] = createStore({
|
||||
draft: "",
|
||||
editing: false,
|
||||
saving: false,
|
||||
menuOpen: false,
|
||||
pendingRename: false,
|
||||
pendingShare: false,
|
||||
|
|
@ -335,38 +319,6 @@ export function MessageTimeline(props: {
|
|||
|
||||
let more: HTMLButtonElement | undefined
|
||||
|
||||
const [req, setReq] = createStore({ share: false, unshare: false })
|
||||
|
||||
const shareSession = () => {
|
||||
const id = sessionID()
|
||||
if (!id || req.share) return
|
||||
if (!shareEnabled()) return
|
||||
setReq("share", true)
|
||||
globalSDK.client.session
|
||||
.share({ sessionID: id, directory: sdk.directory })
|
||||
.catch((err: unknown) => {
|
||||
console.error("Failed to share session", err)
|
||||
})
|
||||
.finally(() => {
|
||||
setReq("share", false)
|
||||
})
|
||||
}
|
||||
|
||||
const unshareSession = () => {
|
||||
const id = sessionID()
|
||||
if (!id || req.unshare) return
|
||||
if (!shareEnabled()) return
|
||||
setReq("unshare", true)
|
||||
globalSDK.client.session
|
||||
.unshare({ sessionID: id, directory: sdk.directory })
|
||||
.catch((err: unknown) => {
|
||||
console.error("Failed to unshare session", err)
|
||||
})
|
||||
.finally(() => {
|
||||
setReq("unshare", false)
|
||||
})
|
||||
}
|
||||
|
||||
const viewShare = () => {
|
||||
const url = shareUrl()
|
||||
if (!url) return
|
||||
|
|
@ -382,6 +334,54 @@ export function MessageTimeline(props: {
|
|||
return language.t("common.requestFailed")
|
||||
}
|
||||
|
||||
const shareMutation = useMutation(() => ({
|
||||
mutationFn: (id: string) => globalSDK.client.session.share({ sessionID: id, directory: sdk.directory }),
|
||||
onError: (err) => {
|
||||
console.error("Failed to share session", err)
|
||||
},
|
||||
}))
|
||||
|
||||
const unshareMutation = useMutation(() => ({
|
||||
mutationFn: (id: string) => globalSDK.client.session.unshare({ sessionID: id, directory: sdk.directory }),
|
||||
onError: (err) => {
|
||||
console.error("Failed to unshare session", err)
|
||||
},
|
||||
}))
|
||||
|
||||
const titleMutation = useMutation(() => ({
|
||||
mutationFn: (input: { id: string; title: string }) =>
|
||||
sdk.client.session.update({ sessionID: input.id, title: input.title }),
|
||||
onSuccess: (_, input) => {
|
||||
sync.set(
|
||||
produce((draft) => {
|
||||
const index = draft.session.findIndex((s) => s.id === input.id)
|
||||
if (index !== -1) draft.session[index].title = input.title
|
||||
}),
|
||||
)
|
||||
setTitle("editing", false)
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
const shareSession = () => {
|
||||
const id = sessionID()
|
||||
if (!id || shareMutation.isPending) return
|
||||
if (!shareEnabled()) return
|
||||
shareMutation.mutate(id)
|
||||
}
|
||||
|
||||
const unshareSession = () => {
|
||||
const id = sessionID()
|
||||
if (!id || unshareMutation.isPending) return
|
||||
if (!shareEnabled()) return
|
||||
unshareMutation.mutate(id)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
sessionKey,
|
||||
|
|
@ -389,7 +389,6 @@ export function MessageTimeline(props: {
|
|||
setTitle({
|
||||
draft: "",
|
||||
editing: false,
|
||||
saving: false,
|
||||
menuOpen: false,
|
||||
pendingRename: false,
|
||||
pendingShare: false,
|
||||
|
|
@ -408,40 +407,22 @@ export function MessageTimeline(props: {
|
|||
}
|
||||
|
||||
const closeTitleEditor = () => {
|
||||
if (title.saving) return
|
||||
setTitle({ editing: false, saving: false })
|
||||
if (titleMutation.isPending) return
|
||||
setTitle("editing", false)
|
||||
}
|
||||
|
||||
const saveTitleEditor = async () => {
|
||||
const saveTitleEditor = () => {
|
||||
const id = sessionID()
|
||||
if (!id) return
|
||||
if (title.saving) return
|
||||
if (titleMutation.isPending) return
|
||||
|
||||
const next = title.draft.trim()
|
||||
if (!next || next === (titleValue() ?? "")) {
|
||||
setTitle({ editing: false, saving: false })
|
||||
setTitle("editing", false)
|
||||
return
|
||||
}
|
||||
|
||||
setTitle("saving", true)
|
||||
await sdk.client.session
|
||||
.update({ sessionID: id, title: next })
|
||||
.then(() => {
|
||||
sync.set(
|
||||
produce((draft) => {
|
||||
const index = draft.session.findIndex((s) => s.id === id)
|
||||
if (index !== -1) draft.session[index].title = next
|
||||
}),
|
||||
)
|
||||
setTitle({ editing: false, saving: false })
|
||||
})
|
||||
.catch((err) => {
|
||||
setTitle("saving", false)
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
})
|
||||
titleMutation.mutate({ id, title: next })
|
||||
}
|
||||
|
||||
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
|
||||
|
|
@ -679,17 +660,15 @@ export function MessageTimeline(props: {
|
|||
<div
|
||||
class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
||||
style={{
|
||||
width: slot.open ? "16px" : "0px",
|
||||
"margin-right": slot.open ? "8px" : "0px",
|
||||
width: working() ? "16px" : "0px",
|
||||
"margin-right": working() ? "8px" : "0px",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Show when={slot.show}>
|
||||
<Show when={workingStatus() !== "hidden"}>
|
||||
<div
|
||||
class="transition-opacity duration-200 ease-out"
|
||||
classList={{
|
||||
"opacity-0": slot.fade,
|
||||
}}
|
||||
classList={{ "opacity-0": workingStatus() === "hiding" }}
|
||||
>
|
||||
<Spinner class="size-4" style={{ color: tint() ?? "var(--icon-interactive-base)" }} />
|
||||
</div>
|
||||
|
|
@ -712,7 +691,7 @@ export function MessageTimeline(props: {
|
|||
titleRef = el
|
||||
}}
|
||||
value={title.draft}
|
||||
disabled={title.saving}
|
||||
disabled={titleMutation.isPending}
|
||||
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
|
||||
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||
|
|
@ -863,9 +842,9 @@ export function MessageTimeline(props: {
|
|||
variant="primary"
|
||||
class="w-full"
|
||||
onClick={shareSession}
|
||||
disabled={req.share}
|
||||
disabled={shareMutation.isPending}
|
||||
>
|
||||
{req.share
|
||||
{shareMutation.isPending
|
||||
? language.t("session.share.action.publishing")
|
||||
: language.t("session.share.action.publish")}
|
||||
</Button>
|
||||
|
|
@ -886,9 +865,9 @@ export function MessageTimeline(props: {
|
|||
variant="secondary"
|
||||
class="w-full shadow-none border border-border-weak-base"
|
||||
onClick={unshareSession}
|
||||
disabled={req.unshare}
|
||||
disabled={unshareMutation.isPending}
|
||||
>
|
||||
{req.unshare
|
||||
{unshareMutation.isPending
|
||||
? language.t("session.share.action.unpublishing")
|
||||
: language.t("session.share.action.unpublish")}
|
||||
</Button>
|
||||
|
|
@ -897,7 +876,7 @@ export function MessageTimeline(props: {
|
|||
variant="primary"
|
||||
class="w-full"
|
||||
onClick={viewShare}
|
||||
disabled={req.unshare}
|
||||
disabled={unshareMutation.isPending}
|
||||
>
|
||||
{language.t("session.share.action.view")}
|
||||
</Button>
|
||||
|
|
@ -915,7 +894,6 @@ export function MessageTimeline(props: {
|
|||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
role="log"
|
||||
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
|
||||
|
|
@ -945,7 +923,15 @@ export function MessageTimeline(props: {
|
|||
{(messageID) => {
|
||||
const active = createMemo(() => activeMessageID() === messageID)
|
||||
const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
|
||||
equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
|
||||
equals: (a, b) =>
|
||||
a.length === b.length &&
|
||||
a.every(
|
||||
(c, i) =>
|
||||
c.path === b[i].path &&
|
||||
c.comment === b[i].comment &&
|
||||
c.selection?.startLine === b[i].selection?.startLine &&
|
||||
c.selection?.endLine === b[i].selection?.endLine,
|
||||
),
|
||||
})
|
||||
const commentCount = createMemo(() => comments().length)
|
||||
return (
|
||||
|
|
@ -1001,6 +987,7 @@ export function MessageTimeline(props: {
|
|||
<SessionTurn
|
||||
sessionID={sessionID() ?? ""}
|
||||
messageID={messageID}
|
||||
messages={sessionMessages()}
|
||||
actions={props.actions}
|
||||
active={active()}
|
||||
status={active() ? sessionStatus() : undefined}
|
||||
|
|
|
|||
|
|
@ -438,12 +438,10 @@ export function SessionSidePanel(props: {
|
|||
size={layout.fileTree.width()}
|
||||
min={200}
|
||||
max={480}
|
||||
collapseThreshold={160}
|
||||
onResize={(width) => {
|
||||
props.size.touch()
|
||||
layout.fileTree.resize(width)
|
||||
}}
|
||||
onCollapse={layout.fileTree.close}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -255,7 +255,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||
id: "file.open",
|
||||
title: language.t("command.file.open"),
|
||||
description: language.t("palette.search.placeholder"),
|
||||
keybind: "mod+p",
|
||||
keybind: "mod+k,mod+p",
|
||||
slash: "open",
|
||||
onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ export const useSessionHashScroll = (input: {
|
|||
sessionID: () => string | undefined
|
||||
messagesReady: () => boolean
|
||||
visibleUserMessages: () => UserMessage[]
|
||||
historyMore: () => boolean
|
||||
historyLoading: () => boolean
|
||||
loadMore: (sessionID: string) => Promise<void>
|
||||
turnStart: () => number
|
||||
currentMessageId: () => string | undefined
|
||||
pendingMessage: () => string | undefined
|
||||
|
|
@ -181,6 +184,21 @@ export const useSessionHashScroll = (input: {
|
|||
queue(() => scrollToMessage(msg, "auto"))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const sessionID = input.sessionID()
|
||||
if (!sessionID || !input.messagesReady()) return
|
||||
|
||||
visibleUserMessages()
|
||||
|
||||
let targetId = input.pendingMessage()
|
||||
if (!targetId && !clearing) targetId = messageIdFromHash(location.hash)
|
||||
if (!targetId) return
|
||||
if (messageById().has(targetId)) return
|
||||
if (!input.historyMore() || input.historyLoading()) return
|
||||
|
||||
void input.loadMore(sessionID)
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
|
||||
window.history.scrollRestoration = "manual"
|
||||
|
|
|
|||
44
packages/app/src/utils/prompt.test.ts
Normal file
44
packages/app/src/utils/prompt.test.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import type { Part } from "@opencode-ai/sdk/v2"
|
||||
import { extractPromptFromParts } from "./prompt"
|
||||
|
||||
describe("extractPromptFromParts", () => {
|
||||
test("restores multiple uploaded attachments", () => {
|
||||
const parts = [
|
||||
{
|
||||
id: "text_1",
|
||||
type: "text",
|
||||
text: "check these",
|
||||
sessionID: "ses_1",
|
||||
messageID: "msg_1",
|
||||
},
|
||||
{
|
||||
id: "file_1",
|
||||
type: "file",
|
||||
mime: "image/png",
|
||||
url: "data:image/png;base64,AAA",
|
||||
filename: "a.png",
|
||||
sessionID: "ses_1",
|
||||
messageID: "msg_1",
|
||||
},
|
||||
{
|
||||
id: "file_2",
|
||||
type: "file",
|
||||
mime: "application/pdf",
|
||||
url: "data:application/pdf;base64,BBB",
|
||||
filename: "b.pdf",
|
||||
sessionID: "ses_1",
|
||||
messageID: "msg_1",
|
||||
},
|
||||
] satisfies Part[]
|
||||
|
||||
const result = extractPromptFromParts(parts)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0]).toMatchObject({ type: "text", content: "check these" })
|
||||
expect(result.slice(1)).toMatchObject([
|
||||
{ type: "image", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
|
||||
{ type: "image", filename: "b.pdf", mime: "application/pdf", dataUrl: "data:application/pdf;base64,BBB" },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -14,6 +14,15 @@ interface CheckServerHealthOptions {
|
|||
const defaultTimeoutMs = 3000
|
||||
const defaultRetryCount = 2
|
||||
const defaultRetryDelayMs = 100
|
||||
const cacheMs = 750
|
||||
const healthCache = new Map<
|
||||
string,
|
||||
{ at: number; done: boolean; fetch: typeof globalThis.fetch; promise: Promise<ServerHealth> }
|
||||
>()
|
||||
|
||||
function cacheKey(server: ServerConnection.HttpBase) {
|
||||
return `${server.url}\n${server.username ?? ""}\n${server.password ?? ""}`
|
||||
}
|
||||
|
||||
function timeoutSignal(timeoutMs: number) {
|
||||
const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout
|
||||
|
|
@ -87,5 +96,18 @@ export function useCheckServerHealth() {
|
|||
const platform = usePlatform()
|
||||
const fetcher = platform.fetch ?? globalThis.fetch
|
||||
|
||||
return (http: ServerConnection.HttpBase) => checkServerHealth(http, fetcher)
|
||||
return (http: ServerConnection.HttpBase) => {
|
||||
const key = cacheKey(http)
|
||||
const hit = healthCache.get(key)
|
||||
const now = Date.now()
|
||||
if (hit && hit.fetch === fetcher && (!hit.done || now - hit.at < cacheMs)) return hit.promise
|
||||
const promise = checkServerHealth(http, fetcher).finally(() => {
|
||||
const next = healthCache.get(key)
|
||||
if (!next || next.promise !== promise) return
|
||||
next.done = true
|
||||
next.at = Date.now()
|
||||
})
|
||||
healthCache.set(key, { at: now, done: false, fetch: fetcher, promise })
|
||||
return promise
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,106 +1,89 @@
|
|||
import alert01 from "@opencode-ai/ui/audio/alert-01.aac"
|
||||
import alert02 from "@opencode-ai/ui/audio/alert-02.aac"
|
||||
import alert03 from "@opencode-ai/ui/audio/alert-03.aac"
|
||||
import alert04 from "@opencode-ai/ui/audio/alert-04.aac"
|
||||
import alert05 from "@opencode-ai/ui/audio/alert-05.aac"
|
||||
import alert06 from "@opencode-ai/ui/audio/alert-06.aac"
|
||||
import alert07 from "@opencode-ai/ui/audio/alert-07.aac"
|
||||
import alert08 from "@opencode-ai/ui/audio/alert-08.aac"
|
||||
import alert09 from "@opencode-ai/ui/audio/alert-09.aac"
|
||||
import alert10 from "@opencode-ai/ui/audio/alert-10.aac"
|
||||
import bipbop01 from "@opencode-ai/ui/audio/bip-bop-01.aac"
|
||||
import bipbop02 from "@opencode-ai/ui/audio/bip-bop-02.aac"
|
||||
import bipbop03 from "@opencode-ai/ui/audio/bip-bop-03.aac"
|
||||
import bipbop04 from "@opencode-ai/ui/audio/bip-bop-04.aac"
|
||||
import bipbop05 from "@opencode-ai/ui/audio/bip-bop-05.aac"
|
||||
import bipbop06 from "@opencode-ai/ui/audio/bip-bop-06.aac"
|
||||
import bipbop07 from "@opencode-ai/ui/audio/bip-bop-07.aac"
|
||||
import bipbop08 from "@opencode-ai/ui/audio/bip-bop-08.aac"
|
||||
import bipbop09 from "@opencode-ai/ui/audio/bip-bop-09.aac"
|
||||
import bipbop10 from "@opencode-ai/ui/audio/bip-bop-10.aac"
|
||||
import nope01 from "@opencode-ai/ui/audio/nope-01.aac"
|
||||
import nope02 from "@opencode-ai/ui/audio/nope-02.aac"
|
||||
import nope03 from "@opencode-ai/ui/audio/nope-03.aac"
|
||||
import nope04 from "@opencode-ai/ui/audio/nope-04.aac"
|
||||
import nope05 from "@opencode-ai/ui/audio/nope-05.aac"
|
||||
import nope06 from "@opencode-ai/ui/audio/nope-06.aac"
|
||||
import nope07 from "@opencode-ai/ui/audio/nope-07.aac"
|
||||
import nope08 from "@opencode-ai/ui/audio/nope-08.aac"
|
||||
import nope09 from "@opencode-ai/ui/audio/nope-09.aac"
|
||||
import nope10 from "@opencode-ai/ui/audio/nope-10.aac"
|
||||
import nope11 from "@opencode-ai/ui/audio/nope-11.aac"
|
||||
import nope12 from "@opencode-ai/ui/audio/nope-12.aac"
|
||||
import staplebops01 from "@opencode-ai/ui/audio/staplebops-01.aac"
|
||||
import staplebops02 from "@opencode-ai/ui/audio/staplebops-02.aac"
|
||||
import staplebops03 from "@opencode-ai/ui/audio/staplebops-03.aac"
|
||||
import staplebops04 from "@opencode-ai/ui/audio/staplebops-04.aac"
|
||||
import staplebops05 from "@opencode-ai/ui/audio/staplebops-05.aac"
|
||||
import staplebops06 from "@opencode-ai/ui/audio/staplebops-06.aac"
|
||||
import staplebops07 from "@opencode-ai/ui/audio/staplebops-07.aac"
|
||||
import yup01 from "@opencode-ai/ui/audio/yup-01.aac"
|
||||
import yup02 from "@opencode-ai/ui/audio/yup-02.aac"
|
||||
import yup03 from "@opencode-ai/ui/audio/yup-03.aac"
|
||||
import yup04 from "@opencode-ai/ui/audio/yup-04.aac"
|
||||
import yup05 from "@opencode-ai/ui/audio/yup-05.aac"
|
||||
import yup06 from "@opencode-ai/ui/audio/yup-06.aac"
|
||||
let files: Record<string, () => Promise<string>> | undefined
|
||||
let loads: Record<SoundID, () => Promise<string>> | undefined
|
||||
|
||||
function getFiles() {
|
||||
if (files) return files
|
||||
files = import.meta.glob("../../../ui/src/assets/audio/*.aac", { import: "default" }) as Record<
|
||||
string,
|
||||
() => Promise<string>
|
||||
>
|
||||
return files
|
||||
}
|
||||
|
||||
export const SOUND_OPTIONS = [
|
||||
{ id: "alert-01", label: "sound.option.alert01", src: alert01 },
|
||||
{ id: "alert-02", label: "sound.option.alert02", src: alert02 },
|
||||
{ id: "alert-03", label: "sound.option.alert03", src: alert03 },
|
||||
{ id: "alert-04", label: "sound.option.alert04", src: alert04 },
|
||||
{ id: "alert-05", label: "sound.option.alert05", src: alert05 },
|
||||
{ id: "alert-06", label: "sound.option.alert06", src: alert06 },
|
||||
{ id: "alert-07", label: "sound.option.alert07", src: alert07 },
|
||||
{ id: "alert-08", label: "sound.option.alert08", src: alert08 },
|
||||
{ id: "alert-09", label: "sound.option.alert09", src: alert09 },
|
||||
{ id: "alert-10", label: "sound.option.alert10", src: alert10 },
|
||||
{ id: "bip-bop-01", label: "sound.option.bipbop01", src: bipbop01 },
|
||||
{ id: "bip-bop-02", label: "sound.option.bipbop02", src: bipbop02 },
|
||||
{ id: "bip-bop-03", label: "sound.option.bipbop03", src: bipbop03 },
|
||||
{ id: "bip-bop-04", label: "sound.option.bipbop04", src: bipbop04 },
|
||||
{ id: "bip-bop-05", label: "sound.option.bipbop05", src: bipbop05 },
|
||||
{ id: "bip-bop-06", label: "sound.option.bipbop06", src: bipbop06 },
|
||||
{ id: "bip-bop-07", label: "sound.option.bipbop07", src: bipbop07 },
|
||||
{ id: "bip-bop-08", label: "sound.option.bipbop08", src: bipbop08 },
|
||||
{ id: "bip-bop-09", label: "sound.option.bipbop09", src: bipbop09 },
|
||||
{ id: "bip-bop-10", label: "sound.option.bipbop10", src: bipbop10 },
|
||||
{ id: "staplebops-01", label: "sound.option.staplebops01", src: staplebops01 },
|
||||
{ id: "staplebops-02", label: "sound.option.staplebops02", src: staplebops02 },
|
||||
{ id: "staplebops-03", label: "sound.option.staplebops03", src: staplebops03 },
|
||||
{ id: "staplebops-04", label: "sound.option.staplebops04", src: staplebops04 },
|
||||
{ id: "staplebops-05", label: "sound.option.staplebops05", src: staplebops05 },
|
||||
{ id: "staplebops-06", label: "sound.option.staplebops06", src: staplebops06 },
|
||||
{ id: "staplebops-07", label: "sound.option.staplebops07", src: staplebops07 },
|
||||
{ id: "nope-01", label: "sound.option.nope01", src: nope01 },
|
||||
{ id: "nope-02", label: "sound.option.nope02", src: nope02 },
|
||||
{ id: "nope-03", label: "sound.option.nope03", src: nope03 },
|
||||
{ id: "nope-04", label: "sound.option.nope04", src: nope04 },
|
||||
{ id: "nope-05", label: "sound.option.nope05", src: nope05 },
|
||||
{ id: "nope-06", label: "sound.option.nope06", src: nope06 },
|
||||
{ id: "nope-07", label: "sound.option.nope07", src: nope07 },
|
||||
{ id: "nope-08", label: "sound.option.nope08", src: nope08 },
|
||||
{ id: "nope-09", label: "sound.option.nope09", src: nope09 },
|
||||
{ id: "nope-10", label: "sound.option.nope10", src: nope10 },
|
||||
{ id: "nope-11", label: "sound.option.nope11", src: nope11 },
|
||||
{ id: "nope-12", label: "sound.option.nope12", src: nope12 },
|
||||
{ id: "yup-01", label: "sound.option.yup01", src: yup01 },
|
||||
{ id: "yup-02", label: "sound.option.yup02", src: yup02 },
|
||||
{ id: "yup-03", label: "sound.option.yup03", src: yup03 },
|
||||
{ id: "yup-04", label: "sound.option.yup04", src: yup04 },
|
||||
{ id: "yup-05", label: "sound.option.yup05", src: yup05 },
|
||||
{ id: "yup-06", label: "sound.option.yup06", src: yup06 },
|
||||
{ id: "alert-01", label: "sound.option.alert01" },
|
||||
{ id: "alert-02", label: "sound.option.alert02" },
|
||||
{ id: "alert-03", label: "sound.option.alert03" },
|
||||
{ id: "alert-04", label: "sound.option.alert04" },
|
||||
{ id: "alert-05", label: "sound.option.alert05" },
|
||||
{ id: "alert-06", label: "sound.option.alert06" },
|
||||
{ id: "alert-07", label: "sound.option.alert07" },
|
||||
{ id: "alert-08", label: "sound.option.alert08" },
|
||||
{ id: "alert-09", label: "sound.option.alert09" },
|
||||
{ id: "alert-10", label: "sound.option.alert10" },
|
||||
{ id: "bip-bop-01", label: "sound.option.bipbop01" },
|
||||
{ id: "bip-bop-02", label: "sound.option.bipbop02" },
|
||||
{ id: "bip-bop-03", label: "sound.option.bipbop03" },
|
||||
{ id: "bip-bop-04", label: "sound.option.bipbop04" },
|
||||
{ id: "bip-bop-05", label: "sound.option.bipbop05" },
|
||||
{ id: "bip-bop-06", label: "sound.option.bipbop06" },
|
||||
{ id: "bip-bop-07", label: "sound.option.bipbop07" },
|
||||
{ id: "bip-bop-08", label: "sound.option.bipbop08" },
|
||||
{ id: "bip-bop-09", label: "sound.option.bipbop09" },
|
||||
{ id: "bip-bop-10", label: "sound.option.bipbop10" },
|
||||
{ id: "staplebops-01", label: "sound.option.staplebops01" },
|
||||
{ id: "staplebops-02", label: "sound.option.staplebops02" },
|
||||
{ id: "staplebops-03", label: "sound.option.staplebops03" },
|
||||
{ id: "staplebops-04", label: "sound.option.staplebops04" },
|
||||
{ id: "staplebops-05", label: "sound.option.staplebops05" },
|
||||
{ id: "staplebops-06", label: "sound.option.staplebops06" },
|
||||
{ id: "staplebops-07", label: "sound.option.staplebops07" },
|
||||
{ id: "nope-01", label: "sound.option.nope01" },
|
||||
{ id: "nope-02", label: "sound.option.nope02" },
|
||||
{ id: "nope-03", label: "sound.option.nope03" },
|
||||
{ id: "nope-04", label: "sound.option.nope04" },
|
||||
{ id: "nope-05", label: "sound.option.nope05" },
|
||||
{ id: "nope-06", label: "sound.option.nope06" },
|
||||
{ id: "nope-07", label: "sound.option.nope07" },
|
||||
{ id: "nope-08", label: "sound.option.nope08" },
|
||||
{ id: "nope-09", label: "sound.option.nope09" },
|
||||
{ id: "nope-10", label: "sound.option.nope10" },
|
||||
{ id: "nope-11", label: "sound.option.nope11" },
|
||||
{ id: "nope-12", label: "sound.option.nope12" },
|
||||
{ id: "yup-01", label: "sound.option.yup01" },
|
||||
{ id: "yup-02", label: "sound.option.yup02" },
|
||||
{ id: "yup-03", label: "sound.option.yup03" },
|
||||
{ id: "yup-04", label: "sound.option.yup04" },
|
||||
{ id: "yup-05", label: "sound.option.yup05" },
|
||||
{ id: "yup-06", label: "sound.option.yup06" },
|
||||
] as const
|
||||
|
||||
export type SoundOption = (typeof SOUND_OPTIONS)[number]
|
||||
export type SoundID = SoundOption["id"]
|
||||
|
||||
const soundById = Object.fromEntries(SOUND_OPTIONS.map((s) => [s.id, s.src])) as Record<SoundID, string>
|
||||
function getLoads() {
|
||||
if (loads) return loads
|
||||
loads = Object.fromEntries(
|
||||
Object.entries(getFiles()).flatMap(([path, load]) => {
|
||||
const file = path.split("/").at(-1)
|
||||
if (!file) return []
|
||||
return [[file.replace(/\.aac$/, ""), load] as const]
|
||||
}),
|
||||
) as Record<SoundID, () => Promise<string>>
|
||||
return loads
|
||||
}
|
||||
|
||||
const cache = new Map<SoundID, Promise<string | undefined>>()
|
||||
|
||||
export function soundSrc(id: string | undefined) {
|
||||
if (!id) return
|
||||
if (!(id in soundById)) return
|
||||
return soundById[id as SoundID]
|
||||
const loads = getLoads()
|
||||
if (!id || !(id in loads)) return Promise.resolve(undefined)
|
||||
const key = id as SoundID
|
||||
const hit = cache.get(key)
|
||||
if (hit) return hit
|
||||
const next = loads[key]().catch(() => undefined)
|
||||
cache.set(key, next)
|
||||
return next
|
||||
}
|
||||
|
||||
export function playSound(src: string | undefined) {
|
||||
|
|
@ -108,10 +91,12 @@ export function playSound(src: string | undefined) {
|
|||
if (!src) return
|
||||
const audio = new Audio(src)
|
||||
audio.play().catch(() => undefined)
|
||||
|
||||
// Return a cleanup function to pause the sound.
|
||||
return () => {
|
||||
audio.pause()
|
||||
audio.currentTime = 0
|
||||
}
|
||||
}
|
||||
|
||||
export function playSoundById(id: string | undefined) {
|
||||
return soundSrc(id).then((src) => playSound(src))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { readFileSync } from "node:fs"
|
||||
import solidPlugin from "vite-plugin-solid"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const theme = fileURLToPath(new URL("./public/oc-theme-preload.js", import.meta.url))
|
||||
|
||||
/**
|
||||
* @type {import("vite").PluginOption}
|
||||
*/
|
||||
|
|
@ -21,6 +24,15 @@ export default [
|
|||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "opencode-desktop:theme-preload",
|
||||
transformIndexHtml(html) {
|
||||
return html.replace(
|
||||
'<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>',
|
||||
`<script id="oc-theme-preload-script">${readFileSync(theme, "utf8")}</script>`,
|
||||
)
|
||||
},
|
||||
},
|
||||
tailwindcss(),
|
||||
solidPlugin(),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.2",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@ import { redirect } from "@solidjs/router"
|
|||
|
||||
export async function GET() {
|
||||
return redirect(
|
||||
"https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true",
|
||||
"https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true",
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,13 @@ import {
|
|||
FreeUsageLimitError,
|
||||
SubscriptionUsageLimitError,
|
||||
} from "./error"
|
||||
import { createBodyConverter, createStreamPartConverter, createResponseConverter, UsageInfo } from "./provider/provider"
|
||||
import {
|
||||
buildCostChunk,
|
||||
createBodyConverter,
|
||||
createStreamPartConverter,
|
||||
createResponseConverter,
|
||||
UsageInfo,
|
||||
} from "./provider/provider"
|
||||
import { anthropicHelper } from "./provider/anthropic"
|
||||
import { googleHelper } from "./provider/google"
|
||||
import { openaiHelper } from "./provider/openai"
|
||||
|
|
@ -90,7 +96,7 @@ export async function handler(
|
|||
const projectId = input.request.headers.get("x-opencode-project") ?? ""
|
||||
const ocClient = input.request.headers.get("x-opencode-client") ?? ""
|
||||
logger.metric({
|
||||
is_tream: isStream,
|
||||
is_stream: isStream,
|
||||
session: sessionId,
|
||||
request: requestId,
|
||||
client: ocClient,
|
||||
|
|
@ -230,7 +236,7 @@ export async function handler(
|
|||
const body = JSON.stringify(
|
||||
responseConverter({
|
||||
...json,
|
||||
cost: calculateOccuredCost(billingSource, costInfo),
|
||||
cost: calculateOccurredCost(billingSource, costInfo),
|
||||
}),
|
||||
)
|
||||
logger.metric({ response_length: body.length })
|
||||
|
|
@ -274,8 +280,8 @@ export async function handler(
|
|||
await trialLimiter?.track(usageInfo)
|
||||
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
|
||||
await reload(billingSource, authInfo, costInfo)
|
||||
const cost = calculateOccuredCost(billingSource, costInfo)
|
||||
c.enqueue(encoder.encode(usageParser.buidlCostChunk(cost)))
|
||||
const cost = calculateOccurredCost(billingSource, costInfo)
|
||||
c.enqueue(encoder.encode(buildCostChunk(opts.format, cost)))
|
||||
}
|
||||
c.close()
|
||||
return
|
||||
|
|
@ -334,6 +340,13 @@ export async function handler(
|
|||
"error.message": error.message,
|
||||
"error.cause": error.cause?.toString(),
|
||||
})
|
||||
if (error.message.startsWith("Failed query")) {
|
||||
try {
|
||||
logger.metric({
|
||||
"error.cause2": JSON.stringify(error.cause),
|
||||
})
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message.
|
||||
if (
|
||||
|
|
@ -455,12 +468,17 @@ export async function handler(
|
|||
...modelProvider,
|
||||
...zenData.providers[modelProvider.id],
|
||||
...(() => {
|
||||
const format = zenData.providers[modelProvider.id].format
|
||||
const providerProps = zenData.providers[modelProvider.id]
|
||||
const format = providerProps.format
|
||||
const providerModel = modelProvider.model
|
||||
if (format === "anthropic") return anthropicHelper({ reqModel, providerModel })
|
||||
if (format === "google") return googleHelper({ reqModel, providerModel })
|
||||
if (format === "openai") return openaiHelper({ reqModel, providerModel })
|
||||
return oaCompatHelper({ reqModel, providerModel })
|
||||
return oaCompatHelper({
|
||||
reqModel,
|
||||
providerModel,
|
||||
adjustCacheUsage: providerProps.adjustCacheUsage,
|
||||
})
|
||||
})(),
|
||||
}
|
||||
}
|
||||
|
|
@ -818,7 +836,7 @@ export async function handler(
|
|||
}
|
||||
}
|
||||
|
||||
function calculateOccuredCost(billingSource: BillingSource, costInfo: CostInfo) {
|
||||
function calculateOccurredCost(billingSource: BillingSource, costInfo: CostInfo) {
|
||||
return billingSource === "balance" ? (costInfo.totalCostInCent / 100).toFixed(8) : "0"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
|
|||
const isBedrockModelArn = providerModel.startsWith("arn:aws:bedrock:")
|
||||
const isBedrockModelID = providerModel.startsWith("global.anthropic.")
|
||||
const isBedrock = isBedrockModelArn || isBedrockModelID
|
||||
const isDatabricks = providerModel.startsWith("databricks-claude-")
|
||||
const supports1m = reqModel.includes("sonnet") || reqModel.includes("opus-4-6")
|
||||
return {
|
||||
format: "anthropic",
|
||||
|
|
@ -28,7 +29,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
|
|||
? `${providerApi}/model/${isBedrockModelArn ? encodeURIComponent(providerModel) : providerModel}/${isStream ? "invoke-with-response-stream" : "invoke"}`
|
||||
: providerApi + "/messages",
|
||||
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
|
||||
if (isBedrock) {
|
||||
if (isBedrock || isDatabricks) {
|
||||
headers.set("Authorization", `Bearer ${apiKey}`)
|
||||
} else {
|
||||
headers.set("x-api-key", apiKey)
|
||||
|
|
@ -47,9 +48,14 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
|
|||
model: undefined,
|
||||
stream: undefined,
|
||||
}
|
||||
: {
|
||||
service_tier: "standard_only",
|
||||
}),
|
||||
: isDatabricks
|
||||
? {
|
||||
anthropic_version: "bedrock-2023-05-31",
|
||||
anthropic_beta: supports1m ? ["context-1m-2025-08-07"] : undefined,
|
||||
}
|
||||
: {
|
||||
service_tier: "standard_only",
|
||||
}),
|
||||
}),
|
||||
createBinaryStreamDecoder: () => {
|
||||
if (!isBedrock) return undefined
|
||||
|
|
@ -167,7 +173,6 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
|
|||
}
|
||||
},
|
||||
retrieve: () => usage,
|
||||
buidlCostChunk: (cost: string) => `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`,
|
||||
}
|
||||
},
|
||||
normalizeUsage: (usage: Usage) => ({
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@ export const googleHelper: ProviderHelper = ({ providerModel }) => ({
|
|||
usage = json.usageMetadata
|
||||
},
|
||||
retrieve: () => usage,
|
||||
buidlCostChunk: (cost: string) => `data: ${JSON.stringify({ type: "ping", cost })}\n\n`,
|
||||
}
|
||||
},
|
||||
normalizeUsage: (usage: Usage) => {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ type Usage = {
|
|||
}
|
||||
}
|
||||
|
||||
export const oaCompatHelper: ProviderHelper = () => ({
|
||||
export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage }) => ({
|
||||
format: "oa-compat",
|
||||
modifyUrl: (providerApi: string) => providerApi + "/chat/completions",
|
||||
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
|
||||
|
|
@ -54,14 +54,18 @@ export const oaCompatHelper: ProviderHelper = () => ({
|
|||
usage = json.usage
|
||||
},
|
||||
retrieve: () => usage,
|
||||
buidlCostChunk: (cost: string) => `data: ${JSON.stringify({ choices: [], cost })}\n\n`,
|
||||
}
|
||||
},
|
||||
normalizeUsage: (usage: Usage) => {
|
||||
const inputTokens = usage.prompt_tokens ?? 0
|
||||
let inputTokens = usage.prompt_tokens ?? 0
|
||||
const outputTokens = usage.completion_tokens ?? 0
|
||||
const reasoningTokens = usage.completion_tokens_details?.reasoning_tokens ?? undefined
|
||||
const cacheReadTokens = usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? undefined
|
||||
let cacheReadTokens = usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? undefined
|
||||
|
||||
if (adjustCacheUsage && !cacheReadTokens) {
|
||||
cacheReadTokens = Math.floor(inputTokens * 0.9)
|
||||
}
|
||||
|
||||
return {
|
||||
inputTokens: inputTokens - (cacheReadTokens ?? 0),
|
||||
outputTokens,
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ export const openaiHelper: ProviderHelper = () => ({
|
|||
usage = json.response.usage
|
||||
},
|
||||
retrieve: () => usage,
|
||||
buidlCostChunk: (cost: string) => `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`,
|
||||
}
|
||||
},
|
||||
normalizeUsage: (usage: Usage) => {
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export type UsageInfo = {
|
|||
cacheWrite1hTokens?: number
|
||||
}
|
||||
|
||||
export type ProviderHelper = (input: { reqModel: string; providerModel: string }) => {
|
||||
export type ProviderHelper = (input: { reqModel: string; providerModel: string; adjustCacheUsage?: boolean }) => {
|
||||
format: ZenData.Format
|
||||
modifyUrl: (providerApi: string, isStream?: boolean) => string
|
||||
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => void
|
||||
|
|
@ -43,7 +43,6 @@ export type ProviderHelper = (input: { reqModel: string; providerModel: string }
|
|||
createUsageParser: () => {
|
||||
parse: (chunk: string) => void
|
||||
retrieve: () => any
|
||||
buidlCostChunk: (cost: string) => string
|
||||
}
|
||||
normalizeUsage: (usage: any) => UsageInfo
|
||||
}
|
||||
|
|
@ -162,6 +161,19 @@ export interface CommonChunk {
|
|||
}
|
||||
}
|
||||
|
||||
export function buildCostChunk(format: ZenData.Format, cost: string): string {
|
||||
switch (format) {
|
||||
case "anthropic":
|
||||
return `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`
|
||||
case "openai":
|
||||
return `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`
|
||||
case "oa-compat":
|
||||
return `data: ${JSON.stringify({ choices: [], cost })}\n\n`
|
||||
default:
|
||||
return `data: ${JSON.stringify({ type: "ping", cost })}\n\n`
|
||||
}
|
||||
}
|
||||
|
||||
export function createBodyConverter(from: ZenData.Format, to: ZenData.Format) {
|
||||
return (body: any): any => {
|
||||
if (from === to) return body
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
|
@ -42,7 +42,7 @@
|
|||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "catalog:",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
"@types/bun": "1.3.0",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"drizzle-kit": "catalog:",
|
||||
"mysql2": "3.14.4",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
import { Database, and, eq, sql } from "../src/drizzle/index.js"
|
||||
import { AuthTable } from "../src/schema/auth.sql.js"
|
||||
import { UserTable } from "../src/schema/user.sql.js"
|
||||
import { BillingTable, PaymentTable, SubscriptionTable, BlackPlans, UsageTable } from "../src/schema/billing.sql.js"
|
||||
import {
|
||||
BillingTable,
|
||||
PaymentTable,
|
||||
SubscriptionTable,
|
||||
BlackPlans,
|
||||
UsageTable,
|
||||
LiteTable,
|
||||
} from "../src/schema/billing.sql.js"
|
||||
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
|
||||
import { KeyTable } from "../src/schema/key.sql.js"
|
||||
import { BlackData } from "../src/black.js"
|
||||
|
|
@ -72,11 +79,13 @@ else {
|
|||
workspaceID: UserTable.workspaceID,
|
||||
workspaceName: WorkspaceTable.name,
|
||||
role: UserTable.role,
|
||||
subscribed: SubscriptionTable.timeCreated,
|
||||
black: SubscriptionTable.timeCreated,
|
||||
lite: LiteTable.timeCreated,
|
||||
})
|
||||
.from(UserTable)
|
||||
.rightJoin(WorkspaceTable, eq(WorkspaceTable.id, UserTable.workspaceID))
|
||||
.leftJoin(SubscriptionTable, eq(SubscriptionTable.userID, UserTable.id))
|
||||
.leftJoin(LiteTable, eq(LiteTable.userID, UserTable.id))
|
||||
.where(eq(UserTable.accountID, accountID))
|
||||
.then((rows) =>
|
||||
rows.map((row) => ({
|
||||
|
|
@ -84,7 +93,8 @@ else {
|
|||
workspaceID: row.workspaceID,
|
||||
workspaceName: row.workspaceName,
|
||||
role: row.role,
|
||||
subscribed: formatDate(row.subscribed),
|
||||
black: formatDate(row.black),
|
||||
lite: formatDate(row.lite),
|
||||
})),
|
||||
),
|
||||
)
|
||||
|
|
@ -151,13 +161,14 @@ async function printWorkspace(workspaceID: string) {
|
|||
balance: BillingTable.balance,
|
||||
customerID: BillingTable.customerID,
|
||||
reload: BillingTable.reload,
|
||||
subscriptionID: BillingTable.subscriptionID,
|
||||
subscription: {
|
||||
blackSubscriptionID: BillingTable.subscriptionID,
|
||||
blackSubscription: {
|
||||
plan: BillingTable.subscriptionPlan,
|
||||
booked: BillingTable.timeSubscriptionBooked,
|
||||
enrichment: BillingTable.subscription,
|
||||
},
|
||||
timeSubscriptionSelected: BillingTable.timeSubscriptionSelected,
|
||||
timeBlackSubscriptionSelected: BillingTable.timeSubscriptionSelected,
|
||||
liteSubscriptionID: BillingTable.liteSubscriptionID,
|
||||
})
|
||||
.from(BillingTable)
|
||||
.where(eq(BillingTable.workspaceID, workspace.id))
|
||||
|
|
@ -167,16 +178,21 @@ async function printWorkspace(workspaceID: string) {
|
|||
balance: `$${(row.balance / 100000000).toFixed(2)}`,
|
||||
reload: row.reload ? "yes" : "no",
|
||||
customerID: row.customerID,
|
||||
subscriptionID: row.subscriptionID,
|
||||
subscription: row.subscriptionID
|
||||
liteSubscriptionID: row.liteSubscriptionID,
|
||||
blackSubscriptionID: row.blackSubscriptionID,
|
||||
blackSubscription: row.blackSubscriptionID
|
||||
? [
|
||||
`Black ${row.subscription.enrichment!.plan}`,
|
||||
row.subscription.enrichment!.seats > 1 ? `X ${row.subscription.enrichment!.seats} seats` : "",
|
||||
row.subscription.enrichment!.coupon ? `(coupon: ${row.subscription.enrichment!.coupon})` : "",
|
||||
`(ref: ${row.subscriptionID})`,
|
||||
`Black ${row.blackSubscription.enrichment!.plan}`,
|
||||
row.blackSubscription.enrichment!.seats > 1
|
||||
? `X ${row.blackSubscription.enrichment!.seats} seats`
|
||||
: "",
|
||||
row.blackSubscription.enrichment!.coupon
|
||||
? `(coupon: ${row.blackSubscription.enrichment!.coupon})`
|
||||
: "",
|
||||
`(ref: ${row.blackSubscriptionID})`,
|
||||
].join(" ")
|
||||
: row.subscription.booked
|
||||
? `Waitlist ${row.subscription.plan} plan${row.timeSubscriptionSelected ? " (selected)" : ""}`
|
||||
: row.blackSubscription.booked
|
||||
? `Waitlist ${row.blackSubscription.plan} plan${row.timeBlackSubscriptionSelected ? " (selected)" : ""}`
|
||||
: undefined,
|
||||
}))[0],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ export namespace ZenData {
|
|||
headerMappings: z.record(z.string(), z.string()).optional(),
|
||||
payloadModifier: z.record(z.string(), z.any()).optional(),
|
||||
payloadMappings: z.record(z.string(), z.string()).optional(),
|
||||
adjustCacheUsage: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const ModelsSchema = z.object({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.2",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.2",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ FROM ${REGISTRY}/build/base:24.04
|
|||
SHELL ["/bin/bash", "-lc"]
|
||||
|
||||
ARG NODE_VERSION=24.4.0
|
||||
ARG BUN_VERSION=1.3.5
|
||||
ARG BUN_VERSION=1.3.11
|
||||
|
||||
ENV BUN_INSTALL=/opt/bun
|
||||
ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"private": true,
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.2",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export type CommandEvent =
|
|||
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }
|
||||
|
||||
export type CommandChild = {
|
||||
pid: number | undefined
|
||||
kill: () => void
|
||||
}
|
||||
|
||||
|
|
@ -191,7 +192,7 @@ export function spawnCommand(args: string, extraEnv: Record<string, string>) {
|
|||
treeKill(child.pid)
|
||||
}
|
||||
|
||||
return { events, child: { kill }, exit }
|
||||
return { events, child: { pid: child.pid, kill }, exit }
|
||||
}
|
||||
|
||||
function handleSqliteProgress(events: EventEmitter, line: string) {
|
||||
|
|
|
|||
|
|
@ -81,6 +81,17 @@ function setupApp() {
|
|||
killSidecar()
|
||||
})
|
||||
|
||||
app.on("will-quit", () => {
|
||||
killSidecar()
|
||||
})
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||
process.on(signal, () => {
|
||||
killSidecar()
|
||||
app.exit(0)
|
||||
})
|
||||
}
|
||||
|
||||
void app.whenReady().then(async () => {
|
||||
// migrate()
|
||||
app.setAsDefaultProtocolClient("opencode")
|
||||
|
|
@ -234,8 +245,15 @@ registerIpcHandlers({
|
|||
|
||||
function killSidecar() {
|
||||
if (!sidecar) return
|
||||
const pid = sidecar.pid
|
||||
sidecar.kill()
|
||||
sidecar = null
|
||||
// tree-kill is async; also send process group signal as immediate fallback
|
||||
if (pid && process.platform !== "win32") {
|
||||
try {
|
||||
process.kill(-pid, "SIGTERM")
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureLoopbackNoProxy() {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ import {
|
|||
AppBaseProviders,
|
||||
AppInterface,
|
||||
handleNotificationClick,
|
||||
loadLocaleDict,
|
||||
normalizeLocale,
|
||||
type Locale,
|
||||
type Platform,
|
||||
PlatformProvider,
|
||||
ServerConnection,
|
||||
|
|
@ -246,6 +249,17 @@ listenForDeepLinks()
|
|||
|
||||
render(() => {
|
||||
const platform = createPlatform()
|
||||
const loadLocale = async () => {
|
||||
const current = await platform.storage?.("opencode.global.dat").getItem("language")
|
||||
const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
|
||||
const raw = current ?? legacy
|
||||
if (!raw) return
|
||||
const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1]
|
||||
if (!locale) return
|
||||
const next = normalizeLocale(locale)
|
||||
if (next !== "en") await loadLocaleDict(next)
|
||||
return next satisfies Locale
|
||||
}
|
||||
|
||||
const [windowCount] = createResource(() => window.api.getWindowCount())
|
||||
|
||||
|
|
@ -257,6 +271,7 @@ render(() => {
|
|||
if (url) return ServerConnection.key({ type: "http", http: { url } })
|
||||
}),
|
||||
)
|
||||
const [locale] = createResource(loadLocale)
|
||||
|
||||
const servers = () => {
|
||||
const data = sidecar()
|
||||
|
|
@ -309,15 +324,14 @@ render(() => {
|
|||
|
||||
return (
|
||||
<PlatformProvider value={platform}>
|
||||
<AppBaseProviders>
|
||||
<Show when={!defaultServer.loading && !sidecar.loading && !windowCount.loading}>
|
||||
<AppBaseProviders locale={locale.latest}>
|
||||
<Show when={!defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading}>
|
||||
{(_) => {
|
||||
return (
|
||||
<AppInterface
|
||||
defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
|
||||
servers={servers()}
|
||||
router={MemoryRouter}
|
||||
disableHealthCheck={(windowCount() ?? 0) > 1}
|
||||
>
|
||||
<Inner />
|
||||
</AppInterface>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.2",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ import {
|
|||
AppBaseProviders,
|
||||
AppInterface,
|
||||
handleNotificationClick,
|
||||
loadLocaleDict,
|
||||
normalizeLocale,
|
||||
type Locale,
|
||||
type Platform,
|
||||
PlatformProvider,
|
||||
ServerConnection,
|
||||
|
|
@ -414,6 +417,17 @@ void listenForDeepLinks()
|
|||
|
||||
render(() => {
|
||||
const platform = createPlatform()
|
||||
const loadLocale = async () => {
|
||||
const current = await platform.storage?.("opencode.global.dat").getItem("language")
|
||||
const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
|
||||
const raw = current ?? legacy
|
||||
if (!raw) return
|
||||
const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1]
|
||||
if (!locale) return
|
||||
const next = normalizeLocale(locale)
|
||||
if (next !== "en") await loadLocaleDict(next)
|
||||
return next satisfies Locale
|
||||
}
|
||||
|
||||
// Fetch sidecar credentials from Rust (available immediately, before health check)
|
||||
const [sidecar] = createResource(() => commands.awaitInitialization(new Channel<InitStep>() as any))
|
||||
|
|
@ -423,6 +437,7 @@ render(() => {
|
|||
if (url) return ServerConnection.key({ type: "http", http: { url } })
|
||||
}),
|
||||
)
|
||||
const [locale] = createResource(loadLocale)
|
||||
|
||||
// Build the sidecar server connection once credentials arrive
|
||||
const servers = () => {
|
||||
|
|
@ -465,8 +480,8 @@ render(() => {
|
|||
|
||||
return (
|
||||
<PlatformProvider value={platform}>
|
||||
<AppBaseProviders>
|
||||
<Show when={!defaultServer.loading && !sidecar.loading}>
|
||||
<AppBaseProviders locale={locale.latest}>
|
||||
<Show when={!defaultServer.loading && !sidecar.loading && !locale.loading}>
|
||||
{(_) => {
|
||||
return (
|
||||
<AppInterface
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.2.27"
|
||||
version = "1.3.2"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
|
|
@ -11,26 +11,26 @@ name = "OpenCode"
|
|||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.2",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
|
|
|||
0
packages/opencode/git
Normal file
0
packages/opencode/git
Normal file
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.2.27",
|
||||
"version": "1.3.2",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
|
@ -91,8 +91,6 @@
|
|||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@ff-labs/fff-node": "0.4.2",
|
||||
"@gitlab/gitlab-ai-provider": "3.6.0",
|
||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
|
|
@ -104,8 +102,8 @@
|
|||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||
"@opentui/core": "0.1.87",
|
||||
"@opentui/solid": "0.1.87",
|
||||
"@opentui/core": "0.1.90",
|
||||
"@opentui/solid": "0.1.90",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
|
|
@ -123,6 +121,7 @@
|
|||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"gitlab-ai-provider": "5.3.2",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
|
|
@ -133,8 +132,10 @@
|
|||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.0.3",
|
||||
"open": "10.1.2",
|
||||
"opencode-gitlab-auth": "2.0.0",
|
||||
"opentui-spinner": "0.0.6",
|
||||
"partial-json": "0.1.7",
|
||||
"opencode-poe-auth": "0.0.1",
|
||||
"remeda": "catalog:",
|
||||
"semver": "^7.6.3",
|
||||
"solid-js": "catalog:",
|
||||
|
|
|
|||
|
|
@ -101,6 +101,14 @@ export default {
|
|||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "kotlin",
|
||||
wasm: "https://github.com/fwcd/tree-sitter-kotlin/releases/download/0.3.8/tree-sitter-kotlin.wasm",
|
||||
queries: {
|
||||
highlights: ["https://raw.githubusercontent.com/fwcd/tree-sitter-kotlin/0.3.8/queries/highlights.scm"],
|
||||
locals: ["https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/master/queries/kotlin/locals.scm"],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "ruby",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-ruby/releases/download/v0.23.1/tree-sitter-ruby.wasm",
|
||||
|
|
@ -158,6 +166,15 @@ export default {
|
|||
// },
|
||||
// },
|
||||
},
|
||||
{
|
||||
filetype: "hcl",
|
||||
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-hcl/releases/download/v1.2.0/tree-sitter-hcl.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/master/queries/hcl/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "json",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-json/releases/download/v0.24.8/tree-sitter-json.wasm",
|
||||
|
|
@ -203,6 +220,16 @@ export default {
|
|||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "lua",
|
||||
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-lua/releases/download/v0.5.0/tree-sitter-lua.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/tree-sitter-grammars/tree-sitter-lua/v0.5.0/queries/highlights.scm",
|
||||
],
|
||||
locals: ["https://raw.githubusercontent.com/tree-sitter-grammars/tree-sitter-lua/v0.5.0/queries/locals.scm"],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "ocaml",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-ocaml/releases/download/v0.24.2/tree-sitter-ocaml.wasm",
|
||||
|
|
@ -236,6 +263,15 @@ export default {
|
|||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "toml",
|
||||
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-toml/releases/download/v0.7.0/tree-sitter-toml.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/master/queries/toml/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "nix",
|
||||
// TODO: Replace with official tree-sitter-nix WASM when published
|
||||
|
|
|
|||
|
|
@ -21,10 +21,14 @@ const modelsData = process.env.MODELS_DEV_API_JSON
|
|||
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
|
||||
: await fetch(`${modelsUrl}/api.json`).then((x) => x.text())
|
||||
await Bun.write(
|
||||
path.join(dir, "src/provider/models-snapshot.ts"),
|
||||
`// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData} as const\n`,
|
||||
path.join(dir, "src/provider/models-snapshot.js"),
|
||||
`// @ts-nocheck\n// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData}\n`,
|
||||
)
|
||||
console.log("Generated models-snapshot.ts")
|
||||
await Bun.write(
|
||||
path.join(dir, "src/provider/models-snapshot.d.ts"),
|
||||
`// Auto-generated by build.ts - do not edit\nexport declare const snapshot: Record<string, unknown>\n`,
|
||||
)
|
||||
console.log("Generated models-snapshot.js")
|
||||
|
||||
// Load migrations from migration directories
|
||||
const migrationDirs = (
|
||||
|
|
@ -199,6 +203,19 @@ for (const item of targets) {
|
|||
},
|
||||
})
|
||||
|
||||
// Smoke test: only run if binary is for current platform
|
||||
if (item.os === process.platform && item.arch === process.arch && !item.abi) {
|
||||
const binaryPath = `dist/${name}/bin/opencode`
|
||||
console.log(`Running smoke test: ${binaryPath} --version`)
|
||||
try {
|
||||
const versionOutput = await $`${binaryPath} --version`.text()
|
||||
console.log(`Smoke test passed: ${versionOutput.trim()}`)
|
||||
} catch (e) {
|
||||
console.error(`Smoke test failed for ${name}:`, e)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
await $`rm -rf ./dist/${name}/bin/tui`
|
||||
await Bun.file(`dist/${name}/package.json`).write(
|
||||
JSON.stringify(
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ const seed = async () => {
|
|||
const { Instance } = await import("../src/project/instance")
|
||||
const { InstanceBootstrap } = await import("../src/project/bootstrap")
|
||||
const { Config } = await import("../src/config/config")
|
||||
const { disposeRuntime } = await import("../src/effect/runtime")
|
||||
const { Session } = await import("../src/session")
|
||||
const { MessageID, PartID } = await import("../src/session/schema")
|
||||
const { Project } = await import("../src/project/project")
|
||||
|
|
@ -55,7 +54,6 @@ const seed = async () => {
|
|||
})
|
||||
} finally {
|
||||
await Instance.disposeAll().catch(() => {})
|
||||
await disposeRuntime().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,18 +4,18 @@ Practical reference for new and migrated Effect code in `packages/opencode`.
|
|||
|
||||
## Choose scope
|
||||
|
||||
Use the shared runtime for process-wide services with one lifecycle for the whole app.
|
||||
Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need per-directory state, per-instance cleanup, or project-bound background work. InstanceState uses a `ScopedCache` keyed by directory, so each open project gets its own copy of the state that is automatically cleaned up on disposal.
|
||||
|
||||
Use `src/effect/instances.ts` for services that are created per directory or need `InstanceContext`, per-project state, or per-instance cleanup.
|
||||
Use `makeRunPromise` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`.
|
||||
|
||||
- Shared runtime: config readers, stateless helpers, global clients
|
||||
- Instance-scoped: watchers, per-project caches, session state, project-bound background work
|
||||
- Global services (no per-directory state): Account, Auth, Installation, Truncate
|
||||
- Instance-scoped (per-directory state via InstanceState): File, FileTime, FileWatcher, Format, Permission, Question, Skill, Snapshot, Vcs, ProviderAuth
|
||||
|
||||
Rule of thumb: if two open directories should not share one copy of the service, it belongs in `Instances`.
|
||||
Rule of thumb: if two open directories should not share one copy of the service, it needs `InstanceState`.
|
||||
|
||||
## Service shape
|
||||
|
||||
For a fully migrated module, use the public namespace directly:
|
||||
Every service follows the same pattern — a single namespace with the service definition, layer, `runPromise`, and async facade functions:
|
||||
|
||||
```ts
|
||||
export namespace Foo {
|
||||
|
|
@ -28,53 +28,86 @@ export namespace Foo {
|
|||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
return Service.of({
|
||||
get: Effect.fn("Foo.get")(function* (id) {
|
||||
return yield* ...
|
||||
}),
|
||||
// For instance-scoped services:
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Foo.state")(() => Effect.succeed({ ... })),
|
||||
)
|
||||
|
||||
const get = Effect.fn("Foo.get")(function* (id: FooID) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
// ...
|
||||
})
|
||||
|
||||
return Service.of({ get })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(FooRepo.defaultLayer))
|
||||
// Optional: wire dependencies
|
||||
export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
|
||||
|
||||
// Per-service runtime (inside the namespace)
|
||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
|
||||
// Async facade functions
|
||||
export async function get(id: FooID) {
|
||||
return runPromise((svc) => svc.get(id))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Keep `Interface`, `Service`, `layer`, and `defaultLayer` on the owning namespace
|
||||
- Export `defaultLayer` only when wiring dependencies is useful
|
||||
- Use the direct namespace form once the module is fully migrated
|
||||
- Keep everything in one namespace, one file — no separate `service.ts` / `index.ts` split
|
||||
- `runPromise` goes inside the namespace (not exported unless tests need it)
|
||||
- Facade functions are plain `async function` — no `fn()` wrappers
|
||||
- Use `Effect.fn("Namespace.method")` for all Effect functions (for tracing)
|
||||
- No `Layer.fresh` — InstanceState handles per-directory isolation
|
||||
|
||||
## Temporary mixed-mode pattern
|
||||
## Schema → Zod interop
|
||||
|
||||
Prefer a single namespace whenever possible.
|
||||
|
||||
Use a `*Effect` namespace only when there is a real mixed-mode split, usually because a legacy boundary facade still exists or because merging everything immediately would create awkward cycles.
|
||||
When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@/util/effect-zod`:
|
||||
|
||||
```ts
|
||||
export namespace FooEffect {
|
||||
export interface Interface {
|
||||
readonly get: (id: FooID) => Effect.Effect<Foo, FooError>
|
||||
}
|
||||
import { zod } from "@/util/effect-zod"
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Foo") {}
|
||||
|
||||
export const layer = Layer.effect(...)
|
||||
}
|
||||
export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union
|
||||
```
|
||||
|
||||
Then keep the old boundary thin:
|
||||
See `Auth.ZodInfo` for the canonical example.
|
||||
|
||||
## InstanceState init patterns
|
||||
|
||||
The `InstanceState.make` init callback receives a `Scope`, so you can use `Effect.acquireRelease`, `Effect.addFinalizer`, and `Effect.forkScoped` inside it. Resources acquired this way are automatically cleaned up when the instance is disposed or invalidated by `ScopedCache`. This makes it the right place for:
|
||||
|
||||
- **Subscriptions**: Use `Effect.acquireRelease` to subscribe and auto-unsubscribe:
|
||||
|
||||
```ts
|
||||
export namespace Foo {
|
||||
export function get(id: FooID) {
|
||||
return runtime.runPromise(FooEffect.Service.use((svc) => svc.get(id)))
|
||||
}
|
||||
}
|
||||
const cache =
|
||||
yield *
|
||||
InstanceState.make<State>(
|
||||
Effect.fn("Foo.state")(function* (ctx) {
|
||||
// ... load state ...
|
||||
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
Bus.subscribeAll((event) => {
|
||||
/* handle */
|
||||
}),
|
||||
),
|
||||
(unsub) => Effect.sync(unsub),
|
||||
)
|
||||
|
||||
return {
|
||||
/* state */
|
||||
}
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
Remove the `Effect` suffix when the boundary split is gone.
|
||||
- **Background fibers**: Use `Effect.forkScoped` — the fiber is interrupted on disposal.
|
||||
- **Side effects at init**: Config notification, event wiring, etc. all belong in the init closure. Callers just do `InstanceState.get(cache)` to trigger everything, and `ScopedCache` deduplicates automatically.
|
||||
|
||||
The key insight: don't split init into a separate method with a `started` flag. Put everything in the `InstanceState.make` closure and let `ScopedCache` handle the run-once semantics.
|
||||
|
||||
## Scheduled Tasks
|
||||
|
||||
|
|
@ -107,38 +140,39 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow
|
|||
|
||||
## Migration checklist
|
||||
|
||||
Done now:
|
||||
Fully migrated (single namespace, InstanceState where needed, flattened facade):
|
||||
|
||||
- [x] `AccountEffect` (mixed-mode)
|
||||
- [x] `AuthEffect` (mixed-mode)
|
||||
- [x] `TruncateEffect` (mixed-mode)
|
||||
- [x] `Question`
|
||||
- [x] `PermissionNext`
|
||||
- [x] `ProviderAuth`
|
||||
- [x] `FileWatcher`
|
||||
- [x] `FileTime`
|
||||
- [x] `Format`
|
||||
- [x] `Vcs`
|
||||
- [x] `Skill`
|
||||
- [x] `Discovery`
|
||||
- [x] `File`
|
||||
- [x] `Snapshot`
|
||||
- [x] `Account` — `account/index.ts`
|
||||
- [x] `Auth` — `auth/index.ts` (uses `zod()` helper for Schema→Zod interop)
|
||||
- [x] `File` — `file/index.ts`
|
||||
- [x] `FileTime` — `file/time.ts`
|
||||
- [x] `FileWatcher` — `file/watcher.ts`
|
||||
- [x] `Format` — `format/index.ts`
|
||||
- [x] `Installation` — `installation/index.ts`
|
||||
- [x] `Permission` — `permission/index.ts`
|
||||
- [x] `ProviderAuth` — `provider/auth.ts`
|
||||
- [x] `Question` — `question/index.ts`
|
||||
- [x] `Skill` — `skill/index.ts`
|
||||
- [x] `Snapshot` — `snapshot/index.ts`
|
||||
- [x] `Truncate` — `tool/truncate.ts`
|
||||
- [x] `Vcs` — `project/vcs.ts`
|
||||
- [x] `Discovery` — `skill/discovery.ts`
|
||||
- [x] `SessionStatus`
|
||||
|
||||
Still open and likely worth migrating:
|
||||
|
||||
- [ ] `Plugin`
|
||||
- [ ] `ToolRegistry`
|
||||
- [x] `Plugin`
|
||||
- [x] `ToolRegistry`
|
||||
- [ ] `Pty`
|
||||
- [ ] `Worktree`
|
||||
- [ ] `Installation`
|
||||
- [ ] `Bus`
|
||||
- [ ] `Command`
|
||||
- [x] `Command`
|
||||
- [ ] `Config`
|
||||
- [ ] `Session`
|
||||
- [ ] `SessionProcessor`
|
||||
- [ ] `SessionPrompt`
|
||||
- [ ] `SessionCompaction`
|
||||
- [ ] `Provider`
|
||||
- [ ] `Project`
|
||||
- [x] `Project`
|
||||
- [ ] `LSP`
|
||||
- [ ] `MCP`
|
||||
|
|
|
|||
|
|
@ -1,380 +0,0 @@
|
|||
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
import { AccountRepo, type AccountRow } from "./repo"
|
||||
import {
|
||||
type AccountError,
|
||||
AccessToken,
|
||||
AccountID,
|
||||
DeviceCode,
|
||||
Info,
|
||||
RefreshToken,
|
||||
AccountServiceError,
|
||||
Login,
|
||||
Org,
|
||||
OrgID,
|
||||
PollDenied,
|
||||
PollError,
|
||||
PollExpired,
|
||||
PollPending,
|
||||
type PollResult,
|
||||
PollSlow,
|
||||
PollSuccess,
|
||||
UserCode,
|
||||
} from "./schema"
|
||||
|
||||
export {
|
||||
AccountID,
|
||||
type AccountError,
|
||||
AccountRepoError,
|
||||
AccountServiceError,
|
||||
AccessToken,
|
||||
RefreshToken,
|
||||
DeviceCode,
|
||||
UserCode,
|
||||
Info,
|
||||
Org,
|
||||
OrgID,
|
||||
Login,
|
||||
PollSuccess,
|
||||
PollPending,
|
||||
PollSlow,
|
||||
PollExpired,
|
||||
PollDenied,
|
||||
PollError,
|
||||
PollResult,
|
||||
} from "./schema"
|
||||
|
||||
export type AccountOrgs = {
|
||||
account: Info
|
||||
orgs: readonly Org[]
|
||||
}
|
||||
|
||||
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
|
||||
config: Schema.Record(Schema.String, Schema.Json),
|
||||
}) {}
|
||||
|
||||
const DurationFromSeconds = Schema.Number.pipe(
|
||||
Schema.decodeTo(Schema.Duration, {
|
||||
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
|
||||
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
|
||||
}),
|
||||
)
|
||||
|
||||
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
|
||||
access_token: AccessToken,
|
||||
refresh_token: RefreshToken,
|
||||
expires_in: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
|
||||
device_code: DeviceCode,
|
||||
user_code: UserCode,
|
||||
verification_uri_complete: Schema.String,
|
||||
expires_in: DurationFromSeconds,
|
||||
interval: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
|
||||
access_token: AccessToken,
|
||||
refresh_token: RefreshToken,
|
||||
token_type: Schema.Literal("Bearer"),
|
||||
expires_in: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
|
||||
error: Schema.String,
|
||||
error_description: Schema.String,
|
||||
}) {
|
||||
toPollResult(): PollResult {
|
||||
if (this.error === "authorization_pending") return new PollPending()
|
||||
if (this.error === "slow_down") return new PollSlow()
|
||||
if (this.error === "expired_token") return new PollExpired()
|
||||
if (this.error === "access_denied") return new PollDenied()
|
||||
return new PollError({ cause: this.error })
|
||||
}
|
||||
}
|
||||
|
||||
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
|
||||
|
||||
class User extends Schema.Class<User>("User")({
|
||||
id: AccountID,
|
||||
email: Schema.String,
|
||||
}) {}
|
||||
|
||||
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
|
||||
|
||||
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
|
||||
grant_type: Schema.String,
|
||||
device_code: DeviceCode,
|
||||
client_id: Schema.String,
|
||||
}) {}
|
||||
|
||||
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
|
||||
grant_type: Schema.String,
|
||||
refresh_token: RefreshToken,
|
||||
client_id: Schema.String,
|
||||
}) {}
|
||||
|
||||
const clientId = "opencode-cli"
|
||||
|
||||
const mapAccountServiceError =
|
||||
(message = "Account service operation failed") =>
|
||||
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> =>
|
||||
effect.pipe(
|
||||
Effect.mapError((cause) =>
|
||||
cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }),
|
||||
),
|
||||
)
|
||||
|
||||
export namespace Account {
|
||||
export interface Interface {
|
||||
readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
|
||||
readonly list: () => Effect.Effect<Info[], AccountError>
|
||||
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
|
||||
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
|
||||
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
|
||||
readonly orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], AccountError>
|
||||
readonly config: (
|
||||
accountID: AccountID,
|
||||
orgID: OrgID,
|
||||
) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
|
||||
readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
|
||||
readonly login: (url: string) => Effect.Effect<Login, AccountError>
|
||||
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Account") {}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const repo = yield* AccountRepo
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const httpRead = withTransientReadRetry(http)
|
||||
const httpOk = HttpClient.filterStatusOk(http)
|
||||
const httpReadOk = HttpClient.filterStatusOk(httpRead)
|
||||
|
||||
const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
|
||||
|
||||
const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
|
||||
|
||||
const executeEffectOk = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
|
||||
request.pipe(
|
||||
Effect.flatMap((req) => httpOk.execute(req)),
|
||||
mapAccountServiceError("HTTP request failed"),
|
||||
)
|
||||
|
||||
const executeEffect = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
|
||||
request.pipe(
|
||||
Effect.flatMap((req) => http.execute(req)),
|
||||
mapAccountServiceError("HTTP request failed"),
|
||||
)
|
||||
|
||||
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
if (row.token_expiry && row.token_expiry > now) return row.access_token
|
||||
|
||||
const response = yield* executeEffectOk(
|
||||
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
|
||||
new TokenRefreshRequest({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: row.refresh_token,
|
||||
client_id: clientId,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
|
||||
const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
|
||||
|
||||
yield* repo.persistToken({
|
||||
accountID: row.id,
|
||||
accessToken: parsed.access_token,
|
||||
refreshToken: parsed.refresh_token,
|
||||
expiry,
|
||||
})
|
||||
|
||||
return parsed.access_token
|
||||
})
|
||||
|
||||
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
|
||||
const maybeAccount = yield* repo.getRow(accountID)
|
||||
if (Option.isNone(maybeAccount)) return Option.none()
|
||||
|
||||
const account = maybeAccount.value
|
||||
const accessToken = yield* resolveToken(account)
|
||||
return Option.some({ account, accessToken })
|
||||
})
|
||||
|
||||
const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
|
||||
const response = yield* executeReadOk(
|
||||
HttpClientRequest.get(`${url}/api/orgs`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(accessToken),
|
||||
),
|
||||
)
|
||||
|
||||
return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
})
|
||||
|
||||
const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
|
||||
const response = yield* executeReadOk(
|
||||
HttpClientRequest.get(`${url}/api/user`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(accessToken),
|
||||
),
|
||||
)
|
||||
|
||||
return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
})
|
||||
|
||||
const token = Effect.fn("Account.token")((accountID: AccountID) =>
|
||||
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
|
||||
)
|
||||
|
||||
const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
|
||||
const accounts = yield* repo.list()
|
||||
const [errors, results] = yield* Effect.partition(
|
||||
accounts,
|
||||
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
|
||||
{ concurrency: 3 },
|
||||
)
|
||||
for (const error of errors) {
|
||||
yield* Effect.logWarning("failed to fetch orgs for account").pipe(
|
||||
Effect.annotateLogs({ error: String(error) }),
|
||||
)
|
||||
}
|
||||
return results
|
||||
})
|
||||
|
||||
const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
|
||||
const resolved = yield* resolveAccess(accountID)
|
||||
if (Option.isNone(resolved)) return []
|
||||
|
||||
const { account, accessToken } = resolved.value
|
||||
|
||||
return yield* fetchOrgs(account.url, accessToken)
|
||||
})
|
||||
|
||||
const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
|
||||
const resolved = yield* resolveAccess(accountID)
|
||||
if (Option.isNone(resolved)) return Option.none()
|
||||
|
||||
const { account, accessToken } = resolved.value
|
||||
|
||||
const response = yield* executeRead(
|
||||
HttpClientRequest.get(`${account.url}/api/config`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(accessToken),
|
||||
HttpClientRequest.setHeaders({ "x-org-id": orgID }),
|
||||
),
|
||||
)
|
||||
|
||||
if (response.status === 404) return Option.none()
|
||||
|
||||
const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError())
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
return Option.some(parsed.config)
|
||||
})
|
||||
|
||||
const login = Effect.fn("Account.login")(function* (server: string) {
|
||||
const response = yield* executeEffectOk(
|
||||
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })),
|
||||
),
|
||||
)
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
return new Login({
|
||||
code: parsed.device_code,
|
||||
user: parsed.user_code,
|
||||
url: `${server}${parsed.verification_uri_complete}`,
|
||||
server,
|
||||
expiry: parsed.expires_in,
|
||||
interval: parsed.interval,
|
||||
})
|
||||
})
|
||||
|
||||
const poll = Effect.fn("Account.poll")(function* (input: Login) {
|
||||
const response = yield* executeEffect(
|
||||
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
|
||||
new DeviceTokenRequest({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
device_code: input.code,
|
||||
client_id: clientId,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
|
||||
if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
|
||||
const accessToken = parsed.access_token
|
||||
|
||||
const user = fetchUser(input.server, accessToken)
|
||||
const orgs = fetchOrgs(input.server, accessToken)
|
||||
|
||||
const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 })
|
||||
|
||||
// TODO: When there are multiple orgs, let the user choose
|
||||
const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
|
||||
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const expiry = now + Duration.toMillis(parsed.expires_in)
|
||||
const refreshToken = parsed.refresh_token
|
||||
|
||||
yield* repo.persistAccount({
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
url: input.server,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiry,
|
||||
orgID: firstOrgID,
|
||||
})
|
||||
|
||||
return new PollSuccess({ email: account.email })
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
active: repo.active,
|
||||
list: repo.list,
|
||||
orgsByAccount,
|
||||
remove: repo.remove,
|
||||
use: repo.use,
|
||||
orgs,
|
||||
config,
|
||||
token,
|
||||
login,
|
||||
poll,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
|
||||
}
|
||||
|
|
@ -1,34 +1,397 @@
|
|||
import { Effect, Option } from "effect"
|
||||
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
|
||||
import { Account as S, type AccountError, type AccessToken, AccountID, Info as Model, OrgID } from "./effect"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
import { AccountRepo, type AccountRow } from "./repo"
|
||||
import {
|
||||
type AccountError,
|
||||
AccessToken,
|
||||
AccountID,
|
||||
DeviceCode,
|
||||
Info,
|
||||
RefreshToken,
|
||||
AccountServiceError,
|
||||
Login,
|
||||
Org,
|
||||
OrgID,
|
||||
PollDenied,
|
||||
PollError,
|
||||
PollExpired,
|
||||
PollPending,
|
||||
type PollResult,
|
||||
PollSlow,
|
||||
PollSuccess,
|
||||
UserCode,
|
||||
} from "./schema"
|
||||
|
||||
export { AccessToken, AccountID, OrgID } from "./effect"
|
||||
export {
|
||||
AccountID,
|
||||
type AccountError,
|
||||
AccountRepoError,
|
||||
AccountServiceError,
|
||||
AccessToken,
|
||||
RefreshToken,
|
||||
DeviceCode,
|
||||
UserCode,
|
||||
Info,
|
||||
Org,
|
||||
OrgID,
|
||||
Login,
|
||||
PollSuccess,
|
||||
PollPending,
|
||||
PollSlow,
|
||||
PollExpired,
|
||||
PollDenied,
|
||||
PollError,
|
||||
PollResult,
|
||||
} from "./schema"
|
||||
|
||||
import { runtime } from "@/effect/runtime"
|
||||
|
||||
function runSync<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) {
|
||||
return runtime.runSync(S.Service.use(f))
|
||||
export type AccountOrgs = {
|
||||
account: Info
|
||||
orgs: readonly Org[]
|
||||
}
|
||||
|
||||
function runPromise<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) {
|
||||
return runtime.runPromise(S.Service.use(f))
|
||||
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
|
||||
config: Schema.Record(Schema.String, Schema.Json),
|
||||
}) {}
|
||||
|
||||
const DurationFromSeconds = Schema.Number.pipe(
|
||||
Schema.decodeTo(Schema.Duration, {
|
||||
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
|
||||
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
|
||||
}),
|
||||
)
|
||||
|
||||
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
|
||||
access_token: AccessToken,
|
||||
refresh_token: RefreshToken,
|
||||
expires_in: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
|
||||
device_code: DeviceCode,
|
||||
user_code: UserCode,
|
||||
verification_uri_complete: Schema.String,
|
||||
expires_in: DurationFromSeconds,
|
||||
interval: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
|
||||
access_token: AccessToken,
|
||||
refresh_token: RefreshToken,
|
||||
token_type: Schema.Literal("Bearer"),
|
||||
expires_in: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
|
||||
error: Schema.String,
|
||||
error_description: Schema.String,
|
||||
}) {
|
||||
toPollResult(): PollResult {
|
||||
if (this.error === "authorization_pending") return new PollPending()
|
||||
if (this.error === "slow_down") return new PollSlow()
|
||||
if (this.error === "expired_token") return new PollExpired()
|
||||
if (this.error === "access_denied") return new PollDenied()
|
||||
return new PollError({ cause: this.error })
|
||||
}
|
||||
}
|
||||
|
||||
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
|
||||
|
||||
class User extends Schema.Class<User>("User")({
|
||||
id: AccountID,
|
||||
email: Schema.String,
|
||||
}) {}
|
||||
|
||||
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
|
||||
|
||||
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
|
||||
grant_type: Schema.String,
|
||||
device_code: DeviceCode,
|
||||
client_id: Schema.String,
|
||||
}) {}
|
||||
|
||||
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
|
||||
grant_type: Schema.String,
|
||||
refresh_token: RefreshToken,
|
||||
client_id: Schema.String,
|
||||
}) {}
|
||||
|
||||
const clientId = "opencode-cli"
|
||||
|
||||
const mapAccountServiceError =
|
||||
(message = "Account service operation failed") =>
|
||||
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> =>
|
||||
effect.pipe(
|
||||
Effect.mapError((cause) =>
|
||||
cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }),
|
||||
),
|
||||
)
|
||||
|
||||
export namespace Account {
|
||||
export const Info = Model
|
||||
export type Info = Model
|
||||
export interface Interface {
|
||||
readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
|
||||
readonly list: () => Effect.Effect<Info[], AccountError>
|
||||
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
|
||||
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
|
||||
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
|
||||
readonly orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], AccountError>
|
||||
readonly config: (
|
||||
accountID: AccountID,
|
||||
orgID: OrgID,
|
||||
) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
|
||||
readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
|
||||
readonly login: (url: string) => Effect.Effect<Login, AccountError>
|
||||
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
|
||||
}
|
||||
|
||||
export function active(): Info | undefined {
|
||||
return Option.getOrUndefined(runSync((service) => service.active()))
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Account") {}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const repo = yield* AccountRepo
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const httpRead = withTransientReadRetry(http)
|
||||
const httpOk = HttpClient.filterStatusOk(http)
|
||||
const httpReadOk = HttpClient.filterStatusOk(httpRead)
|
||||
|
||||
const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
|
||||
|
||||
const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
|
||||
|
||||
const executeEffectOk = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
|
||||
request.pipe(
|
||||
Effect.flatMap((req) => httpOk.execute(req)),
|
||||
mapAccountServiceError("HTTP request failed"),
|
||||
)
|
||||
|
||||
const executeEffect = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
|
||||
request.pipe(
|
||||
Effect.flatMap((req) => http.execute(req)),
|
||||
mapAccountServiceError("HTTP request failed"),
|
||||
)
|
||||
|
||||
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
if (row.token_expiry && row.token_expiry > now) return row.access_token
|
||||
|
||||
const response = yield* executeEffectOk(
|
||||
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
|
||||
new TokenRefreshRequest({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: row.refresh_token,
|
||||
client_id: clientId,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
|
||||
const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
|
||||
|
||||
yield* repo.persistToken({
|
||||
accountID: row.id,
|
||||
accessToken: parsed.access_token,
|
||||
refreshToken: parsed.refresh_token,
|
||||
expiry,
|
||||
})
|
||||
|
||||
return parsed.access_token
|
||||
})
|
||||
|
||||
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
|
||||
const maybeAccount = yield* repo.getRow(accountID)
|
||||
if (Option.isNone(maybeAccount)) return Option.none()
|
||||
|
||||
const account = maybeAccount.value
|
||||
const accessToken = yield* resolveToken(account)
|
||||
return Option.some({ account, accessToken })
|
||||
})
|
||||
|
||||
const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
|
||||
const response = yield* executeReadOk(
|
||||
HttpClientRequest.get(`${url}/api/orgs`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(accessToken),
|
||||
),
|
||||
)
|
||||
|
||||
return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
})
|
||||
|
||||
const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
|
||||
const response = yield* executeReadOk(
|
||||
HttpClientRequest.get(`${url}/api/user`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(accessToken),
|
||||
),
|
||||
)
|
||||
|
||||
return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
})
|
||||
|
||||
const token = Effect.fn("Account.token")((accountID: AccountID) =>
|
||||
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
|
||||
)
|
||||
|
||||
const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
|
||||
const accounts = yield* repo.list()
|
||||
const [errors, results] = yield* Effect.partition(
|
||||
accounts,
|
||||
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
|
||||
{ concurrency: 3 },
|
||||
)
|
||||
for (const error of errors) {
|
||||
yield* Effect.logWarning("failed to fetch orgs for account").pipe(
|
||||
Effect.annotateLogs({ error: String(error) }),
|
||||
)
|
||||
}
|
||||
return results
|
||||
})
|
||||
|
||||
const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
|
||||
const resolved = yield* resolveAccess(accountID)
|
||||
if (Option.isNone(resolved)) return []
|
||||
|
||||
const { account, accessToken } = resolved.value
|
||||
|
||||
return yield* fetchOrgs(account.url, accessToken)
|
||||
})
|
||||
|
||||
const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
|
||||
const resolved = yield* resolveAccess(accountID)
|
||||
if (Option.isNone(resolved)) return Option.none()
|
||||
|
||||
const { account, accessToken } = resolved.value
|
||||
|
||||
const response = yield* executeRead(
|
||||
HttpClientRequest.get(`${account.url}/api/config`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(accessToken),
|
||||
HttpClientRequest.setHeaders({ "x-org-id": orgID }),
|
||||
),
|
||||
)
|
||||
|
||||
if (response.status === 404) return Option.none()
|
||||
|
||||
const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError())
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
return Option.some(parsed.config)
|
||||
})
|
||||
|
||||
const login = Effect.fn("Account.login")(function* (server: string) {
|
||||
const response = yield* executeEffectOk(
|
||||
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })),
|
||||
),
|
||||
)
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
return new Login({
|
||||
code: parsed.device_code,
|
||||
user: parsed.user_code,
|
||||
url: `${server}${parsed.verification_uri_complete}`,
|
||||
server,
|
||||
expiry: parsed.expires_in,
|
||||
interval: parsed.interval,
|
||||
})
|
||||
})
|
||||
|
||||
const poll = Effect.fn("Account.poll")(function* (input: Login) {
|
||||
const response = yield* executeEffect(
|
||||
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
|
||||
new DeviceTokenRequest({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
device_code: input.code,
|
||||
client_id: clientId,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
|
||||
if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
|
||||
const accessToken = parsed.access_token
|
||||
|
||||
const user = fetchUser(input.server, accessToken)
|
||||
const orgs = fetchOrgs(input.server, accessToken)
|
||||
|
||||
const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 })
|
||||
|
||||
// TODO: When there are multiple orgs, let the user choose
|
||||
const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
|
||||
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const expiry = now + Duration.toMillis(parsed.expires_in)
|
||||
const refreshToken = parsed.refresh_token
|
||||
|
||||
yield* repo.persistAccount({
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
url: input.server,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiry,
|
||||
orgID: firstOrgID,
|
||||
})
|
||||
|
||||
return new PollSuccess({ email: account.email })
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
active: repo.active,
|
||||
list: repo.list,
|
||||
orgsByAccount,
|
||||
remove: repo.remove,
|
||||
use: repo.use,
|
||||
orgs,
|
||||
config,
|
||||
token,
|
||||
login,
|
||||
poll,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
|
||||
|
||||
export const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
|
||||
export async function active(): Promise<Info | undefined> {
|
||||
return Option.getOrUndefined(await runPromise((service) => service.active()))
|
||||
}
|
||||
|
||||
export async function config(accountID: AccountID, orgID: OrgID): Promise<Record<string, unknown> | undefined> {
|
||||
const config = await runPromise((service) => service.config(accountID, orgID))
|
||||
return Option.getOrUndefined(config)
|
||||
const cfg = await runPromise((service) => service.config(accountID, orgID))
|
||||
return Option.getOrUndefined(cfg)
|
||||
}
|
||||
|
||||
export async function token(accountID: AccountID): Promise<AccessToken | undefined> {
|
||||
const token = await runPromise((service) => service.token(accountID))
|
||||
return Option.getOrUndefined(token)
|
||||
const t = await runPromise((service) => service.token(accountID))
|
||||
return Option.getOrUndefined(t)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,6 +136,8 @@ export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Ser
|
|||
.onConflictDoUpdate({
|
||||
target: AccountTable.id,
|
||||
set: {
|
||||
email: input.email,
|
||||
url: input.url,
|
||||
access_token: input.accessToken,
|
||||
refresh_token: input.refreshToken,
|
||||
token_expiry: input.expiry,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import z from "zod"
|
|||
import { Provider } from "../provider/provider"
|
||||
import { ModelID, ProviderID } from "../provider/schema"
|
||||
import { generateObject, streamObject, type ModelMessage } from "ai"
|
||||
import { SystemPrompt } from "../session/system"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Truncate } from "../tool/truncate"
|
||||
import { Auth } from "../auth"
|
||||
|
|
@ -14,12 +13,15 @@ import PROMPT_COMPACTION from "./prompt/compaction.txt"
|
|||
import PROMPT_EXPLORE from "./prompt/explore.txt"
|
||||
import PROMPT_SUMMARY from "./prompt/summary.txt"
|
||||
import PROMPT_TITLE from "./prompt/title.txt"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { Permission } from "@/permission"
|
||||
import { mergeDeep, pipe, sortBy, values } from "remeda"
|
||||
import { Global } from "@/global"
|
||||
import path from "path"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Skill } from "../skill"
|
||||
import { Effect, ServiceMap, Layer } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
|
||||
export namespace Agent {
|
||||
export const Info = z
|
||||
|
|
@ -32,7 +34,7 @@ export namespace Agent {
|
|||
topP: z.number().optional(),
|
||||
temperature: z.number().optional(),
|
||||
color: z.string().optional(),
|
||||
permission: PermissionNext.Ruleset,
|
||||
permission: Permission.Ruleset,
|
||||
model: z
|
||||
.object({
|
||||
modelID: ModelID.zod,
|
||||
|
|
@ -49,295 +51,364 @@ export namespace Agent {
|
|||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
const state = Instance.state(async () => {
|
||||
const cfg = await Config.get()
|
||||
export interface Interface {
|
||||
readonly get: (agent: string) => Effect.Effect<Agent.Info>
|
||||
readonly list: () => Effect.Effect<Agent.Info[]>
|
||||
readonly defaultAgent: () => Effect.Effect<string>
|
||||
readonly generate: (input: {
|
||||
description: string
|
||||
model?: { providerID: ProviderID; modelID: ModelID }
|
||||
}) => Effect.Effect<{
|
||||
identifier: string
|
||||
whenToUse: string
|
||||
systemPrompt: string
|
||||
}>
|
||||
}
|
||||
|
||||
const skillDirs = await Skill.dirs()
|
||||
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
|
||||
const defaults = PermissionNext.fromConfig({
|
||||
"*": "allow",
|
||||
doom_loop: "ask",
|
||||
external_directory: {
|
||||
"*": "ask",
|
||||
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
|
||||
},
|
||||
question: "deny",
|
||||
plan_enter: "deny",
|
||||
plan_exit: "deny",
|
||||
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
|
||||
read: {
|
||||
"*": "allow",
|
||||
"*.env": "ask",
|
||||
"*.env.*": "ask",
|
||||
"*.env.example": "allow",
|
||||
},
|
||||
})
|
||||
const user = PermissionNext.fromConfig(cfg.permission ?? {})
|
||||
type State = Omit<Interface, "generate">
|
||||
|
||||
const result: Record<string, Info> = {
|
||||
build: {
|
||||
name: "build",
|
||||
description: "The default agent. Executes tools based on configured permissions.",
|
||||
options: {},
|
||||
permission: PermissionNext.merge(
|
||||
defaults,
|
||||
PermissionNext.fromConfig({
|
||||
question: "allow",
|
||||
plan_enter: "allow",
|
||||
}),
|
||||
user,
|
||||
),
|
||||
mode: "primary",
|
||||
native: true,
|
||||
},
|
||||
plan: {
|
||||
name: "plan",
|
||||
description: "Plan mode. Disallows all edit tools.",
|
||||
options: {},
|
||||
permission: PermissionNext.merge(
|
||||
defaults,
|
||||
PermissionNext.fromConfig({
|
||||
question: "allow",
|
||||
plan_exit: "allow",
|
||||
external_directory: {
|
||||
[path.join(Global.Path.data, "plans", "*")]: "allow",
|
||||
},
|
||||
edit: {
|
||||
"*": "deny",
|
||||
[path.join(".opencode", "plans", "*.md")]: "allow",
|
||||
[path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow",
|
||||
},
|
||||
}),
|
||||
user,
|
||||
),
|
||||
mode: "primary",
|
||||
native: true,
|
||||
},
|
||||
general: {
|
||||
name: "general",
|
||||
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
|
||||
permission: PermissionNext.merge(
|
||||
defaults,
|
||||
PermissionNext.fromConfig({
|
||||
todoread: "deny",
|
||||
todowrite: "deny",
|
||||
}),
|
||||
user,
|
||||
),
|
||||
options: {},
|
||||
mode: "subagent",
|
||||
native: true,
|
||||
},
|
||||
explore: {
|
||||
name: "explore",
|
||||
permission: PermissionNext.merge(
|
||||
defaults,
|
||||
PermissionNext.fromConfig({
|
||||
"*": "deny",
|
||||
grep: "allow",
|
||||
glob: "allow",
|
||||
list: "allow",
|
||||
bash: "allow",
|
||||
webfetch: "allow",
|
||||
websearch: "allow",
|
||||
codesearch: "allow",
|
||||
read: "allow",
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Agent") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = () => Effect.promise(() => Config.get())
|
||||
const auth = yield* Auth.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Agent.state")(function* (ctx) {
|
||||
const cfg = yield* config()
|
||||
const skillDirs = yield* Effect.promise(() => Skill.dirs())
|
||||
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
|
||||
|
||||
const defaults = Permission.fromConfig({
|
||||
"*": "allow",
|
||||
doom_loop: "ask",
|
||||
external_directory: {
|
||||
"*": "ask",
|
||||
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
|
||||
},
|
||||
}),
|
||||
user,
|
||||
),
|
||||
description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`,
|
||||
prompt: PROMPT_EXPLORE,
|
||||
options: {},
|
||||
mode: "subagent",
|
||||
native: true,
|
||||
},
|
||||
compaction: {
|
||||
name: "compaction",
|
||||
mode: "primary",
|
||||
native: true,
|
||||
hidden: true,
|
||||
prompt: PROMPT_COMPACTION,
|
||||
permission: PermissionNext.merge(
|
||||
defaults,
|
||||
PermissionNext.fromConfig({
|
||||
"*": "deny",
|
||||
}),
|
||||
user,
|
||||
),
|
||||
options: {},
|
||||
},
|
||||
title: {
|
||||
name: "title",
|
||||
mode: "primary",
|
||||
options: {},
|
||||
native: true,
|
||||
hidden: true,
|
||||
temperature: 0.5,
|
||||
permission: PermissionNext.merge(
|
||||
defaults,
|
||||
PermissionNext.fromConfig({
|
||||
"*": "deny",
|
||||
}),
|
||||
user,
|
||||
),
|
||||
prompt: PROMPT_TITLE,
|
||||
},
|
||||
summary: {
|
||||
name: "summary",
|
||||
mode: "primary",
|
||||
options: {},
|
||||
native: true,
|
||||
hidden: true,
|
||||
permission: PermissionNext.merge(
|
||||
defaults,
|
||||
PermissionNext.fromConfig({
|
||||
"*": "deny",
|
||||
}),
|
||||
user,
|
||||
),
|
||||
prompt: PROMPT_SUMMARY,
|
||||
},
|
||||
}
|
||||
question: "deny",
|
||||
plan_enter: "deny",
|
||||
plan_exit: "deny",
|
||||
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
|
||||
read: {
|
||||
"*": "allow",
|
||||
"*.env": "ask",
|
||||
"*.env.*": "ask",
|
||||
"*.env.example": "allow",
|
||||
},
|
||||
})
|
||||
|
||||
for (const [key, value] of Object.entries(cfg.agent ?? {})) {
|
||||
if (value.disable) {
|
||||
delete result[key]
|
||||
continue
|
||||
}
|
||||
let item = result[key]
|
||||
if (!item)
|
||||
item = result[key] = {
|
||||
name: key,
|
||||
mode: "all",
|
||||
permission: PermissionNext.merge(defaults, user),
|
||||
options: {},
|
||||
native: false,
|
||||
}
|
||||
if (value.model) item.model = Provider.parseModel(value.model)
|
||||
item.variant = value.variant ?? item.variant
|
||||
item.prompt = value.prompt ?? item.prompt
|
||||
item.description = value.description ?? item.description
|
||||
item.temperature = value.temperature ?? item.temperature
|
||||
item.topP = value.top_p ?? item.topP
|
||||
item.mode = value.mode ?? item.mode
|
||||
item.color = value.color ?? item.color
|
||||
item.hidden = value.hidden ?? item.hidden
|
||||
item.name = value.name ?? item.name
|
||||
item.steps = value.steps ?? item.steps
|
||||
item.options = mergeDeep(item.options, value.options ?? {})
|
||||
item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {}))
|
||||
}
|
||||
const user = Permission.fromConfig(cfg.permission ?? {})
|
||||
|
||||
// Ensure Truncate.GLOB is allowed unless explicitly configured
|
||||
for (const name in result) {
|
||||
const agent = result[name]
|
||||
const explicit = agent.permission.some((r) => {
|
||||
if (r.permission !== "external_directory") return false
|
||||
if (r.action !== "deny") return false
|
||||
return r.pattern === Truncate.GLOB
|
||||
})
|
||||
if (explicit) continue
|
||||
const agents: Record<string, Info> = {
|
||||
build: {
|
||||
name: "build",
|
||||
description: "The default agent. Executes tools based on configured permissions.",
|
||||
options: {},
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
Permission.fromConfig({
|
||||
question: "allow",
|
||||
plan_enter: "allow",
|
||||
}),
|
||||
user,
|
||||
),
|
||||
mode: "primary",
|
||||
native: true,
|
||||
},
|
||||
plan: {
|
||||
name: "plan",
|
||||
description: "Plan mode. Disallows all edit tools.",
|
||||
options: {},
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
Permission.fromConfig({
|
||||
question: "allow",
|
||||
plan_exit: "allow",
|
||||
external_directory: {
|
||||
[path.join(Global.Path.data, "plans", "*")]: "allow",
|
||||
},
|
||||
edit: {
|
||||
"*": "deny",
|
||||
[path.join(".opencode", "plans", "*.md")]: "allow",
|
||||
[path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]:
|
||||
"allow",
|
||||
},
|
||||
}),
|
||||
user,
|
||||
),
|
||||
mode: "primary",
|
||||
native: true,
|
||||
},
|
||||
general: {
|
||||
name: "general",
|
||||
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
Permission.fromConfig({
|
||||
todoread: "deny",
|
||||
todowrite: "deny",
|
||||
}),
|
||||
user,
|
||||
),
|
||||
options: {},
|
||||
mode: "subagent",
|
||||
native: true,
|
||||
},
|
||||
explore: {
|
||||
name: "explore",
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
Permission.fromConfig({
|
||||
"*": "deny",
|
||||
grep: "allow",
|
||||
glob: "allow",
|
||||
list: "allow",
|
||||
bash: "allow",
|
||||
webfetch: "allow",
|
||||
websearch: "allow",
|
||||
codesearch: "allow",
|
||||
read: "allow",
|
||||
external_directory: {
|
||||
"*": "ask",
|
||||
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
|
||||
},
|
||||
}),
|
||||
user,
|
||||
),
|
||||
description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`,
|
||||
prompt: PROMPT_EXPLORE,
|
||||
options: {},
|
||||
mode: "subagent",
|
||||
native: true,
|
||||
},
|
||||
compaction: {
|
||||
name: "compaction",
|
||||
mode: "primary",
|
||||
native: true,
|
||||
hidden: true,
|
||||
prompt: PROMPT_COMPACTION,
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
Permission.fromConfig({
|
||||
"*": "deny",
|
||||
}),
|
||||
user,
|
||||
),
|
||||
options: {},
|
||||
},
|
||||
title: {
|
||||
name: "title",
|
||||
mode: "primary",
|
||||
options: {},
|
||||
native: true,
|
||||
hidden: true,
|
||||
temperature: 0.5,
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
Permission.fromConfig({
|
||||
"*": "deny",
|
||||
}),
|
||||
user,
|
||||
),
|
||||
prompt: PROMPT_TITLE,
|
||||
},
|
||||
summary: {
|
||||
name: "summary",
|
||||
mode: "primary",
|
||||
options: {},
|
||||
native: true,
|
||||
hidden: true,
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
Permission.fromConfig({
|
||||
"*": "deny",
|
||||
}),
|
||||
user,
|
||||
),
|
||||
prompt: PROMPT_SUMMARY,
|
||||
},
|
||||
}
|
||||
|
||||
result[name].permission = PermissionNext.merge(
|
||||
result[name].permission,
|
||||
PermissionNext.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
|
||||
for (const [key, value] of Object.entries(cfg.agent ?? {})) {
|
||||
if (value.disable) {
|
||||
delete agents[key]
|
||||
continue
|
||||
}
|
||||
let item = agents[key]
|
||||
if (!item)
|
||||
item = agents[key] = {
|
||||
name: key,
|
||||
mode: "all",
|
||||
permission: Permission.merge(defaults, user),
|
||||
options: {},
|
||||
native: false,
|
||||
}
|
||||
if (value.model) item.model = Provider.parseModel(value.model)
|
||||
item.variant = value.variant ?? item.variant
|
||||
item.prompt = value.prompt ?? item.prompt
|
||||
item.description = value.description ?? item.description
|
||||
item.temperature = value.temperature ?? item.temperature
|
||||
item.topP = value.top_p ?? item.topP
|
||||
item.mode = value.mode ?? item.mode
|
||||
item.color = value.color ?? item.color
|
||||
item.hidden = value.hidden ?? item.hidden
|
||||
item.name = value.name ?? item.name
|
||||
item.steps = value.steps ?? item.steps
|
||||
item.options = mergeDeep(item.options, value.options ?? {})
|
||||
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
|
||||
}
|
||||
|
||||
// Ensure Truncate.GLOB is allowed unless explicitly configured
|
||||
for (const name in agents) {
|
||||
const agent = agents[name]
|
||||
const explicit = agent.permission.some((r) => {
|
||||
if (r.permission !== "external_directory") return false
|
||||
if (r.action !== "deny") return false
|
||||
return r.pattern === Truncate.GLOB
|
||||
})
|
||||
if (explicit) continue
|
||||
|
||||
agents[name].permission = Permission.merge(
|
||||
agents[name].permission,
|
||||
Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
|
||||
)
|
||||
}
|
||||
|
||||
const get = Effect.fnUntraced(function* (agent: string) {
|
||||
return agents[agent]
|
||||
})
|
||||
|
||||
const list = Effect.fnUntraced(function* () {
|
||||
const cfg = yield* config()
|
||||
return pipe(
|
||||
agents,
|
||||
values(),
|
||||
sortBy(
|
||||
[(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"],
|
||||
[(x) => x.name, "asc"],
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const defaultAgent = Effect.fnUntraced(function* () {
|
||||
const c = yield* config()
|
||||
if (c.default_agent) {
|
||||
const agent = agents[c.default_agent]
|
||||
if (!agent) throw new Error(`default agent "${c.default_agent}" not found`)
|
||||
if (agent.mode === "subagent") throw new Error(`default agent "${c.default_agent}" is a subagent`)
|
||||
if (agent.hidden === true) throw new Error(`default agent "${c.default_agent}" is hidden`)
|
||||
return agent.name
|
||||
}
|
||||
const visible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true)
|
||||
if (!visible) throw new Error("no primary visible agent found")
|
||||
return visible.name
|
||||
})
|
||||
|
||||
return {
|
||||
get,
|
||||
list,
|
||||
defaultAgent,
|
||||
} satisfies State
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
return Service.of({
|
||||
get: Effect.fn("Agent.get")(function* (agent: string) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.get(agent))
|
||||
}),
|
||||
list: Effect.fn("Agent.list")(function* () {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.list())
|
||||
}),
|
||||
defaultAgent: Effect.fn("Agent.defaultAgent")(function* () {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.defaultAgent())
|
||||
}),
|
||||
generate: Effect.fn("Agent.generate")(function* (input: {
|
||||
description: string
|
||||
model?: { providerID: ProviderID; modelID: ModelID }
|
||||
}) {
|
||||
const cfg = yield* config()
|
||||
const model = input.model ?? (yield* Effect.promise(() => Provider.defaultModel()))
|
||||
const resolved = yield* Effect.promise(() => Provider.getModel(model.providerID, model.modelID))
|
||||
const language = yield* Effect.promise(() => Provider.getLanguage(resolved))
|
||||
|
||||
const system = [PROMPT_GENERATE]
|
||||
yield* Effect.promise(() =>
|
||||
Plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system }),
|
||||
)
|
||||
const existing = yield* InstanceState.useEffect(state, (s) => s.list())
|
||||
|
||||
const params = {
|
||||
experimental_telemetry: {
|
||||
isEnabled: cfg.experimental?.openTelemetry,
|
||||
metadata: {
|
||||
userId: cfg.username ?? "unknown",
|
||||
},
|
||||
},
|
||||
temperature: 0.3,
|
||||
messages: [
|
||||
...system.map(
|
||||
(item): ModelMessage => ({
|
||||
role: "system",
|
||||
content: item,
|
||||
}),
|
||||
),
|
||||
{
|
||||
role: "user",
|
||||
content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`,
|
||||
},
|
||||
],
|
||||
model: language,
|
||||
schema: z.object({
|
||||
identifier: z.string(),
|
||||
whenToUse: z.string(),
|
||||
systemPrompt: z.string(),
|
||||
}),
|
||||
} satisfies Parameters<typeof generateObject>[0]
|
||||
|
||||
// TODO: clean this up so provider specific logic doesnt bleed over
|
||||
const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie)
|
||||
if (model.providerID === "openai" && authInfo?.type === "oauth") {
|
||||
return yield* Effect.promise(async () => {
|
||||
const result = streamObject({
|
||||
...params,
|
||||
providerOptions: ProviderTransform.providerOptions(resolved, {
|
||||
store: false,
|
||||
}),
|
||||
onError: () => {},
|
||||
})
|
||||
for await (const part of result.fullStream) {
|
||||
if (part.type === "error") throw part.error
|
||||
}
|
||||
return result.object
|
||||
})
|
||||
}
|
||||
|
||||
return yield* Effect.promise(() => generateObject(params).then((r) => r.object))
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
|
||||
|
||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
|
||||
export async function get(agent: string) {
|
||||
return state().then((x) => x[agent])
|
||||
return runPromise((svc) => svc.get(agent))
|
||||
}
|
||||
|
||||
export async function list() {
|
||||
const cfg = await Config.get()
|
||||
return pipe(
|
||||
await state(),
|
||||
values(),
|
||||
sortBy(
|
||||
[(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"],
|
||||
[(x) => x.name, "asc"],
|
||||
),
|
||||
)
|
||||
return runPromise((svc) => svc.list())
|
||||
}
|
||||
|
||||
export async function defaultAgent() {
|
||||
const cfg = await Config.get()
|
||||
const agents = await state()
|
||||
|
||||
if (cfg.default_agent) {
|
||||
const agent = agents[cfg.default_agent]
|
||||
if (!agent) throw new Error(`default agent "${cfg.default_agent}" not found`)
|
||||
if (agent.mode === "subagent") throw new Error(`default agent "${cfg.default_agent}" is a subagent`)
|
||||
if (agent.hidden === true) throw new Error(`default agent "${cfg.default_agent}" is hidden`)
|
||||
return agent.name
|
||||
}
|
||||
|
||||
const primaryVisible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true)
|
||||
if (!primaryVisible) throw new Error("no primary visible agent found")
|
||||
return primaryVisible.name
|
||||
return runPromise((svc) => svc.defaultAgent())
|
||||
}
|
||||
|
||||
export async function generate(input: { description: string; model?: { providerID: ProviderID; modelID: ModelID } }) {
|
||||
const cfg = await Config.get()
|
||||
const defaultModel = input.model ?? (await Provider.defaultModel())
|
||||
const model = await Provider.getModel(defaultModel.providerID, defaultModel.modelID)
|
||||
const language = await Provider.getLanguage(model)
|
||||
|
||||
const system = [PROMPT_GENERATE]
|
||||
await Plugin.trigger("experimental.chat.system.transform", { model }, { system })
|
||||
const existing = await list()
|
||||
|
||||
const params = {
|
||||
experimental_telemetry: {
|
||||
isEnabled: cfg.experimental?.openTelemetry,
|
||||
metadata: {
|
||||
userId: cfg.username ?? "unknown",
|
||||
},
|
||||
},
|
||||
temperature: 0.3,
|
||||
messages: [
|
||||
...system.map(
|
||||
(item): ModelMessage => ({
|
||||
role: "system",
|
||||
content: item,
|
||||
}),
|
||||
),
|
||||
{
|
||||
role: "user",
|
||||
content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`,
|
||||
},
|
||||
],
|
||||
model: language,
|
||||
schema: z.object({
|
||||
identifier: z.string(),
|
||||
whenToUse: z.string(),
|
||||
systemPrompt: z.string(),
|
||||
}),
|
||||
} satisfies Parameters<typeof generateObject>[0]
|
||||
|
||||
// TODO: clean this up so provider specific logic doesnt bleed over
|
||||
if (defaultModel.providerID === "openai" && (await Auth.get(defaultModel.providerID))?.type === "oauth") {
|
||||
const result = streamObject({
|
||||
...params,
|
||||
providerOptions: ProviderTransform.providerOptions(model, {
|
||||
store: false,
|
||||
}),
|
||||
onError: () => {},
|
||||
})
|
||||
for await (const part of result.fullStream) {
|
||||
if (part.type === "error") throw part.error
|
||||
}
|
||||
return result.object
|
||||
}
|
||||
|
||||
const result = await generateObject(params)
|
||||
return result.object
|
||||
return runPromise((svc) => svc.generate(input))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,94 +0,0 @@
|
|||
import path from "path"
|
||||
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
|
||||
import { Global } from "../global"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
|
||||
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
|
||||
|
||||
export class Oauth extends Schema.Class<Oauth>("OAuth")({
|
||||
type: Schema.Literal("oauth"),
|
||||
refresh: Schema.String,
|
||||
access: Schema.String,
|
||||
expires: Schema.Number,
|
||||
accountId: Schema.optional(Schema.String),
|
||||
enterpriseUrl: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
|
||||
export class Api extends Schema.Class<Api>("ApiAuth")({
|
||||
type: Schema.Literal("api"),
|
||||
key: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
|
||||
type: Schema.Literal("wellknown"),
|
||||
key: Schema.String,
|
||||
token: Schema.String,
|
||||
}) {}
|
||||
|
||||
export const Info = Schema.Union([Oauth, Api, WellKnown])
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
const file = path.join(Global.Path.data, "auth.json")
|
||||
|
||||
const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause })
|
||||
|
||||
export namespace Auth {
|
||||
export interface Interface {
|
||||
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
|
||||
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
|
||||
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
|
||||
readonly remove: (key: string) => Effect.Effect<void, AuthError>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Auth") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const decode = Schema.decodeUnknownOption(Info)
|
||||
|
||||
const all = Effect.fn("Auth.all")(() =>
|
||||
Effect.tryPromise({
|
||||
try: async () => {
|
||||
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
|
||||
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
|
||||
},
|
||||
catch: fail("Failed to read auth data"),
|
||||
}),
|
||||
)
|
||||
|
||||
const get = Effect.fn("Auth.get")(function* (providerID: string) {
|
||||
return (yield* all())[providerID]
|
||||
})
|
||||
|
||||
const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
|
||||
const norm = key.replace(/\/+$/, "")
|
||||
const data = yield* all()
|
||||
if (norm !== key) delete data[key]
|
||||
delete data[norm + "/"]
|
||||
yield* Effect.tryPromise({
|
||||
try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
|
||||
catch: fail("Failed to write auth data"),
|
||||
})
|
||||
})
|
||||
|
||||
const remove = Effect.fn("Auth.remove")(function* (key: string) {
|
||||
const norm = key.replace(/\/+$/, "")
|
||||
const data = yield* all()
|
||||
delete data[key]
|
||||
delete data[norm]
|
||||
yield* Effect.tryPromise({
|
||||
try: () => Filesystem.writeJson(file, data, 0o600),
|
||||
catch: fail("Failed to write auth data"),
|
||||
})
|
||||
})
|
||||
|
||||
return Service.of({ get, all, set, remove })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -1,43 +1,101 @@
|
|||
import { Effect } from "effect"
|
||||
import z from "zod"
|
||||
import { runtime } from "@/effect/runtime"
|
||||
import * as S from "./effect"
|
||||
import path from "path"
|
||||
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { Global } from "../global"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
|
||||
export { OAUTH_DUMMY_KEY } from "./effect"
|
||||
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
|
||||
|
||||
function runPromise<A>(f: (service: S.Auth.Interface) => Effect.Effect<A, S.AuthError>) {
|
||||
return runtime.runPromise(S.Auth.Service.use(f))
|
||||
}
|
||||
const file = path.join(Global.Path.data, "auth.json")
|
||||
|
||||
const fail = (message: string) => (cause: unknown) => new Auth.AuthError({ message, cause })
|
||||
|
||||
export namespace Auth {
|
||||
export const Oauth = z
|
||||
.object({
|
||||
type: z.literal("oauth"),
|
||||
refresh: z.string(),
|
||||
access: z.string(),
|
||||
expires: z.number(),
|
||||
accountId: z.string().optional(),
|
||||
enterpriseUrl: z.string().optional(),
|
||||
})
|
||||
.meta({ ref: "OAuth" })
|
||||
export class Oauth extends Schema.Class<Oauth>("OAuth")({
|
||||
type: Schema.Literal("oauth"),
|
||||
refresh: Schema.String,
|
||||
access: Schema.String,
|
||||
expires: Schema.Number,
|
||||
accountId: Schema.optional(Schema.String),
|
||||
enterpriseUrl: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
|
||||
export const Api = z
|
||||
.object({
|
||||
type: z.literal("api"),
|
||||
key: z.string(),
|
||||
})
|
||||
.meta({ ref: "ApiAuth" })
|
||||
export class Api extends Schema.Class<Api>("ApiAuth")({
|
||||
type: Schema.Literal("api"),
|
||||
key: Schema.String,
|
||||
}) {}
|
||||
|
||||
export const WellKnown = z
|
||||
.object({
|
||||
type: z.literal("wellknown"),
|
||||
key: z.string(),
|
||||
token: z.string(),
|
||||
})
|
||||
.meta({ ref: "WellKnownAuth" })
|
||||
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
|
||||
type: Schema.Literal("wellknown"),
|
||||
key: Schema.String,
|
||||
token: Schema.String,
|
||||
}) {}
|
||||
|
||||
export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).meta({ ref: "Auth" })
|
||||
export type Info = z.infer<typeof Info>
|
||||
const _Info = Schema.Union([Oauth, Api, WellKnown]).annotate({ discriminator: "type", identifier: "Auth" })
|
||||
export const Info = Object.assign(_Info, { zod: zod(_Info) })
|
||||
export type Info = Schema.Schema.Type<typeof _Info>
|
||||
|
||||
export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
|
||||
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
|
||||
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
|
||||
readonly remove: (key: string) => Effect.Effect<void, AuthError>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Auth") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const decode = Schema.decodeUnknownOption(Info)
|
||||
|
||||
const all = Effect.fn("Auth.all")(() =>
|
||||
Effect.tryPromise({
|
||||
try: async () => {
|
||||
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
|
||||
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
|
||||
},
|
||||
catch: fail("Failed to read auth data"),
|
||||
}),
|
||||
)
|
||||
|
||||
const get = Effect.fn("Auth.get")(function* (providerID: string) {
|
||||
return (yield* all())[providerID]
|
||||
})
|
||||
|
||||
const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
|
||||
const norm = key.replace(/\/+$/, "")
|
||||
const data = yield* all()
|
||||
if (norm !== key) delete data[key]
|
||||
delete data[norm + "/"]
|
||||
yield* Effect.tryPromise({
|
||||
try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
|
||||
catch: fail("Failed to write auth data"),
|
||||
})
|
||||
})
|
||||
|
||||
const remove = Effect.fn("Auth.remove")(function* (key: string) {
|
||||
const norm = key.replace(/\/+$/, "")
|
||||
const data = yield* all()
|
||||
delete data[key]
|
||||
delete data[norm]
|
||||
yield* Effect.tryPromise({
|
||||
try: () => Filesystem.writeJson(file, data, 0o600),
|
||||
catch: fail("Failed to write auth data"),
|
||||
})
|
||||
})
|
||||
|
||||
return Service.of({ get, all, set, remove })
|
||||
}),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
|
||||
export async function get(providerID: string) {
|
||||
return runPromise((service) => service.get(providerID))
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { cmd } from "./cmd"
|
||||
import { Duration, Effect, Match, Option } from "effect"
|
||||
import { UI } from "../ui"
|
||||
import { runtime } from "@/effect/runtime"
|
||||
import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account/effect"
|
||||
import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account"
|
||||
import { type AccountError } from "@/account/schema"
|
||||
import * as Prompt from "../effect/prompt"
|
||||
import open from "open"
|
||||
|
|
@ -11,6 +10,26 @@ const openBrowser = (url: string) => Effect.promise(() => open(url).catch(() =>
|
|||
|
||||
const println = (msg: string) => Effect.sync(() => UI.println(msg))
|
||||
|
||||
const dim = (value: string) => UI.Style.TEXT_DIM + value + UI.Style.TEXT_NORMAL
|
||||
|
||||
const activeSuffix = (isActive: boolean) => (isActive ? dim(" (active)") : "")
|
||||
|
||||
export const formatAccountLabel = (account: { email: string; url: string }, isActive: boolean) =>
|
||||
`${account.email} ${dim(account.url)}${activeSuffix(isActive)}`
|
||||
|
||||
const formatOrgChoiceLabel = (account: { email: string }, org: { name: string }, isActive: boolean) =>
|
||||
`${org.name} (${account.email})${activeSuffix(isActive)}`
|
||||
|
||||
export const formatOrgLine = (
|
||||
account: { email: string; url: string },
|
||||
org: { id: string; name: string },
|
||||
isActive: boolean,
|
||||
) => {
|
||||
const dot = isActive ? UI.Style.TEXT_SUCCESS + "●" + UI.Style.TEXT_NORMAL : " "
|
||||
const name = isActive ? UI.Style.TEXT_HIGHLIGHT_BOLD + org.name + UI.Style.TEXT_NORMAL : org.name
|
||||
return ` ${dot} ${name} ${dim(account.email)} ${dim(account.url)} ${dim(org.id)}`
|
||||
}
|
||||
|
||||
const isActiveOrgChoice = (
|
||||
active: Option.Option<{ id: AccountID; active_org_id: OrgID | null }>,
|
||||
choice: { accountID: AccountID; orgID: OrgID },
|
||||
|
|
@ -77,10 +96,9 @@ const logoutEffect = Effect.fn("logout")(function* (email?: string) {
|
|||
|
||||
const opts = accounts.map((a) => {
|
||||
const isActive = Option.isSome(activeID) && activeID.value === a.id
|
||||
const server = UI.Style.TEXT_DIM + a.url + UI.Style.TEXT_NORMAL
|
||||
return {
|
||||
value: a,
|
||||
label: isActive ? `${a.email} ${server}` + UI.Style.TEXT_DIM + " (active)" : `${a.email} ${server}`,
|
||||
label: formatAccountLabel(a, isActive),
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -110,9 +128,7 @@ const switchEffect = Effect.fn("switch")(function* () {
|
|||
const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: org.id })
|
||||
return {
|
||||
value: { orgID: org.id, accountID: group.account.id, label: org.name },
|
||||
label: isActive
|
||||
? `${org.name} (${group.account.email})` + UI.Style.TEXT_DIM + " (active)"
|
||||
: `${org.name} (${group.account.email})`,
|
||||
label: formatOrgChoiceLabel(group.account, org, isActive),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
|
@ -140,15 +156,21 @@ const orgsEffect = Effect.fn("orgs")(function* () {
|
|||
for (const group of groups) {
|
||||
for (const org of group.orgs) {
|
||||
const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: org.id })
|
||||
const dot = isActive ? UI.Style.TEXT_SUCCESS + "●" + UI.Style.TEXT_NORMAL : " "
|
||||
const name = isActive ? UI.Style.TEXT_HIGHLIGHT_BOLD + org.name + UI.Style.TEXT_NORMAL : org.name
|
||||
const email = UI.Style.TEXT_DIM + group.account.email + UI.Style.TEXT_NORMAL
|
||||
const id = UI.Style.TEXT_DIM + org.id + UI.Style.TEXT_NORMAL
|
||||
yield* println(` ${dot} ${name} ${email} ${id}`)
|
||||
yield* println(formatOrgLine(group.account, org, isActive))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const openEffect = Effect.fn("open")(function* () {
|
||||
const service = yield* Account.Service
|
||||
const active = yield* service.active()
|
||||
if (Option.isNone(active)) return yield* println("No active account")
|
||||
|
||||
const url = active.value.url
|
||||
yield* openBrowser(url)
|
||||
yield* Prompt.outro("Opened " + url)
|
||||
})
|
||||
|
||||
export const LoginCommand = cmd({
|
||||
command: "login <url>",
|
||||
describe: false,
|
||||
|
|
@ -160,7 +182,7 @@ export const LoginCommand = cmd({
|
|||
}),
|
||||
async handler(args) {
|
||||
UI.empty()
|
||||
await runtime.runPromise(loginEffect(args.url))
|
||||
await Account.runPromise((_svc) => loginEffect(args.url))
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -174,7 +196,7 @@ export const LogoutCommand = cmd({
|
|||
}),
|
||||
async handler(args) {
|
||||
UI.empty()
|
||||
await runtime.runPromise(logoutEffect(args.email))
|
||||
await Account.runPromise((_svc) => logoutEffect(args.email))
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -183,7 +205,7 @@ export const SwitchCommand = cmd({
|
|||
describe: false,
|
||||
async handler() {
|
||||
UI.empty()
|
||||
await runtime.runPromise(switchEffect())
|
||||
await Account.runPromise((_svc) => switchEffect())
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -192,7 +214,16 @@ export const OrgsCommand = cmd({
|
|||
describe: false,
|
||||
async handler() {
|
||||
UI.empty()
|
||||
await runtime.runPromise(orgsEffect())
|
||||
await Account.runPromise((_svc) => orgsEffect())
|
||||
},
|
||||
})
|
||||
|
||||
export const OpenCommand = cmd({
|
||||
command: "open",
|
||||
describe: false,
|
||||
async handler() {
|
||||
UI.empty()
|
||||
await Account.runPromise((_svc) => openEffect())
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -217,6 +248,10 @@ export const ConsoleCommand = cmd({
|
|||
...OrgsCommand,
|
||||
describe: "list orgs",
|
||||
})
|
||||
.command({
|
||||
...OpenCommand,
|
||||
describe: "open active console account",
|
||||
})
|
||||
.demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import type { MessageV2 } from "../../../session/message-v2"
|
|||
import { MessageID, PartID } from "../../../session/schema"
|
||||
import { ToolRegistry } from "../../../tool/registry"
|
||||
import { Instance } from "../../../project/instance"
|
||||
import { PermissionNext } from "../../../permission"
|
||||
import { Permission } from "../../../permission"
|
||||
import { iife } from "../../../util/iife"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
|
|
@ -75,7 +75,7 @@ async function getAvailableTools(agent: Agent.Info) {
|
|||
}
|
||||
|
||||
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
|
||||
const disabled = PermissionNext.disabled(
|
||||
const disabled = Permission.disabled(
|
||||
availableTools.map((tool) => tool.id),
|
||||
agent.permission,
|
||||
)
|
||||
|
|
@ -145,7 +145,7 @@ async function createToolContext(agent: Agent.Info) {
|
|||
}
|
||||
await Session.updateMessage(message)
|
||||
|
||||
const ruleset = PermissionNext.merge(agent.permission, session.permission ?? [])
|
||||
const ruleset = Permission.merge(agent.permission, session.permission ?? [])
|
||||
|
||||
return {
|
||||
sessionID: session.id,
|
||||
|
|
@ -155,11 +155,11 @@ async function createToolContext(agent: Agent.Info) {
|
|||
abort: new AbortController().signal,
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
async ask(req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) {
|
||||
async ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) {
|
||||
for (const pattern of req.patterns) {
|
||||
const rule = PermissionNext.evaluate(req.permission, pattern, ruleset)
|
||||
const rule = Permission.evaluate(req.permission, pattern, ruleset)
|
||||
if (rule.action === "deny") {
|
||||
throw new PermissionNext.DeniedError({ ruleset })
|
||||
throw new Permission.DeniedError({ ruleset })
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart
|
|||
import { Server } from "../../server/server"
|
||||
import { Provider } from "../../provider/provider"
|
||||
import { Agent } from "../../agent/agent"
|
||||
import { PermissionNext } from "../../permission"
|
||||
import { Permission } from "../../permission"
|
||||
import { Tool } from "../../tool/tool"
|
||||
import { GlobTool } from "../../tool/glob"
|
||||
import { GrepTool } from "../../tool/grep"
|
||||
|
|
@ -354,7 +354,7 @@ export const RunCommand = cmd({
|
|||
process.exit(1)
|
||||
}
|
||||
|
||||
const rules: PermissionNext.Ruleset = [
|
||||
const rules: Permission.Ruleset = [
|
||||
{
|
||||
permission: "question",
|
||||
action: "deny",
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import { MouseButton, TextAttributes } from "@opentui/core"
|
|||
import { RouteProvider, useRoute } from "@tui/context/route"
|
||||
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
|
||||
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
|
||||
import { Installation } from "@/installation"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import semver from "semver"
|
||||
import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
||||
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
|
||||
import { SDKProvider, useSDK } from "@tui/context/sdk"
|
||||
|
|
@ -29,6 +29,7 @@ import { PromptHistoryProvider } from "./component/prompt/history"
|
|||
import { FrecencyProvider } from "./component/prompt/frecency"
|
||||
import { PromptStashProvider } from "./component/prompt/stash"
|
||||
import { DialogAlert } from "./ui/dialog-alert"
|
||||
import { DialogConfirm } from "./ui/dialog-confirm"
|
||||
import { ToastProvider, useToast } from "./ui/toast"
|
||||
import { ExitProvider, useExit } from "./context/exit"
|
||||
import { Session as SessionApi } from "@/session"
|
||||
|
|
@ -103,11 +104,13 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
|||
}
|
||||
|
||||
import type { EventSource } from "./context/sdk"
|
||||
import { Installation } from "@/installation"
|
||||
|
||||
export function tui(input: {
|
||||
url: string
|
||||
args: Args
|
||||
config: TuiConfig.Info
|
||||
onSnapshot?: () => Promise<string[]>
|
||||
directory?: string
|
||||
fetch?: typeof fetch
|
||||
headers?: RequestInit["headers"]
|
||||
|
|
@ -158,7 +161,7 @@ export function tui(input: {
|
|||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<App />
|
||||
<App onSnapshot={input.onSnapshot} />
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
|
|
@ -199,7 +202,7 @@ export function tui(input: {
|
|||
})
|
||||
}
|
||||
|
||||
function App() {
|
||||
function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
const route = useRoute()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const renderer = useRenderer()
|
||||
|
|
@ -210,7 +213,7 @@ function App() {
|
|||
const command = useCommandDialog()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const { theme, mode, setMode } = useTheme()
|
||||
const { theme, mode, setMode, locked, lock, unlock } = useTheme()
|
||||
const sync = useSync()
|
||||
const exit = useExit()
|
||||
const promptRef = usePromptRef()
|
||||
|
|
@ -555,7 +558,7 @@ function App() {
|
|||
category: "System",
|
||||
},
|
||||
{
|
||||
title: "Toggle appearance",
|
||||
title: "Toggle Theme Mode",
|
||||
value: "theme.switch_mode",
|
||||
onSelect: (dialog) => {
|
||||
setMode(mode() === "dark" ? "light" : "dark")
|
||||
|
|
@ -563,6 +566,16 @@ function App() {
|
|||
},
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
title: locked() ? "Unlock Theme Mode" : "Lock Theme Mode",
|
||||
value: "theme.mode.lock",
|
||||
onSelect: (dialog) => {
|
||||
if (locked()) unlock()
|
||||
else lock()
|
||||
dialog.clear()
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
title: "Help",
|
||||
value: "help.show",
|
||||
|
|
@ -615,11 +628,11 @@ function App() {
|
|||
title: "Write heap snapshot",
|
||||
category: "System",
|
||||
value: "app.heap_snapshot",
|
||||
onSelect: (dialog) => {
|
||||
const path = writeHeapSnapshot()
|
||||
onSelect: async (dialog) => {
|
||||
const files = await props.onSnapshot?.()
|
||||
toast.show({
|
||||
variant: "info",
|
||||
message: `Heap snapshot written to ${path}`,
|
||||
message: `Heap snapshot written to ${files?.join(", ")}`,
|
||||
duration: 5000,
|
||||
})
|
||||
dialog.clear()
|
||||
|
|
@ -729,13 +742,51 @@ function App() {
|
|||
})
|
||||
})
|
||||
|
||||
sdk.event.on(Installation.Event.UpdateAvailable.type, (evt) => {
|
||||
sdk.event.on("installation.update-available", async (evt) => {
|
||||
const version = evt.properties.version
|
||||
|
||||
const skipped = kv.get("skipped_version")
|
||||
if (skipped && !semver.gt(version, skipped)) return
|
||||
|
||||
const choice = await DialogConfirm.show(
|
||||
dialog,
|
||||
`Update Available`,
|
||||
`A new release v${version} is available. Would you like to update now?`,
|
||||
"skip",
|
||||
)
|
||||
|
||||
if (choice === false) {
|
||||
kv.set("skipped_version", version)
|
||||
return
|
||||
}
|
||||
|
||||
if (choice !== true) return
|
||||
|
||||
toast.show({
|
||||
variant: "info",
|
||||
title: "Update Available",
|
||||
message: `OpenCode v${evt.properties.version} is available. Run 'opencode upgrade' to update manually.`,
|
||||
duration: 10000,
|
||||
message: `Updating to v${version}...`,
|
||||
duration: 30000,
|
||||
})
|
||||
|
||||
const result = await sdk.client.global.upgrade({ target: version })
|
||||
|
||||
if (result.error || !result.data?.success) {
|
||||
toast.show({
|
||||
variant: "error",
|
||||
title: "Update Failed",
|
||||
message: "Update failed",
|
||||
duration: 10000,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await DialogAlert.show(
|
||||
dialog,
|
||||
"Update Complete",
|
||||
`Successfully updated to OpenCode v${result.data.version}. Please restart the application.`,
|
||||
)
|
||||
|
||||
exit()
|
||||
})
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg } from "@opentui/core"
|
||||
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes, t, dim, fg } from "@opentui/core"
|
||||
import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
|
||||
import "opentui-spinner/solid"
|
||||
import path from "path"
|
||||
|
|
@ -934,7 +934,7 @@ export function Prompt(props: PromptProps) {
|
|||
// Normalize line endings at the boundary
|
||||
// Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
|
||||
// Replace CRLF first, then any remaining CR
|
||||
const normalizedText = event.text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
const normalizedText = decodePasteBytes(event.bytes).replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
const pastedContent = normalizedText.trim()
|
||||
if (!pastedContent) {
|
||||
command.trigger("prompt.paste")
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
|||
message: store,
|
||||
},
|
||||
)
|
||||
process.on("SIGHUP", () => exit())
|
||||
return exit
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core"
|
||||
import { CliRenderEvents, SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core"
|
||||
import path from "path"
|
||||
import { createEffect, createMemo, onMount } from "solid-js"
|
||||
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { Glob } from "../../../../util/glob"
|
||||
import aura from "./theme/aura.json" with { type: "json" }
|
||||
|
|
@ -280,11 +280,18 @@ function ansiToRgba(code: number): RGBA {
|
|||
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
name: "Theme",
|
||||
init: (props: { mode: "dark" | "light" }) => {
|
||||
const renderer = useRenderer()
|
||||
const config = useTuiConfig()
|
||||
const kv = useKV()
|
||||
const pick = (value: unknown) => {
|
||||
if (value === "dark" || value === "light") return value
|
||||
return
|
||||
}
|
||||
const lock = pick(kv.get("theme_mode_lock"))
|
||||
const [store, setStore] = createStore({
|
||||
themes: DEFAULT_THEMES,
|
||||
mode: kv.get("theme_mode", props.mode),
|
||||
mode: lock ?? pick(kv.get("theme_mode", props.mode)) ?? props.mode,
|
||||
lock,
|
||||
active: (config.theme ?? kv.get("theme", "opencode")) as string,
|
||||
ready: false,
|
||||
})
|
||||
|
|
@ -295,7 +302,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||
})
|
||||
|
||||
function init() {
|
||||
resolveSystemTheme()
|
||||
resolveSystemTheme(store.mode)
|
||||
getCustomThemes()
|
||||
.then((custom) => {
|
||||
setStore(
|
||||
|
|
@ -316,14 +323,12 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||
|
||||
onMount(init)
|
||||
|
||||
function resolveSystemTheme() {
|
||||
console.log("resolveSystemTheme")
|
||||
function resolveSystemTheme(mode: "dark" | "light" = store.mode) {
|
||||
renderer
|
||||
.getPalette({
|
||||
size: 16,
|
||||
})
|
||||
.then((colors) => {
|
||||
console.log(colors.palette)
|
||||
if (!colors.palette[0]) {
|
||||
if (store.active === "system") {
|
||||
setStore(
|
||||
|
|
@ -337,7 +342,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||
}
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.themes.system = generateSystem(colors, store.mode)
|
||||
draft.themes.system = generateSystem(colors, mode)
|
||||
if (store.active === "system") {
|
||||
draft.ready = true
|
||||
}
|
||||
|
|
@ -346,16 +351,44 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||
})
|
||||
}
|
||||
|
||||
const renderer = useRenderer()
|
||||
process.on("SIGUSR2", async () => {
|
||||
function apply(mode: "dark" | "light") {
|
||||
kv.set("theme_mode", mode)
|
||||
if (store.mode === mode) return
|
||||
setStore("mode", mode)
|
||||
renderer.clearPaletteCache()
|
||||
init()
|
||||
resolveSystemTheme(mode)
|
||||
}
|
||||
|
||||
function pin(mode: "dark" | "light" = store.mode) {
|
||||
setStore("lock", mode)
|
||||
kv.set("theme_mode_lock", mode)
|
||||
apply(mode)
|
||||
}
|
||||
|
||||
function free() {
|
||||
setStore("lock", undefined)
|
||||
kv.set("theme_mode_lock", undefined)
|
||||
const mode = renderer.themeMode
|
||||
if (mode) apply(mode)
|
||||
}
|
||||
|
||||
const handle = (mode: "dark" | "light") => {
|
||||
if (store.lock) return
|
||||
apply(mode)
|
||||
}
|
||||
renderer.on(CliRenderEvents.THEME_MODE, handle)
|
||||
onCleanup(() => {
|
||||
renderer.off(CliRenderEvents.THEME_MODE, handle)
|
||||
})
|
||||
|
||||
const values = createMemo(() => {
|
||||
return resolveTheme(store.themes[store.active] ?? store.themes.opencode, store.mode)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
renderer.setBackgroundColor(values().background)
|
||||
})
|
||||
|
||||
const syntax = createMemo(() => generateSyntax(values()))
|
||||
const subtleSyntax = createMemo(() => generateSubtleSyntax(values()))
|
||||
|
||||
|
|
@ -377,9 +410,17 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||
mode() {
|
||||
return store.mode
|
||||
},
|
||||
locked() {
|
||||
return store.lock !== undefined
|
||||
},
|
||||
lock() {
|
||||
pin(store.mode)
|
||||
},
|
||||
unlock() {
|
||||
free()
|
||||
},
|
||||
setMode(mode: "dark" | "light") {
|
||||
setStore("mode", mode)
|
||||
kv.set("theme_mode", mode)
|
||||
pin(mode)
|
||||
},
|
||||
set(theme: string) {
|
||||
setStore("active", theme)
|
||||
|
|
@ -428,7 +469,7 @@ export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA {
|
|||
function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
|
||||
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
|
||||
const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
|
||||
const transparent = RGBA.fromInts(0, 0, 0, 0)
|
||||
const transparent = RGBA.fromValues(bg.r, bg.g, bg.b, 0)
|
||||
const isDark = mode == "dark"
|
||||
|
||||
const col = (i: number) => {
|
||||
|
|
|
|||
|
|
@ -1465,6 +1465,8 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
|
|||
streaming={true}
|
||||
content={props.part.text.trim()}
|
||||
conceal={ctx.conceal()}
|
||||
fg={theme.markdownText}
|
||||
bg={theme.background}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={!Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}>
|
||||
|
|
@ -1667,6 +1669,7 @@ function InlineTool(props: {
|
|||
|
||||
const denied = createMemo(
|
||||
() =>
|
||||
error()?.includes("QuestionRejectedError") ||
|
||||
error()?.includes("rejected permission") ||
|
||||
error()?.includes("specified a rule") ||
|
||||
error()?.includes("user dismissed"),
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue