mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-08 18:30:00 -04:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 55874ccaeb | |||
| b8ed70d6d3 | |||
| 2092350cfa | |||
| 5fb0d7c99c | |||
| a1b4843a33 | |||
| cc7827fe08 | |||
| 76e4d88d21 | |||
| 439ed66c7b | |||
| 047d434aa2 | |||
| d7651519f3 | |||
| 1eb3a43add | |||
| dcae95e2bb | |||
| 0cc507b8a9 | |||
| 727beae2d5 | |||
| cd64a17e37 | |||
| d35ca49c31 | |||
| 5cf97f1b96 | |||
| 65c6a71903 | |||
| a779027303 | |||
| 20fa444f31 | |||
| c66d84169a | |||
| 7dbe8c4c13 | |||
| 8864b01d0b | |||
| ec95b27308 | |||
| be2f74d44a | |||
| 912a801060 | |||
| 0215498f63 | |||
| 1539bd6794 | |||
| 939da6a5e7 | |||
| 0f27ee7b4c | |||
| 51cef27579 |
@@ -178,6 +178,7 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
|
|||||||
toolSchema: Schema.optional(LanguageModelToolSchemaCompatibility),
|
toolSchema: Schema.optional(LanguageModelToolSchemaCompatibility),
|
||||||
reasoningField: Schema.optional(Schema.String),
|
reasoningField: Schema.optional(Schema.String),
|
||||||
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
|
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
|
||||||
|
requireFinishReason: Schema.optional(Schema.Boolean),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export namespace LanguageModelCompatibility {
|
export namespace LanguageModelCompatibility {
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ describe("llm constructors", () => {
|
|||||||
const updated = LanguageModel.update(base, {
|
const updated = LanguageModel.update(base, {
|
||||||
route: responsesRoute,
|
route: responsesRoute,
|
||||||
defaults: { generation: { maxTokens: 20 } },
|
defaults: { generation: { maxTokens: 20 } },
|
||||||
compatibility: { toolSchema: "gemini" },
|
compatibility: { toolSchema: "gemini", requireFinishReason: false },
|
||||||
})
|
})
|
||||||
const updatedInput = LanguageModel.input(updated)
|
const updatedInput = LanguageModel.input(updated)
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ describe("llm constructors", () => {
|
|||||||
expect(String(updated.id)).toBe("fake-model")
|
expect(String(updated.id)).toBe("fake-model")
|
||||||
expect(updated.route).toBe(responsesRoute)
|
expect(updated.route).toBe(responsesRoute)
|
||||||
expect(updated.defaults?.generation).toEqual({ maxTokens: 20 })
|
expect(updated.defaults?.generation).toEqual({ maxTokens: 20 })
|
||||||
expect(updated.compatibility).toEqual({ toolSchema: "gemini" })
|
expect(updated.compatibility).toEqual({ toolSchema: "gemini", requireFinishReason: false })
|
||||||
expect(updatedInput.defaults).toBe(updated.defaults)
|
expect(updatedInput.defaults).toBe(updated.defaults)
|
||||||
expect(updatedInput.compatibility).toBe(updated.compatibility)
|
expect(updatedInput.compatibility).toBe(updated.compatibility)
|
||||||
expect(String(updatedInput.provider)).toBe("fake")
|
expect(String(updatedInput.provider)).toBe("fake")
|
||||||
|
|||||||
+8
-10
@@ -1,19 +1,18 @@
|
|||||||
import { expect, test } from "@playwright/test"
|
import { expect, test } from "@playwright/test"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
|
||||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
|
||||||
const draftID = "draft_legacy_new_session"
|
const draftID = "draft_removed_layout_preference"
|
||||||
const directory = "C:/OpenCode/LegacyNewSession"
|
const directory = "C:/OpenCode/RemovedLayoutPreference"
|
||||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
|
||||||
test("redirects a draft to the legacy new-session route", async ({ page }) => {
|
test("ignores persisted old layout preferences when opening drafts", async ({ page }) => {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: "proj_legacy_new_session",
|
id: "proj_removed_layout_preference",
|
||||||
worktree: directory,
|
worktree: directory,
|
||||||
vcs: "git",
|
vcs: "git",
|
||||||
name: "legacy-new-session",
|
name: "removed-layout-preference",
|
||||||
time: { created: 1700000000000, updated: 1700000000000 },
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
sandboxes: [],
|
sandboxes: [],
|
||||||
},
|
},
|
||||||
@@ -24,7 +23,6 @@ test("redirects a draft to the legacy new-session route", async ({ page }) => {
|
|||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ directory, draftID, server }) => {
|
({ directory, draftID, server }) => {
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
|
||||||
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
|
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"opencode.window.browser.dat:tabs",
|
"opencode.window.browser.dat:tabs",
|
||||||
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
||||||
@@ -35,7 +33,7 @@ test("redirects a draft to the legacy new-session route", async ({ page }) => {
|
|||||||
|
|
||||||
await page.goto(`/new-session?draftId=${draftID}`)
|
await page.goto(`/new-session?draftId=${draftID}`)
|
||||||
|
|
||||||
await expect(page).toHaveURL(`/${base64Encode(directory)}/session`)
|
await expect(page).toHaveURL(`/new-session?draftId=${draftID}`)
|
||||||
await expect(page.locator("header[data-tauri-drag-region]")).toBeVisible()
|
await expect(page.locator("body")).toHaveAttribute("data-new-layout", "")
|
||||||
await expect(page.locator('[data-component="prompt-input"]')).toBeVisible()
|
await expect(page.getByRole("textbox", { name: "Prompt" })).toBeVisible()
|
||||||
})
|
})
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
<meta property="twitter:image" content="/social-share.png" />
|
<meta property="twitter:image" content="/social-share.png" />
|
||||||
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
|
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="antialiased overscroll-none text-12-regular overflow-hidden bg-v2-background-bg-deep">
|
<body data-new-layout class="antialiased overscroll-none font-(family-name:--font-family-text) text-[13px] font-[440] overflow-hidden bg-v2-background-bg-deep">
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
<div id="root" class="flex flex-col h-dvh bg-v2-background-bg-deep p-px"></div>
|
<div id="root" class="flex flex-col h-dvh bg-v2-background-bg-deep p-px"></div>
|
||||||
<script src="/src/entry.tsx" type="module"></script>
|
<script src="/src/entry.tsx" type="module"></script>
|
||||||
|
|||||||
+31
-146
@@ -14,14 +14,11 @@ import {
|
|||||||
Navigate,
|
Navigate,
|
||||||
Route,
|
Route,
|
||||||
Router,
|
Router,
|
||||||
useLocation,
|
|
||||||
useNavigate,
|
|
||||||
useParams,
|
useParams,
|
||||||
useSearchParams,
|
useSearchParams,
|
||||||
} from "@solidjs/router"
|
} from "@solidjs/router"
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
|
||||||
import {
|
import {
|
||||||
type Component,
|
type Component,
|
||||||
createEffect,
|
createEffect,
|
||||||
@@ -43,7 +40,7 @@ import { CommandProvider, useCommand, type CommandOption } from "@/context/comma
|
|||||||
import { CommentsProvider } from "@/context/comments"
|
import { CommentsProvider } from "@/context/comments"
|
||||||
import { FileProvider } from "@/context/file"
|
import { FileProvider } from "@/context/file"
|
||||||
import { ServerSDKProvider } from "@/context/server-sdk"
|
import { ServerSDKProvider } from "@/context/server-sdk"
|
||||||
import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
|
import { ServerSyncProvider } from "@/context/server-sync"
|
||||||
import { GlobalProvider, useGlobal } from "@/context/global"
|
import { GlobalProvider, useGlobal } from "@/context/global"
|
||||||
import { HighlightsProvider } from "@/context/highlights"
|
import { HighlightsProvider } from "@/context/highlights"
|
||||||
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||||
@@ -54,58 +51,36 @@ import { PermissionProvider } from "@/context/permission"
|
|||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { PromptProvider } from "@/context/prompt"
|
import { PromptProvider } from "@/context/prompt"
|
||||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||||
import { SettingsProvider, useSettings } from "@/context/settings"
|
import { SettingsProvider } from "@/context/settings"
|
||||||
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
||||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
import { SDKProvider } from "@/context/sdk"
|
||||||
import { WslServersProvider } from "@/wsl/context"
|
import { WslServersProvider } from "@/wsl/context"
|
||||||
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
|
import { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||||
import LegacyLayout from "@/pages/layout"
|
import Layout from "@/pages/layout"
|
||||||
import NewLayout from "@/pages/layout-new"
|
|
||||||
import { ErrorPage } from "./pages/error"
|
import { ErrorPage } from "./pages/error"
|
||||||
import { useCheckServerHealth } from "./utils/server-health"
|
import { useCheckServerHealth } from "./utils/server-health"
|
||||||
import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
||||||
import { createSessionLineage } from "@/pages/session/session-lineage"
|
import { decode64 } from "@/utils/base64"
|
||||||
|
|
||||||
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
|
import { TargetSessionRouteContent } from "@/pages/session"
|
||||||
import { NewHome } from "@/pages/home"
|
import { Home } from "@/pages/home"
|
||||||
import { LegacyHome } from "@/pages/home/legacy-home"
|
|
||||||
|
|
||||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||||
|
|
||||||
const SessionRoute = () => {
|
const DirectoryDraftRedirect = () => {
|
||||||
const settings = useSettings()
|
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||||
const sdk = useSDK()
|
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
|
|
||||||
if (params.id && settings.general.newLayoutDesigns()) {
|
|
||||||
const sessionID = params.id
|
|
||||||
return (
|
|
||||||
<Show when={tabs.ready()}>
|
|
||||||
{(_) => {
|
|
||||||
const persisted = tabs.store.filter((item) => item.type === "session")
|
|
||||||
return <Navigate href={sessionHref(legacySessionServer(persisted, sessionID, server.key), sessionID)} />
|
|
||||||
}}
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// When the new layout is enabled, the legacy new-session route (/:dir/session with no id)
|
|
||||||
// is replaced by a draft at /new-session?draftId=…
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!settings.general.newLayoutDesigns()) return
|
if (search.draftId || !tabs.ready()) return
|
||||||
if (params.id || search.draftId) return
|
const directory = decode64(params.dir)
|
||||||
if (!tabs.ready() || !sdk().directory) return
|
if (!directory) return
|
||||||
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
|
tabs.newDraft({ server: server.key, directory }, search.prompt)
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return null
|
||||||
<SessionRouteErrorBoundary sessionID={params.id}>
|
|
||||||
<SessionPage />
|
|
||||||
</SessionRouteErrorBoundary>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function TargetServerRoute(props: ParentProps) {
|
function TargetServerRoute(props: ParentProps) {
|
||||||
@@ -117,9 +92,7 @@ function TargetServerRoute(props: ParentProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// Owns the server-identity remount. Session changes must NOT remount this
|
// Owns the server-identity remount. Session changes must not remount this subtree.
|
||||||
// subtree (SessionRouteErrorBoundary resets and createSessionLineage
|
|
||||||
// re-resolves reactively instead); both rely on this key for server changes.
|
|
||||||
<Show when={requireServerKey(params.serverKey)} keyed>
|
<Show when={requireServerKey(params.serverKey)} keyed>
|
||||||
<ServerSDKProvider server={conn}>
|
<ServerSDKProvider server={conn}>
|
||||||
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
|
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
|
||||||
@@ -134,35 +107,6 @@ const TargetSessionRoute = () => (
|
|||||||
</TargetServerRoute>
|
</TargetServerRoute>
|
||||||
)
|
)
|
||||||
|
|
||||||
function LegacyTargetSessionRoute() {
|
|
||||||
const params = useParams<{ serverKey: string; id: string }>()
|
|
||||||
return (
|
|
||||||
<TargetServerRoute>
|
|
||||||
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)}>
|
|
||||||
<LegacyTargetSessionRedirect />
|
|
||||||
</SessionRouteErrorBoundary>
|
|
||||||
</TargetServerRoute>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function LegacyTargetSessionRedirect() {
|
|
||||||
const params = useParams<{ id: string }>()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const sync = useServerSync()
|
|
||||||
const current = createSessionLineage(
|
|
||||||
() => params.id,
|
|
||||||
() => sync().session.lineage,
|
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const directory = current()?.session.location.directory
|
|
||||||
if (!directory) return
|
|
||||||
navigate(legacySessionHref(directory, params.id), { replace: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
||||||
// server via ServerKey, then provide the server-scoped shell for that server.
|
// server via ServerKey, then provide the server-scoped shell for that server.
|
||||||
function SelectedServerProviders(props: ParentProps) {
|
function SelectedServerProviders(props: ParentProps) {
|
||||||
@@ -175,17 +119,8 @@ function SelectedServerProviders(props: ParentProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LegacyServerLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
|
|
||||||
return (
|
|
||||||
<SelectedServerProviders>
|
|
||||||
<LegacyServerScopedShell serverScoped={props.serverScoped}>{props.children}</LegacyServerScopedShell>
|
|
||||||
</SelectedServerProviders>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DraftRoute() {
|
function DraftRoute() {
|
||||||
const [search] = useSearchParams<{ draftId?: string }>()
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
const settings = useSettings()
|
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
return (
|
return (
|
||||||
<Show when={tabs.ready()}>
|
<Show when={tabs.ready()}>
|
||||||
@@ -194,14 +129,7 @@ function DraftRoute() {
|
|||||||
keyed
|
keyed
|
||||||
fallback={<Navigate href="/" />}
|
fallback={<Navigate href="/" />}
|
||||||
>
|
>
|
||||||
{(draft) => (
|
{(draft) => <ResolvedDraftRoute draft={draft} />}
|
||||||
<Show
|
|
||||||
when={settings.general.newLayoutDesigns()}
|
|
||||||
fallback={<Navigate href={`/${base64Encode(draft.directory)}/session`} />}
|
|
||||||
>
|
|
||||||
<ResolvedDraftRoute draft={draft} />
|
|
||||||
</Show>
|
|
||||||
)}
|
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
@@ -237,10 +165,6 @@ function UiI18nBridge(props: ParentProps) {
|
|||||||
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
|
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
|
||||||
}
|
}
|
||||||
|
|
||||||
function LayoutCompatibility(props: ParentProps) {
|
|
||||||
return <>{props.children}</>
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__OPENCODE__?: {
|
__OPENCODE__?: {
|
||||||
@@ -267,17 +191,11 @@ function QueryProvider(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function BodyDesignClass() {
|
function BodyDesignClass() {
|
||||||
const settings = useSettings()
|
|
||||||
|
|
||||||
createRenderEffect(() => {
|
createRenderEffect(() => {
|
||||||
if (typeof document === "undefined") return
|
if (typeof document === "undefined") return
|
||||||
|
document.body.toggleAttribute("data-new-layout", true)
|
||||||
const enabled = settings.general.newLayoutDesigns()
|
document.body.classList.remove("text-12-regular")
|
||||||
document.body.toggleAttribute("data-new-layout", enabled)
|
document.body.classList.add("font-(family-name:--font-family-text)", "text-[13px]", "font-[440]")
|
||||||
document.body.classList.toggle("text-12-regular", !enabled)
|
|
||||||
document.body.classList.toggle("font-(family-name:--font-family-text)", enabled)
|
|
||||||
document.body.classList.toggle("text-[13px]", enabled)
|
|
||||||
document.body.classList.toggle("font-[440]", enabled)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return null
|
return null
|
||||||
@@ -320,7 +238,6 @@ function DesktopCommands() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server-scoped providers shared by the legacy shell and the top-level new shell.
|
|
||||||
type ServerScopedShellProps = ParentProps<{
|
type ServerScopedShellProps = ParentProps<{
|
||||||
directory?: () => string | undefined
|
directory?: () => string | undefined
|
||||||
serverScoped?: JSX.Element
|
serverScoped?: JSX.Element
|
||||||
@@ -335,19 +252,11 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LegacyServerScopedShell(props: ServerScopedShellProps) {
|
function AppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
|
||||||
return (
|
|
||||||
<ServerScopedProviders directory={props.directory} serverScoped={props.serverScoped}>
|
|
||||||
<LegacyLayout>{props.children}</LegacyLayout>
|
|
||||||
</ServerScopedProviders>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
|
|
||||||
return (
|
return (
|
||||||
<SelectedServerProviders>
|
<SelectedServerProviders>
|
||||||
<ServerScopedProviders serverScoped={props.serverScoped}>
|
<ServerScopedProviders serverScoped={props.serverScoped}>
|
||||||
<NewLayout>{props.children}</NewLayout>
|
<Layout>{props.children}</Layout>
|
||||||
</ServerScopedProviders>
|
</ServerScopedProviders>
|
||||||
</SelectedServerProviders>
|
</SelectedServerProviders>
|
||||||
)
|
)
|
||||||
@@ -536,7 +445,7 @@ export function AppInterface(props: {
|
|||||||
startup?: Promise<void>
|
startup?: Promise<void>
|
||||||
serverScoped?: JSX.Element
|
serverScoped?: JSX.Element
|
||||||
}) {
|
}) {
|
||||||
// The visual new layout lives in the router root so it remains mounted across
|
// The visual layout lives in the router root so it remains mounted across
|
||||||
// route changes. Draft and session routes override only their server-bound data
|
// route changes. Draft and session routes override only their server-bound data
|
||||||
// providers beneath it.
|
// providers beneath it.
|
||||||
const ServerShell = (shellProps: ParentProps) => (
|
const ServerShell = (shellProps: ParentProps) => (
|
||||||
@@ -557,7 +466,6 @@ export function AppInterface(props: {
|
|||||||
<GlobalProvider>
|
<GlobalProvider>
|
||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck} startup={props.startup}>
|
<ConnectionGate disableHealthCheck={props.disableHealthCheck} startup={props.startup}>
|
||||||
<Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
|
|
||||||
<Dynamic
|
<Dynamic
|
||||||
component={props.router ?? Router}
|
component={props.router ?? Router}
|
||||||
root={(routerProps) => (
|
root={(routerProps) => (
|
||||||
@@ -565,18 +473,15 @@ export function AppInterface(props: {
|
|||||||
<PermissionProvider>
|
<PermissionProvider>
|
||||||
<NotificationProvider>
|
<NotificationProvider>
|
||||||
<ServerShell>
|
<ServerShell>
|
||||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
<AppLayout serverScoped={props.serverScoped}>{routerProps.children}</AppLayout>
|
||||||
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
|
|
||||||
</Show>
|
|
||||||
</ServerShell>
|
</ServerShell>
|
||||||
</NotificationProvider>
|
</NotificationProvider>
|
||||||
</PermissionProvider>
|
</PermissionProvider>
|
||||||
</TabsProvider>
|
</TabsProvider>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Routes serverScoped={props.serverScoped} />
|
<Routes />
|
||||||
</Dynamic>
|
</Dynamic>
|
||||||
</Show>
|
|
||||||
</ConnectionGate>
|
</ConnectionGate>
|
||||||
</SettingsProvider>
|
</SettingsProvider>
|
||||||
</GlobalProvider>
|
</GlobalProvider>
|
||||||
@@ -584,40 +489,20 @@ export function AppInterface(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Routes(props: { serverScoped?: JSX.Element }) {
|
function Routes() {
|
||||||
const settings = useSettings()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Route
|
<Route path="/" component={Home} />
|
||||||
component={(routeProps) => (
|
<Route path="/:dir" component={DirectoryDraftRedirect} />
|
||||||
<LegacyServerLayout serverScoped={props.serverScoped}>{routeProps.children}</LegacyServerLayout>
|
<Route path="/:dir/session" component={DirectoryDraftRedirect} />
|
||||||
)}
|
<Route path="/:dir/session/:id" component={LegacySessionRedirect} />
|
||||||
>
|
|
||||||
<Show when={!settings.general.newLayoutDesigns()}>
|
|
||||||
{
|
|
||||||
<>
|
|
||||||
<Route path="/" component={LegacyHome} />
|
|
||||||
<Route path="/server/:serverKey/session/:id" component={LegacyTargetSessionRoute} />
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
</Show>
|
|
||||||
<Route path="/:dir" component={DirectoryLayout}>
|
|
||||||
<Route path="/" component={() => <Navigate href="session" />} />
|
|
||||||
<Route path="/session/:id?" component={SessionRoute} />
|
|
||||||
</Route>
|
|
||||||
</Route>
|
|
||||||
<Show when={settings.general.newLayoutDesigns()}>
|
|
||||||
<Route path="/" component={NewHome} />
|
|
||||||
<Route path="/:dir/session/:id" component={NewLayoutLegacySessionRedirect} />
|
|
||||||
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
|
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
|
||||||
</Show>
|
|
||||||
<Route path="/new-session" component={DraftRoute} />
|
<Route path="/new-session" component={DraftRoute} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function NewLayoutLegacySessionRedirect() {
|
function LegacySessionRedirect() {
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const params = useParams<{ id: string }>()
|
const params = useParams<{ id: string }>()
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 187 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 163 KiB |
@@ -1,170 +0,0 @@
|
|||||||
import { Button } from "@opencode-ai/ui/button"
|
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
|
||||||
import { For, Show } from "solid-js"
|
|
||||||
import { type LocalProject, getAvatarColors } from "@/context/layout"
|
|
||||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { getProjectAvatarSource } from "@/pages/layout/helpers"
|
|
||||||
import { ServerConnection } from "@/context/server"
|
|
||||||
import { createEditProjectModel } from "./edit-project"
|
|
||||||
|
|
||||||
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
|
||||||
|
|
||||||
export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
|
|
||||||
const language = useLanguage()
|
|
||||||
const model = createEditProjectModel(props)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog title={language.t("dialog.project.edit.title")} class="w-full max-w-[480px] mx-auto">
|
|
||||||
<form onSubmit={model.submit} class="flex flex-col gap-6 p-6 pt-0">
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<TextField
|
|
||||||
autofocus
|
|
||||||
type="text"
|
|
||||||
label={language.t("dialog.project.edit.name")}
|
|
||||||
placeholder={model.folderName()}
|
|
||||||
value={model.store.name}
|
|
||||||
onChange={(v) => model.setStore("name", v)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.icon")}</label>
|
|
||||||
<div class="flex gap-3 items-start">
|
|
||||||
<div
|
|
||||||
class="relative"
|
|
||||||
onMouseEnter={() => model.setStore("iconHover", true)}
|
|
||||||
onMouseLeave={() => model.setStore("iconHover", false)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="relative size-16 rounded-md transition-colors cursor-pointer"
|
|
||||||
classList={{
|
|
||||||
"border-text-interactive-base bg-surface-info-base/20": model.store.dragOver,
|
|
||||||
"border-border-base hover:border-border-strong": !model.store.dragOver,
|
|
||||||
"overflow-hidden": !!model.store.iconOverride,
|
|
||||||
}}
|
|
||||||
onDrop={model.drop}
|
|
||||||
onDragOver={model.dragOver}
|
|
||||||
onDragLeave={model.dragLeave}
|
|
||||||
onClick={model.iconClick}
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={getProjectAvatarSource(props.project.id, {
|
|
||||||
color: model.store.color,
|
|
||||||
url: props.project.icon?.url,
|
|
||||||
override: model.store.iconOverride,
|
|
||||||
})}
|
|
||||||
fallback={
|
|
||||||
<div class="size-full flex items-center justify-center">
|
|
||||||
<Avatar
|
|
||||||
fallback={model.store.name || model.defaultName()}
|
|
||||||
{...getAvatarColors(model.store.color)}
|
|
||||||
class="size-full text-[32px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(src) => (
|
|
||||||
<img
|
|
||||||
src={src()}
|
|
||||||
alt={language.t("dialog.project.edit.icon.alt")}
|
|
||||||
class="size-full object-cover"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
|
|
||||||
classList={{
|
|
||||||
"opacity-100": model.store.iconHover && !model.store.iconOverride,
|
|
||||||
"opacity-0": !(model.store.iconHover && !model.store.iconOverride),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon name="cloud-upload" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
|
|
||||||
classList={{
|
|
||||||
"opacity-100": model.store.iconHover && !!model.store.iconOverride,
|
|
||||||
"opacity-0": !(model.store.iconHover && !!model.store.iconOverride),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
id="icon-upload"
|
|
||||||
ref={(el) => {
|
|
||||||
model.setIconInput(el)
|
|
||||||
}}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
class="hidden"
|
|
||||||
onChange={model.inputChange}
|
|
||||||
/>
|
|
||||||
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
|
|
||||||
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
|
||||||
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={!model.store.iconOverride}>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.color")}</label>
|
|
||||||
<div class="flex gap-1.5">
|
|
||||||
<For each={AVATAR_COLOR_KEYS}>
|
|
||||||
{(color) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={language.t("dialog.project.edit.color.select", { color })}
|
|
||||||
aria-pressed={model.store.color === color}
|
|
||||||
classList={{
|
|
||||||
"flex items-center justify-center size-10 p-0.5 rounded-lg overflow-hidden transition-colors cursor-default": true,
|
|
||||||
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover":
|
|
||||||
model.store.color === color,
|
|
||||||
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
|
|
||||||
model.store.color !== color,
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (model.store.color === color && !props.project.icon?.url) return
|
|
||||||
model.setStore("color", model.store.color === color ? undefined : color)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Avatar
|
|
||||||
fallback={model.store.name || model.defaultName()}
|
|
||||||
{...getAvatarColors(color)}
|
|
||||||
class="size-full rounded"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
multiline
|
|
||||||
label={language.t("dialog.project.edit.worktree.startup")}
|
|
||||||
description={language.t("dialog.project.edit.worktree.startup.description")}
|
|
||||||
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
|
|
||||||
value={model.store.startup}
|
|
||||||
onChange={(v) => model.setStore("startup", v)}
|
|
||||||
spellcheck={false}
|
|
||||||
class="max-h-14 w-full overflow-y-auto font-mono text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<Button type="button" variant="ghost" size="large" onClick={model.close}>
|
|
||||||
{language.t("common.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" variant="primary" size="large" disabled={!model.supported || model.save.isPending}>
|
|
||||||
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ const statusLabels = {
|
|||||||
connected: "mcp.status.connected",
|
connected: "mcp.status.connected",
|
||||||
failed: "mcp.status.failed",
|
failed: "mcp.status.failed",
|
||||||
needs_auth: "mcp.status.needs_auth",
|
needs_auth: "mcp.status.needs_auth",
|
||||||
needs_client_registration: "mcp.status.needs_client_registration",
|
|
||||||
disabled: "mcp.status.disabled",
|
disabled: "mcp.status.disabled",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
@@ -57,7 +56,7 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
}
|
}
|
||||||
const error = () => {
|
const error = () => {
|
||||||
const s = mcpStatus()
|
const s = mcpStatus()
|
||||||
if (s?.status === "failed" || s?.status === "needs_client_registration") return s.error
|
if (s?.status === "failed") return s.error
|
||||||
}
|
}
|
||||||
const enabled = () => status() === "connected"
|
const enabled = () => status() === "connected"
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,21 +6,17 @@ import { Icon } from "@opencode-ai/ui/icon"
|
|||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { useMutation } from "@tanstack/solid-query"
|
import { Show } from "solid-js"
|
||||||
import { showToast } from "@/utils/toast"
|
|
||||||
import { useNavigate } from "@solidjs/router"
|
|
||||||
import { createEffect, createMemo, createResource, Show } from "solid-js"
|
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||||
import { useGlobal } from "@/context/global"
|
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
|
||||||
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useTabs } from "@/context/tabs"
|
import {
|
||||||
|
type ServerDomainController,
|
||||||
const DEFAULT_USERNAME = "opencode"
|
type ServerFormController,
|
||||||
|
useServerDomainController,
|
||||||
|
useServerFormController,
|
||||||
|
} from "@/components/server/server-management-controller"
|
||||||
|
|
||||||
interface ServerFormProps {
|
interface ServerFormProps {
|
||||||
value: string
|
value: string
|
||||||
@@ -39,76 +35,6 @@ interface ServerFormProps {
|
|||||||
onBack: () => void
|
onBack: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
|
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function useDefaultServer() {
|
|
||||||
const language = useLanguage()
|
|
||||||
const platform = usePlatform()
|
|
||||||
const [defaultKey, defaultUrlActions] = createResource(
|
|
||||||
async () => {
|
|
||||||
try {
|
|
||||||
const key = await platform.getDefaultServer?.()
|
|
||||||
if (!key) return null
|
|
||||||
return key
|
|
||||||
} catch (err) {
|
|
||||||
showRequestError(language, err)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ initialValue: null },
|
|
||||||
)
|
|
||||||
|
|
||||||
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
|
|
||||||
const setDefault = async (key: ServerConnection.Key | null) => {
|
|
||||||
try {
|
|
||||||
await platform.setDefaultServer?.(key)
|
|
||||||
defaultUrlActions.mutate(key)
|
|
||||||
} catch (err) {
|
|
||||||
showRequestError(language, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { defaultKey: () => defaultKey.latest, canDefault, setDefault }
|
|
||||||
}
|
|
||||||
|
|
||||||
function useServerPreview() {
|
|
||||||
const checkServerHealth = useCheckServerHealth()
|
|
||||||
|
|
||||||
const looksComplete = (value: string) => {
|
|
||||||
const normalized = normalizeServerUrl(value)
|
|
||||||
if (!normalized) return false
|
|
||||||
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
|
|
||||||
if (!host) return false
|
|
||||||
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
|
|
||||||
return host.includes(".") || host.includes(":")
|
|
||||||
}
|
|
||||||
|
|
||||||
const previewStatus = async (
|
|
||||||
value: string,
|
|
||||||
username: string,
|
|
||||||
password: string,
|
|
||||||
setStatus: (value: boolean | undefined) => void,
|
|
||||||
) => {
|
|
||||||
setStatus(undefined)
|
|
||||||
if (!looksComplete(value)) return
|
|
||||||
const normalized = normalizeServerUrl(value)
|
|
||||||
if (!normalized) return
|
|
||||||
const http: ServerConnection.HttpBase = { url: normalized }
|
|
||||||
if (username) http.username = username
|
|
||||||
if (password) http.password = password
|
|
||||||
const result = await checkServerHealth(http)
|
|
||||||
setStatus(result.healthy)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { previewStatus }
|
|
||||||
}
|
|
||||||
|
|
||||||
function ServerForm(props: ServerFormProps) {
|
function ServerForm(props: ServerFormProps) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const keyDown = (event: KeyboardEvent) => {
|
const keyDown = (event: KeyboardEvent) => {
|
||||||
@@ -176,385 +102,40 @@ function ServerForm(props: ServerFormProps) {
|
|||||||
|
|
||||||
export function DialogSelectServer() {
|
export function DialogSelectServer() {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const controller = useServerManagementController({ onSelect: dialog.close })
|
const language = useLanguage()
|
||||||
|
const domain = useServerDomainController({ onSelect: () => dialog.close() })
|
||||||
|
const form = useServerFormController({ onSelect: () => dialog.close() })
|
||||||
|
const title = () => {
|
||||||
|
if (!form.state.open()) return language.t("dialog.server.title")
|
||||||
|
return (
|
||||||
|
<div class="flex items-center gap-2 -ml-2">
|
||||||
|
<IconButton icon="arrow-left" variant="ghost" onClick={form.reset} aria-label={language.t("common.goBack")} />
|
||||||
|
<span>
|
||||||
|
{form.state.adding() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog title={controller.formTitle()}>
|
<Dialog title={title()}>
|
||||||
<div class="flex flex-1 min-h-0 flex-col px-5">
|
<div class="flex flex-1 min-h-0 flex-col px-5">
|
||||||
<Show when={controller.isFormMode()} fallback={<ServerConnectionList controller={controller} />}>
|
<Show
|
||||||
<ServerConnectionForm controller={controller} />
|
when={form.state.open()}
|
||||||
|
fallback={<ServerConnectionList domain={domain} onAdd={form.start.add} onEdit={form.start.edit} />}
|
||||||
|
>
|
||||||
|
<ServerConnectionForm form={form} />
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useServerManagementController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) {
|
export function ServerConnectionList(props: {
|
||||||
const navigate = useNavigate()
|
domain: ServerDomainController
|
||||||
const server = useServer()
|
onAdd: () => void
|
||||||
const tabs = useTabs()
|
onEdit: (server: ServerConnection.Http) => void
|
||||||
const global = useGlobal()
|
}) {
|
||||||
const platform = usePlatform()
|
|
||||||
const language = useLanguage()
|
|
||||||
const { defaultKey, canDefault, setDefault } = useDefaultServer()
|
|
||||||
const { previewStatus } = useServerPreview()
|
|
||||||
const checkServerHealth = useCheckServerHealth()
|
|
||||||
const [store, setStore] = createStore({
|
|
||||||
addServer: {
|
|
||||||
url: "",
|
|
||||||
name: "",
|
|
||||||
username: DEFAULT_USERNAME,
|
|
||||||
password: "",
|
|
||||||
error: "",
|
|
||||||
showForm: false,
|
|
||||||
status: undefined as boolean | undefined,
|
|
||||||
},
|
|
||||||
editServer: {
|
|
||||||
id: undefined as string | undefined,
|
|
||||||
value: "",
|
|
||||||
name: "",
|
|
||||||
username: "",
|
|
||||||
password: "",
|
|
||||||
error: "",
|
|
||||||
status: undefined as boolean | undefined,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const resetAdd = () => {
|
|
||||||
setStore("addServer", {
|
|
||||||
url: "",
|
|
||||||
name: "",
|
|
||||||
username: DEFAULT_USERNAME,
|
|
||||||
password: "",
|
|
||||||
error: "",
|
|
||||||
showForm: false,
|
|
||||||
status: undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const resetEdit = () => {
|
|
||||||
setStore("editServer", {
|
|
||||||
id: undefined,
|
|
||||||
value: "",
|
|
||||||
name: "",
|
|
||||||
username: "",
|
|
||||||
password: "",
|
|
||||||
error: "",
|
|
||||||
status: undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
if (options.navigateOnAdd === false) {
|
|
||||||
server.add(conn)
|
|
||||||
options.onSelect?.()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
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 originalKey = ServerConnection.key(original)
|
|
||||||
const active = server.key
|
|
||||||
tabs.removeServer(originalKey)
|
|
||||||
const newConn = server.add(next)
|
|
||||||
if (!newConn) return
|
|
||||||
const nextActive = active === originalKey ? ServerConnection.key(newConn) : active
|
|
||||||
if (nextActive) server.setActive(nextActive)
|
|
||||||
server.remove(originalKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = createMemo(() => {
|
|
||||||
const current = server.current
|
|
||||||
const list = server.list
|
|
||||||
if (!current) return list
|
|
||||||
if (!list.includes(current)) return [current, ...list]
|
|
||||||
return [current, ...list.filter((x) => x !== current)]
|
|
||||||
})
|
|
||||||
|
|
||||||
const settings = useSettings()
|
|
||||||
const current = createMemo<ServerConnection.Any | undefined>(() =>
|
|
||||||
settings.general.newLayoutDesigns()
|
|
||||||
? undefined
|
|
||||||
: (items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]),
|
|
||||||
)
|
|
||||||
|
|
||||||
const sortedItems = createMemo(() => {
|
|
||||||
const raw = items()
|
|
||||||
const list = raw
|
|
||||||
if (!list.length) return list
|
|
||||||
const active = current()
|
|
||||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
|
||||||
const rank = (value?: ServerHealth) => {
|
|
||||||
if (value?.healthy === true) return 0
|
|
||||||
if (value?.healthy === false) return 2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
return list.slice().sort((a, b) => {
|
|
||||||
if (a === active) return -1
|
|
||||||
if (b === active) return 1
|
|
||||||
const diff =
|
|
||||||
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
|
|
||||||
if (diff !== 0) return diff
|
|
||||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
async function select(conn: ServerConnection.Any, persist?: boolean) {
|
|
||||||
if (!persist && global.servers.health[ServerConnection.key(conn)]?.healthy === false) return
|
|
||||||
options.onSelect?.()
|
|
||||||
if (persist && conn.type === "http") {
|
|
||||||
server.add(conn)
|
|
||||||
navigate("/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
navigate("/")
|
|
||||||
queueMicrotask(() => server.setActive(ServerConnection.key(conn)))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAddChange = (value: string) => {
|
|
||||||
if (addMutation.isPending) return
|
|
||||||
setStore("addServer", { url: value, error: "" })
|
|
||||||
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
|
|
||||||
setStore("addServer", { status: next }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAddNameChange = (value: string) => {
|
|
||||||
if (addMutation.isPending) return
|
|
||||||
setStore("addServer", { name: value, error: "" })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAddUsernameChange = (value: string) => {
|
|
||||||
if (addMutation.isPending) return
|
|
||||||
setStore("addServer", { username: value, error: "" })
|
|
||||||
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
|
|
||||||
setStore("addServer", { status: next }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAddPasswordChange = (value: string) => {
|
|
||||||
if (addMutation.isPending) return
|
|
||||||
setStore("addServer", { password: value, error: "" })
|
|
||||||
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
|
|
||||||
setStore("addServer", { status: next }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEditChange = (value: string) => {
|
|
||||||
if (editMutation.isPending) return
|
|
||||||
setStore("editServer", { value, error: "" })
|
|
||||||
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
|
|
||||||
setStore("editServer", { status: next }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEditNameChange = (value: string) => {
|
|
||||||
if (editMutation.isPending) return
|
|
||||||
setStore("editServer", { name: value, error: "" })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEditUsernameChange = (value: string) => {
|
|
||||||
if (editMutation.isPending) return
|
|
||||||
setStore("editServer", { username: value, error: "" })
|
|
||||||
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
|
|
||||||
setStore("editServer", { status: next }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEditPasswordChange = (value: string) => {
|
|
||||||
if (editMutation.isPending) return
|
|
||||||
setStore("editServer", { password: value, error: "" })
|
|
||||||
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
|
|
||||||
setStore("editServer", { status: next }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const mode = createMemo<"list" | "add" | "edit">(() => {
|
|
||||||
if (store.editServer.id) return "edit"
|
|
||||||
if (store.addServer.showForm) return "add"
|
|
||||||
return "list"
|
|
||||||
})
|
|
||||||
|
|
||||||
const editing = createMemo(() => {
|
|
||||||
if (!store.editServer.id) return
|
|
||||||
return items().find((x) => x.type === "http" && x.http.url === store.editServer.id)
|
|
||||||
})
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
resetAdd()
|
|
||||||
resetEdit()
|
|
||||||
}
|
|
||||||
|
|
||||||
const startAdd = () => {
|
|
||||||
resetEdit()
|
|
||||||
setStore("addServer", {
|
|
||||||
showForm: true,
|
|
||||||
url: "",
|
|
||||||
name: "",
|
|
||||||
username: DEFAULT_USERNAME,
|
|
||||||
password: "",
|
|
||||||
error: "",
|
|
||||||
status: undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const startEdit = (conn: ServerConnection.Http) => {
|
|
||||||
resetAdd()
|
|
||||||
setStore("editServer", {
|
|
||||||
id: conn.http.url,
|
|
||||||
value: conn.http.url,
|
|
||||||
name: conn.displayName ?? "",
|
|
||||||
username: conn.http.username ?? "",
|
|
||||||
password: conn.http.password ?? "",
|
|
||||||
error: "",
|
|
||||||
status: global.servers.health[ServerConnection.key(conn)]?.healthy,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitForm = () => {
|
|
||||||
if (mode() === "add") {
|
|
||||||
if (addMutation.isPending) return
|
|
||||||
setStore("addServer", { error: "" })
|
|
||||||
addMutation.mutate(store.addServer.url)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const original = editing()
|
|
||||||
if (!original) return
|
|
||||||
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() ? addMutation.isPending : editMutation.isPending))
|
|
||||||
|
|
||||||
const formTitle = createMemo(() => {
|
|
||||||
if (!isFormMode()) return language.t("dialog.server.title")
|
|
||||||
return (
|
|
||||||
<div class="flex items-center gap-2 -ml-2">
|
|
||||||
<IconButton icon="arrow-left" variant="ghost" onClick={resetForm} aria-label={language.t("common.goBack")} />
|
|
||||||
<span>{isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!store.editServer.id) return
|
|
||||||
if (editing()) return
|
|
||||||
resetEdit()
|
|
||||||
})
|
|
||||||
|
|
||||||
async function handleRemove(key: ServerConnection.Key) {
|
|
||||||
try {
|
|
||||||
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
|
|
||||||
tabs.removeServer(key)
|
|
||||||
server.remove(key)
|
|
||||||
if ((await platform.getDefaultServer?.()) === key) {
|
|
||||||
await setDefault(null)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
showRequestError(language, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
defaultKey,
|
|
||||||
canDefault,
|
|
||||||
current,
|
|
||||||
sortedItems,
|
|
||||||
status: () => global.servers.health,
|
|
||||||
isFormMode,
|
|
||||||
isAddMode,
|
|
||||||
formTitle,
|
|
||||||
formBusy,
|
|
||||||
formValue: () => (isAddMode() ? store.addServer.url : store.editServer.value),
|
|
||||||
formName: () => (isAddMode() ? store.addServer.name : store.editServer.name),
|
|
||||||
formUsername: () => (isAddMode() ? store.addServer.username : store.editServer.username),
|
|
||||||
formPassword: () => (isAddMode() ? store.addServer.password : store.editServer.password),
|
|
||||||
formError: () => (isAddMode() ? store.addServer.error : store.editServer.error),
|
|
||||||
formStatus: () => (isAddMode() ? store.addServer.status : store.editServer.status),
|
|
||||||
select,
|
|
||||||
setDefault,
|
|
||||||
startAdd,
|
|
||||||
startEdit,
|
|
||||||
resetForm,
|
|
||||||
submitForm,
|
|
||||||
canRemove: server.canRemove,
|
|
||||||
handleRemove,
|
|
||||||
handleFormChange: () => (isAddMode() ? handleAddChange : handleEditChange),
|
|
||||||
handleFormNameChange: () => (isAddMode() ? handleAddNameChange : handleEditNameChange),
|
|
||||||
handleFormUsernameChange: () => (isAddMode() ? handleAddUsernameChange : handleEditUsernameChange),
|
|
||||||
handleFormPasswordChange: () => (isAddMode() ? handleAddPasswordChange : handleEditPasswordChange),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ServerConnectionList(props: { controller: ReturnType<typeof useServerManagementController> }) {
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
|
||||||
@@ -568,10 +149,10 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
|
|||||||
}}
|
}}
|
||||||
noInitialSelection
|
noInitialSelection
|
||||||
emptyMessage={language.t("dialog.server.empty")}
|
emptyMessage={language.t("dialog.server.empty")}
|
||||||
items={props.controller.sortedItems}
|
items={props.domain.collection.items}
|
||||||
key={(x) => x.http.url}
|
key={(x) => x.http.url}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
if (x && !settings.general.newLayoutDesigns()) void props.controller.select(x)
|
if (x && !settings.general.newLayoutDesigns()) void props.domain.selection.select(x)
|
||||||
}}
|
}}
|
||||||
divider={true}
|
divider={true}
|
||||||
>
|
>
|
||||||
@@ -580,15 +161,15 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
|
|||||||
return (
|
return (
|
||||||
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
|
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
|
||||||
<div class="flex flex-col h-full items-center w-5">
|
<div class="flex flex-col h-full items-center w-5">
|
||||||
<ServerHealthIndicator health={props.controller.status()[key]} />
|
<ServerHealthIndicator health={props.domain.collection.health()[key]} />
|
||||||
</div>
|
</div>
|
||||||
<ServerRow
|
<ServerRow
|
||||||
conn={i}
|
conn={i}
|
||||||
dimmed={props.controller.status()[key]?.healthy === false}
|
dimmed={props.domain.collection.health()[key]?.healthy === false}
|
||||||
status={props.controller.status()[key]}
|
status={props.domain.collection.health()[key]}
|
||||||
class="flex items-center gap-3 min-w-0 flex-1"
|
class="flex items-center gap-3 min-w-0 flex-1"
|
||||||
badge={
|
badge={
|
||||||
<Show when={props.controller.defaultKey() === ServerConnection.key(i)}>
|
<Show when={props.domain.defaults.key() === ServerConnection.key(i)}>
|
||||||
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
|
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||||
{language.t("dialog.server.status.default")}
|
{language.t("dialog.server.status.default")}
|
||||||
</span>
|
</span>
|
||||||
@@ -597,7 +178,12 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
|
|||||||
showCredentials
|
showCredentials
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center justify-center gap-4 pl-4">
|
<div class="flex items-center justify-center gap-4 pl-4">
|
||||||
<Show when={props.controller.current() && ServerConnection.key(props.controller.current()!) === key}>
|
<Show
|
||||||
|
when={
|
||||||
|
props.domain.collection.current() &&
|
||||||
|
ServerConnection.key(props.domain.collection.current()!) === key
|
||||||
|
}
|
||||||
|
>
|
||||||
<Icon name="check" class="h-6" />
|
<Icon name="check" class="h-6" />
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
@@ -616,27 +202,27 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
|
|||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
if (i.type !== "http") return
|
if (i.type !== "http") return
|
||||||
props.controller.startEdit(i)
|
props.onEdit(i)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}>
|
<Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
|
||||||
<DropdownMenu.Item onSelect={() => props.controller.setDefault(key)}>
|
<DropdownMenu.Item onSelect={() => props.domain.defaults.set(key)}>
|
||||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
|
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
|
||||||
<DropdownMenu.Item onSelect={() => props.controller.setDefault(null)}>
|
<DropdownMenu.Item onSelect={() => props.domain.defaults.set(null)}>
|
||||||
<DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>
|
||||||
{language.t("dialog.server.menu.defaultRemove")}
|
{language.t("dialog.server.menu.defaultRemove")}
|
||||||
</DropdownMenu.ItemLabel>
|
</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.controller.canRemove(key)}>
|
<Show when={props.domain.connection.canRemove(key)}>
|
||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => props.controller.handleRemove(ServerConnection.key(i))}
|
onSelect={() => props.domain.connection.remove(key)}
|
||||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
||||||
@@ -657,7 +243,7 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
icon="plus-small"
|
icon="plus-small"
|
||||||
size="large"
|
size="large"
|
||||||
onClick={props.controller.startAdd}
|
onClick={props.onAdd}
|
||||||
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
{language.t("dialog.server.add.button")}
|
{language.t("dialog.server.add.button")}
|
||||||
@@ -667,38 +253,38 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ServerConnectionForm(props: { controller: ReturnType<typeof useServerManagementController> }) {
|
export function ServerConnectionForm(props: { form: ServerFormController }) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-1 min-h-0 flex-col gap-4">
|
<div class="flex flex-1 min-h-0 flex-col gap-4">
|
||||||
<ServerForm
|
<ServerForm
|
||||||
value={props.controller.formValue()}
|
value={props.form.state.value()}
|
||||||
name={props.controller.formName()}
|
name={props.form.state.name()}
|
||||||
username={props.controller.formUsername()}
|
username={props.form.state.username()}
|
||||||
password={props.controller.formPassword()}
|
password={props.form.state.password()}
|
||||||
placeholder={language.t("dialog.server.add.placeholder")}
|
placeholder={language.t("dialog.server.add.placeholder")}
|
||||||
busy={props.controller.formBusy()}
|
busy={props.form.state.busy()}
|
||||||
error={props.controller.formError()}
|
error={props.form.state.error()}
|
||||||
status={props.controller.formStatus()}
|
status={props.form.state.status()}
|
||||||
onChange={props.controller.handleFormChange()}
|
onChange={props.form.change.value}
|
||||||
onNameChange={props.controller.handleFormNameChange()}
|
onNameChange={props.form.change.name}
|
||||||
onUsernameChange={props.controller.handleFormUsernameChange()}
|
onUsernameChange={props.form.change.username}
|
||||||
onPasswordChange={props.controller.handleFormPasswordChange()}
|
onPasswordChange={props.form.change.password}
|
||||||
onSubmit={props.controller.submitForm}
|
onSubmit={props.form.submit}
|
||||||
onBack={props.controller.resetForm}
|
onBack={props.form.reset}
|
||||||
/>
|
/>
|
||||||
<div class="shrink-0 pb-5">
|
<div class="shrink-0 pb-5">
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="large"
|
size="large"
|
||||||
onClick={props.controller.submitForm}
|
onClick={props.form.submit}
|
||||||
disabled={props.controller.formBusy()}
|
disabled={props.form.state.busy()}
|
||||||
class="px-3 py-1.5"
|
class="px-3 py-1.5"
|
||||||
>
|
>
|
||||||
{props.controller.formBusy()
|
{props.form.state.busy()
|
||||||
? language.t("dialog.server.add.checking")
|
? language.t("dialog.server.add.checking")
|
||||||
: props.controller.isAddMode()
|
: props.form.state.adding()
|
||||||
? language.t("dialog.server.add.button")
|
? language.t("dialog.server.add.button")
|
||||||
: language.t("common.save")}
|
: language.t("common.save")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
import { Component, createSignal, startTransition } from "solid-js"
|
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
|
||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { usePlatform } from "@/context/platform"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { SettingsGeneral } from "./settings-general"
|
|
||||||
import { SettingsKeybinds } from "./settings-keybinds"
|
|
||||||
import { SettingsProviders } from "./settings-providers"
|
|
||||||
import { SettingsModels } from "./settings-models"
|
|
||||||
import { SettingsServers } from "./settings-servers"
|
|
||||||
|
|
||||||
export const DialogSettings: Component<{ defaultValue?: string }> = (props) => {
|
|
||||||
const language = useLanguage()
|
|
||||||
const platform = usePlatform()
|
|
||||||
const dialog = useDialog()
|
|
||||||
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
|
|
||||||
|
|
||||||
const showProviders = () => {
|
|
||||||
void dialog.show(() => <DialogSettings defaultValue="providers" />)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog size="x-large" transition>
|
|
||||||
<Tabs
|
|
||||||
orientation="vertical"
|
|
||||||
variant="settings"
|
|
||||||
value={tab()}
|
|
||||||
onChange={(value) => void startTransition(() => setTab(value))}
|
|
||||||
class="h-full settings-dialog"
|
|
||||||
>
|
|
||||||
<Tabs.List>
|
|
||||||
<div class="flex flex-col justify-between h-full w-full gap-4">
|
|
||||||
<div class="flex flex-col gap-3 w-full pt-3">
|
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
<div class="flex flex-col gap-1.5">
|
|
||||||
<Tabs.SectionTitle>{language.t("settings.section.desktop")}</Tabs.SectionTitle>
|
|
||||||
<div class="flex flex-col gap-1.5 w-full">
|
|
||||||
<Tabs.Trigger value="general">
|
|
||||||
<Icon name="sliders" />
|
|
||||||
{language.t("settings.tab.general")}
|
|
||||||
</Tabs.Trigger>
|
|
||||||
<Tabs.Trigger value="shortcuts">
|
|
||||||
<Icon name="keyboard" />
|
|
||||||
{language.t("settings.tab.shortcuts")}
|
|
||||||
</Tabs.Trigger>
|
|
||||||
<Tabs.Trigger value="servers">
|
|
||||||
<Icon name="server" />
|
|
||||||
{language.t("status.popover.tab.servers")}
|
|
||||||
</Tabs.Trigger>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-1.5">
|
|
||||||
<Tabs.SectionTitle>{language.t("settings.section.server")}</Tabs.SectionTitle>
|
|
||||||
<div class="flex flex-col gap-1.5 w-full">
|
|
||||||
<Tabs.Trigger value="providers">
|
|
||||||
<Icon name="providers" />
|
|
||||||
{language.t("settings.providers.title")}
|
|
||||||
</Tabs.Trigger>
|
|
||||||
<Tabs.Trigger value="models">
|
|
||||||
<Icon name="models" />
|
|
||||||
{language.t("settings.models.title")}
|
|
||||||
</Tabs.Trigger>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-1 pl-1 py-1 text-12-medium text-text-weak">
|
|
||||||
<span>{language.t("app.name.desktop")}</span>
|
|
||||||
<span class="text-11-regular">v{platform.version}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Tabs.List>
|
|
||||||
<Tabs.Content value="general" class="no-scrollbar">
|
|
||||||
<SettingsGeneral />
|
|
||||||
</Tabs.Content>
|
|
||||||
<Tabs.Content value="shortcuts" class="no-scrollbar">
|
|
||||||
<SettingsKeybinds />
|
|
||||||
</Tabs.Content>
|
|
||||||
<Tabs.Content value="servers" class="no-scrollbar">
|
|
||||||
<SettingsServers />
|
|
||||||
</Tabs.Content>
|
|
||||||
<Tabs.Content value="providers" class="no-scrollbar">
|
|
||||||
<SettingsProviders onBack={showProviders} />
|
|
||||||
</Tabs.Content>
|
|
||||||
<Tabs.Content value="models" class="no-scrollbar">
|
|
||||||
<SettingsModels />
|
|
||||||
</Tabs.Content>
|
|
||||||
</Tabs>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
|
||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
|
||||||
import { createSignal, Show } from "solid-js"
|
|
||||||
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
|
|
||||||
import { usePlatform } from "@/context/platform"
|
|
||||||
import { useSettings } from "@/context/settings"
|
|
||||||
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
|
|
||||||
import homeImage from "@/assets/help/home.png"
|
|
||||||
import tabsImage from "@/assets/help/tabs.png"
|
|
||||||
|
|
||||||
// TODO: wire to changelog / seen-state when available
|
|
||||||
const showPopover = () => true
|
|
||||||
|
|
||||||
// can remove this after the tabs rollout has been out for a while
|
|
||||||
export function TabsInfoPopup() {
|
|
||||||
const settings = useSettings()
|
|
||||||
const platform = usePlatform()
|
|
||||||
const [drawerOpen, setDrawerOpen] = createSignal(false)
|
|
||||||
const windows = () => platform.platform === "desktop" && platform.os === "windows"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
|
|
||||||
<Show when={settings.general.shouldDisplayTabsToast()}>
|
|
||||||
<div
|
|
||||||
class="fixed bottom-5 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
|
|
||||||
aria-label="Introducing Tabs. Organize your work and active sessions with tabs"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Dismiss Tabs information"
|
|
||||||
class="absolute top-3 right-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
|
|
||||||
onClick={settings.general.dismissTabsToast}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M4.25 11.75L11.75 4.25M11.75 11.75L4.25 4.25" stroke="white" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left"
|
|
||||||
onClick={() => {
|
|
||||||
settings.general.dismissTabsToast()
|
|
||||||
setDrawerOpen(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<video
|
|
||||||
src={introducingTabsVideo}
|
|
||||||
class="absolute inset-0 h-full w-full object-cover"
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
autoplay
|
|
||||||
playsinline
|
|
||||||
aria-hidden="true"
|
|
||||||
onContextMenu={(event) => event.preventDefault()}
|
|
||||||
/>
|
|
||||||
<div class="absolute inset-x-0 bottom-0 flex w-full flex-col items-start gap-1.5 bg-[linear-gradient(180deg,rgba(0,0,0,0)_0%,#000000_100%)] px-3 py-5">
|
|
||||||
<p class="w-full select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-[#FFFFFF]">
|
|
||||||
Introducing Tabs
|
|
||||||
</p>
|
|
||||||
<p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]">
|
|
||||||
Organize your work and active sessions with tabs
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<DrawerContent
|
|
||||||
style={
|
|
||||||
windows()
|
|
||||||
? {
|
|
||||||
inset: "0 0 0 auto",
|
|
||||||
"max-height": "100vh",
|
|
||||||
"max-width": "100vw",
|
|
||||||
"border-radius": "0",
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Show when={windows()}>
|
|
||||||
<DrawerClose
|
|
||||||
as={IconButtonV2}
|
|
||||||
type="button"
|
|
||||||
size="small"
|
|
||||||
variant="neutral"
|
|
||||||
aria-label="Close"
|
|
||||||
icon={<IconV2 name="xmark-small" />}
|
|
||||||
class="absolute top-[10px] left-[-36px]"
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
<div
|
|
||||||
class="flex w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted"
|
|
||||||
classList={{
|
|
||||||
"h-[40px] px-4": windows(),
|
|
||||||
"h-[52px] p-4": !windows(),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
|
|
||||||
July 14
|
|
||||||
</p>
|
|
||||||
<Show when={!windows()}>
|
|
||||||
<DrawerClose
|
|
||||||
as={IconButtonV2}
|
|
||||||
type="button"
|
|
||||||
size="small"
|
|
||||||
variant="ghost-muted"
|
|
||||||
aria-label="Close"
|
|
||||||
icon={<IconV2 name="xmark-small" />}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<div class="relative flex min-h-0 w-full flex-1 flex-col items-start gap-6 overflow-y-auto p-8">
|
|
||||||
<p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base">
|
|
||||||
Introducing Tabs
|
|
||||||
</p>
|
|
||||||
<div class="flex w-full flex-1 flex-col gap-4 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
|
|
||||||
<p>OpenCode Desktop is now built around tabs.</p>
|
|
||||||
<img src={tabsImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
|
|
||||||
<p>
|
|
||||||
Start a new session in a tab, or open an existing session from any of your projects. Open a new tab when
|
|
||||||
you're starting something new, and close it when you're done.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Keeping a few tabs open makes it easier to organize your active sessions. Rename tabs to something
|
|
||||||
memorable if you plan to keep them around.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
You'll find all your sessions and projects on the new Home screen. Selecting a session opens it in a tab.
|
|
||||||
</p>
|
|
||||||
<img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
|
|
||||||
<p>When you reopen the app, your tabs are still open.</p>
|
|
||||||
<p>
|
|
||||||
The new design does not support Git Worktrees yet, it's coming soon. So if you'd prefer to continue using
|
|
||||||
the previous layout, you can switch between layouts in Settings. Just keep in mind that the new layout
|
|
||||||
will become permanent in a few weeks.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DrawerContent>
|
|
||||||
</Drawer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import { useNavigate } from "@solidjs/router"
|
||||||
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
|
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { useGlobal } from "@/context/global"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
|
import { useTabs } from "@/context/tabs"
|
||||||
|
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
||||||
|
import { showToast } from "@/utils/toast"
|
||||||
|
import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management"
|
||||||
|
|
||||||
|
const DEFAULT_USERNAME = "opencode"
|
||||||
|
|
||||||
|
type FormMode = "list" | "add" | "edit"
|
||||||
|
|
||||||
|
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function useDefaultServer() {
|
||||||
|
const language = useLanguage()
|
||||||
|
const platform = usePlatform()
|
||||||
|
const [defaultKey, defaultKeyActions] = createResource(
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
return (await platform.getDefaultServer?.()) ?? null
|
||||||
|
} catch (err) {
|
||||||
|
showRequestError(language, err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ initialValue: null },
|
||||||
|
)
|
||||||
|
|
||||||
|
const set = async (key: ServerConnection.Key | null) => {
|
||||||
|
try {
|
||||||
|
await platform.setDefaultServer?.(key)
|
||||||
|
defaultKeyActions.mutate(key)
|
||||||
|
} catch (err) {
|
||||||
|
showRequestError(language, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: () => defaultKey.latest,
|
||||||
|
available: createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer),
|
||||||
|
set,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useServerMutations() {
|
||||||
|
const server = useServer()
|
||||||
|
const tabs = useTabs()
|
||||||
|
|
||||||
|
return {
|
||||||
|
add: (connection: ServerConnection.Http) => server.add(connection),
|
||||||
|
replace: (originalKey: ServerConnection.Key, next: ServerConnection.Http) =>
|
||||||
|
replaceServerConnection(originalKey, next, {
|
||||||
|
active: () => server.key,
|
||||||
|
removeTabs: (key) => tabs.removeServer(key),
|
||||||
|
add: (connection) => server.add(connection),
|
||||||
|
setActive: (key) => server.setActive(key),
|
||||||
|
remove: (key) => server.remove(key),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useServerActionsController() {
|
||||||
|
const server = useServer()
|
||||||
|
const tabs = useTabs()
|
||||||
|
const platform = usePlatform()
|
||||||
|
const language = useLanguage()
|
||||||
|
const defaults = useDefaultServer()
|
||||||
|
|
||||||
|
const remove = async (key: ServerConnection.Key) => {
|
||||||
|
try {
|
||||||
|
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
|
||||||
|
tabs.removeServer(key)
|
||||||
|
server.remove(key)
|
||||||
|
if ((await platform.getDefaultServer?.()) === key) await defaults.set(null)
|
||||||
|
} catch (err) {
|
||||||
|
showRequestError(language, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { defaults, connection: { canRemove: server.canRemove, remove } }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerActionsController = ReturnType<typeof useServerActionsController>
|
||||||
|
|
||||||
|
export function useServerCollectionController() {
|
||||||
|
const server = useServer()
|
||||||
|
const global = useGlobal()
|
||||||
|
const settings = useSettings()
|
||||||
|
const actions = useServerActionsController()
|
||||||
|
|
||||||
|
const items = createMemo(() => {
|
||||||
|
const current = server.current
|
||||||
|
const list = server.list
|
||||||
|
if (!current) return list
|
||||||
|
if (!list.includes(current)) return [current, ...list]
|
||||||
|
return [current, ...list.filter((item) => item !== current)]
|
||||||
|
})
|
||||||
|
const current = createMemo<ServerConnection.Any | undefined>(() =>
|
||||||
|
settings.general.newLayoutDesigns()
|
||||||
|
? undefined
|
||||||
|
: (items().find((item) => ServerConnection.key(item) === server.key) ?? items()[0]),
|
||||||
|
)
|
||||||
|
const sorted = createMemo(() => {
|
||||||
|
const raw = items()
|
||||||
|
const list = raw
|
||||||
|
if (!list.length) return list
|
||||||
|
const active = current()
|
||||||
|
const order = new Map(list.map((item, index) => [item, index] as const))
|
||||||
|
const rank = (value?: ServerHealth) => {
|
||||||
|
if (value?.healthy === true) return 0
|
||||||
|
if (value?.healthy === false) return 2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return list.slice().sort((a, b) => {
|
||||||
|
if (a === active) return -1
|
||||||
|
if (b === active) return 1
|
||||||
|
const diff =
|
||||||
|
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
|
||||||
|
if (diff !== 0) return diff
|
||||||
|
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
collection: {
|
||||||
|
items: sorted,
|
||||||
|
current,
|
||||||
|
health: () => global.servers.health,
|
||||||
|
},
|
||||||
|
...actions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerCollectionController = ReturnType<typeof useServerCollectionController>
|
||||||
|
|
||||||
|
export function useServerDomainController(options: { onSelect?: () => void } = {}) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const server = useServer()
|
||||||
|
const global = useGlobal()
|
||||||
|
const collection = useServerCollectionController()
|
||||||
|
|
||||||
|
const select = async (connection: ServerConnection.Any) => {
|
||||||
|
if (global.servers.health[ServerConnection.key(connection)]?.healthy === false) return
|
||||||
|
options.onSelect?.()
|
||||||
|
navigate("/")
|
||||||
|
queueMicrotask(() => server.setActive(ServerConnection.key(connection)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...collection, selection: { select } }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerDomainController = ReturnType<typeof useServerDomainController>
|
||||||
|
|
||||||
|
export function useServerFormController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const server = useServer()
|
||||||
|
const global = useGlobal()
|
||||||
|
const language = useLanguage()
|
||||||
|
const mutations = useServerMutations()
|
||||||
|
const checkServerHealth = useCheckServerHealth()
|
||||||
|
const healthPreview = createServerHealthPreview(checkServerHealth)
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
mode: "list" as FormMode,
|
||||||
|
originalUrl: undefined as string | undefined,
|
||||||
|
values: { url: "", name: "", username: DEFAULT_USERNAME, password: "" },
|
||||||
|
error: "",
|
||||||
|
status: undefined as boolean | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(healthPreview.cancel)
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
healthPreview.cancel()
|
||||||
|
setStore({
|
||||||
|
mode: "list",
|
||||||
|
originalUrl: undefined,
|
||||||
|
values: { url: "", name: "", username: DEFAULT_USERNAME, password: "" },
|
||||||
|
error: "",
|
||||||
|
status: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const allServers = () => {
|
||||||
|
if (!server.current || server.list.includes(server.current)) return server.list
|
||||||
|
return [server.current, ...server.list]
|
||||||
|
}
|
||||||
|
const editing = createMemo(() =>
|
||||||
|
allServers().find((item) => item.type === "http" && item.http.url === store.originalUrl),
|
||||||
|
)
|
||||||
|
|
||||||
|
const request = useMutation(() => ({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const normalized = normalizeServerUrl(store.values.url)
|
||||||
|
if (!normalized) {
|
||||||
|
reset()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const original = store.mode === "edit" ? editing() : undefined
|
||||||
|
if (store.mode === "edit" && !original) return
|
||||||
|
const name = store.values.name.trim() || undefined
|
||||||
|
const username = store.values.username || undefined
|
||||||
|
const password = store.values.password || undefined
|
||||||
|
if (
|
||||||
|
original?.type === "http" &&
|
||||||
|
normalized === original.http.url &&
|
||||||
|
name === original.displayName &&
|
||||||
|
username === original.http.username &&
|
||||||
|
password === original.http.password
|
||||||
|
) {
|
||||||
|
reset()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection: ServerConnection.Http = {
|
||||||
|
type: "http",
|
||||||
|
displayName: name,
|
||||||
|
http: {
|
||||||
|
url: normalized,
|
||||||
|
username: store.mode === "add" && !password ? undefined : username,
|
||||||
|
password,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const result = await checkServerHealth(connection.http)
|
||||||
|
if (!result.healthy) {
|
||||||
|
setStore("error", language.t("dialog.server.add.error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (original?.type === "http") {
|
||||||
|
if (normalized === original.http.url) mutations.add(connection)
|
||||||
|
if (normalized !== original.http.url) mutations.replace(ServerConnection.key(original), connection)
|
||||||
|
reset()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reset()
|
||||||
|
if (options.navigateOnAdd === false) {
|
||||||
|
mutations.add(connection)
|
||||||
|
options.onSelect?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mutations.add(connection)
|
||||||
|
options.onSelect?.()
|
||||||
|
navigate("/")
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const preview = () => void healthPreview.preview(store.values, (status) => setStore("status", status))
|
||||||
|
const change = (field: keyof ServerFormValues, value: string) => {
|
||||||
|
if (request.isPending) return
|
||||||
|
setStore("values", field, value)
|
||||||
|
setStore("error", "")
|
||||||
|
if (field !== "name") preview()
|
||||||
|
}
|
||||||
|
const startAdd = () => {
|
||||||
|
reset()
|
||||||
|
setStore("mode", "add")
|
||||||
|
}
|
||||||
|
const startEdit = (connection: ServerConnection.Http) => {
|
||||||
|
reset()
|
||||||
|
setStore({
|
||||||
|
mode: "edit",
|
||||||
|
originalUrl: connection.http.url,
|
||||||
|
values: {
|
||||||
|
url: connection.http.url,
|
||||||
|
name: connection.displayName ?? "",
|
||||||
|
username: connection.http.username ?? "",
|
||||||
|
password: connection.http.password ?? "",
|
||||||
|
},
|
||||||
|
error: "",
|
||||||
|
status: global.servers.health[ServerConnection.key(connection)]?.healthy,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const submit = () => {
|
||||||
|
if (store.mode === "list" || request.isPending) return
|
||||||
|
setStore("error", "")
|
||||||
|
request.mutate()
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (store.mode !== "edit") return
|
||||||
|
if (editing()) return
|
||||||
|
reset()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
mode: () => store.mode,
|
||||||
|
open: () => store.mode !== "list",
|
||||||
|
adding: () => store.mode === "add",
|
||||||
|
busy: () => request.isPending,
|
||||||
|
value: () => store.values.url,
|
||||||
|
name: () => store.values.name,
|
||||||
|
username: () => store.values.username,
|
||||||
|
password: () => store.values.password,
|
||||||
|
error: () => store.error,
|
||||||
|
status: () => store.status,
|
||||||
|
},
|
||||||
|
change: {
|
||||||
|
value: (value: string) => change("url", value),
|
||||||
|
name: (value: string) => change("name", value),
|
||||||
|
username: (value: string) => change("username", value),
|
||||||
|
password: (value: string) => change("password", value),
|
||||||
|
},
|
||||||
|
start: { add: startAdd, edit: startEdit },
|
||||||
|
reset,
|
||||||
|
submit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerFormController = ReturnType<typeof useServerFormController>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { ServerConnection } from "@/context/server"
|
||||||
|
import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management"
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void
|
||||||
|
const promise = new Promise<T>((done) => {
|
||||||
|
resolve = done
|
||||||
|
})
|
||||||
|
return { promise, resolve }
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = (url: string): ServerFormValues => ({ url, name: "", username: "opencode", password: "" })
|
||||||
|
|
||||||
|
describe("createServerHealthPreview", () => {
|
||||||
|
test("ignores an older response that resolves after the latest response", async () => {
|
||||||
|
const first = deferred<{ healthy: boolean }>()
|
||||||
|
const second = deferred<{ healthy: boolean }>()
|
||||||
|
const requests = [first, second]
|
||||||
|
const status: Array<boolean | undefined> = []
|
||||||
|
const preview = createServerHealthPreview(() => requests.shift()!.promise)
|
||||||
|
|
||||||
|
const older = preview.preview(values("old.example.com"), (value) => status.push(value))
|
||||||
|
const latest = preview.preview(values("new.example.com"), (value) => status.push(value))
|
||||||
|
second.resolve({ healthy: true })
|
||||||
|
await latest
|
||||||
|
first.resolve({ healthy: false })
|
||||||
|
await older
|
||||||
|
|
||||||
|
expect(status).toEqual([undefined, undefined, true])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("an incomplete value invalidates an in-flight response", async () => {
|
||||||
|
const request = deferred<{ healthy: boolean }>()
|
||||||
|
const status: Array<boolean | undefined> = []
|
||||||
|
const preview = createServerHealthPreview(() => request.promise)
|
||||||
|
|
||||||
|
const pending = preview.preview(values("server.example.com"), (value) => status.push(value))
|
||||||
|
await preview.preview(values("server"), (value) => status.push(value))
|
||||||
|
request.resolve({ healthy: true })
|
||||||
|
await pending
|
||||||
|
|
||||||
|
expect(status).toEqual([undefined, undefined])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("cancellation prevents an in-flight response from updating status", async () => {
|
||||||
|
const request = deferred<{ healthy: boolean }>()
|
||||||
|
const status: Array<boolean | undefined> = []
|
||||||
|
const preview = createServerHealthPreview(() => request.promise)
|
||||||
|
|
||||||
|
const pending = preview.preview(values("server.example.com"), (value) => status.push(value))
|
||||||
|
preview.cancel()
|
||||||
|
request.resolve({ healthy: true })
|
||||||
|
await pending
|
||||||
|
|
||||||
|
expect(status).toEqual([undefined])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("replaceServerConnection", () => {
|
||||||
|
const original: ServerConnection.Http = { type: "http", http: { url: "https://old.example.com" } }
|
||||||
|
const next: ServerConnection.Http = { type: "http", http: { url: "https://new.example.com" } }
|
||||||
|
|
||||||
|
test("moves active selection after adding the replacement and removes the original", () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
|
||||||
|
replaceServerConnection(ServerConnection.key(original), next, {
|
||||||
|
active: () => ServerConnection.key(original),
|
||||||
|
removeTabs: (key) => calls.push(`tabs:${key}`),
|
||||||
|
add: (server) => {
|
||||||
|
calls.push(`add:${ServerConnection.key(server)}`)
|
||||||
|
return server
|
||||||
|
},
|
||||||
|
setActive: (key) => calls.push(`active:${key}`),
|
||||||
|
remove: (key) => calls.push(`remove:${key}`),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(calls).toEqual([
|
||||||
|
"tabs:https://old.example.com",
|
||||||
|
"add:https://new.example.com",
|
||||||
|
"active:https://new.example.com",
|
||||||
|
"remove:https://old.example.com",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps the original when the replacement cannot be added", () => {
|
||||||
|
const removed: ServerConnection.Key[] = []
|
||||||
|
|
||||||
|
replaceServerConnection(ServerConnection.key(original), next, {
|
||||||
|
active: () => ServerConnection.key(original),
|
||||||
|
removeTabs: () => {},
|
||||||
|
add: () => undefined,
|
||||||
|
setActive: () => {},
|
||||||
|
remove: (key) => removed.push(key),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(removed).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { normalizeServerUrl, ServerConnection } from "@/context/server"
|
||||||
|
import type { ServerHealth } from "@/utils/server-health"
|
||||||
|
|
||||||
|
export type ServerFormValues = {
|
||||||
|
url: string
|
||||||
|
name: string
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServerHealthPreview(
|
||||||
|
check: (server: ServerConnection.HttpBase) => Promise<Pick<ServerHealth, "healthy">>,
|
||||||
|
) {
|
||||||
|
let generation = 0
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
generation += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview = async (values: ServerFormValues, setStatus: (value: boolean | undefined) => void) => {
|
||||||
|
const current = ++generation
|
||||||
|
setStatus(undefined)
|
||||||
|
const normalized = normalizeServerUrl(values.url)
|
||||||
|
if (!normalized) return
|
||||||
|
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
|
||||||
|
if (!host) return
|
||||||
|
if (!host.includes("localhost") && !host.startsWith("127.0.0.1") && !host.includes(".") && !host.includes(":"))
|
||||||
|
return
|
||||||
|
|
||||||
|
const http: ServerConnection.HttpBase = { url: normalized }
|
||||||
|
if (values.username) http.username = values.username
|
||||||
|
if (values.password) http.password = values.password
|
||||||
|
const result = await check(http)
|
||||||
|
if (current !== generation) return
|
||||||
|
setStatus(result.healthy)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cancel, preview }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaceServerConnection(
|
||||||
|
originalKey: ServerConnection.Key,
|
||||||
|
next: ServerConnection.Http,
|
||||||
|
operations: {
|
||||||
|
active: () => ServerConnection.Key | undefined
|
||||||
|
removeTabs: (key: ServerConnection.Key) => void
|
||||||
|
add: (server: ServerConnection.Http) => ServerConnection.Any | undefined
|
||||||
|
setActive: (key: ServerConnection.Key) => void
|
||||||
|
remove: (key: ServerConnection.Key) => void
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const active = operations.active()
|
||||||
|
operations.removeTabs(originalKey)
|
||||||
|
const added = operations.add(next)
|
||||||
|
if (!added) return
|
||||||
|
const nextActive = active === originalKey ? ServerConnection.key(added) : active
|
||||||
|
if (nextActive) operations.setActive(nextActive)
|
||||||
|
operations.remove(originalKey)
|
||||||
|
}
|
||||||
@@ -2,13 +2,13 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
|||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||||
import { type Component, Show } from "solid-js"
|
import { type Component, Show } from "solid-js"
|
||||||
import { useServerManagementController } from "@/components/dialog-select-server"
|
import type { ServerActionsController } from "@/components/server/server-management-controller"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
|
|
||||||
export const ServerRowMenu: Component<{
|
export const ServerRowMenu: Component<{
|
||||||
server: ServerConnection.Any
|
server: ServerConnection.Any
|
||||||
controller: ReturnType<typeof useServerManagementController>
|
domain: ServerActionsController
|
||||||
onEdit: (server: ServerConnection.Http) => void
|
onEdit: (server: ServerConnection.Http) => void
|
||||||
open?: boolean
|
open?: boolean
|
||||||
onOpenChange?: (open: boolean) => void
|
onOpenChange?: (open: boolean) => void
|
||||||
@@ -19,13 +19,13 @@ export const ServerRowMenu: Component<{
|
|||||||
<ServerRowMenuView
|
<ServerRowMenuView
|
||||||
server={props.server}
|
server={props.server}
|
||||||
labels={serverMenuLabels(language)}
|
labels={serverMenuLabels(language)}
|
||||||
canDefault={props.controller.canDefault()}
|
canDefault={props.domain.defaults.available()}
|
||||||
isDefault={props.controller.defaultKey() === key}
|
isDefault={props.domain.defaults.key() === key}
|
||||||
canRemove={props.controller.canRemove(key)}
|
canRemove={props.domain.connection.canRemove(key)}
|
||||||
onEdit={props.onEdit}
|
onEdit={props.onEdit}
|
||||||
onSetDefault={() => props.controller.setDefault(key)}
|
onSetDefault={() => props.domain.defaults.set(key)}
|
||||||
onRemoveDefault={() => props.controller.setDefault(null)}
|
onRemoveDefault={() => props.domain.defaults.set(null)}
|
||||||
onRemove={() => props.controller.handleRemove(key)}
|
onRemove={() => props.domain.connection.remove(key)}
|
||||||
open={props.open}
|
open={props.open}
|
||||||
onOpenChange={props.onOpenChange}
|
onOpenChange={props.onOpenChange}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,793 +0,0 @@
|
|||||||
import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
|
|
||||||
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 { TextField } from "@opencode-ai/ui/text-field"
|
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
|
||||||
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
|
||||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { useParams } from "@solidjs/router"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { usePermission } from "@/context/permission"
|
|
||||||
import { usePlatform, type DisplayBackend } from "@/context/platform"
|
|
||||||
import { useServerSync } from "@/context/server-sync"
|
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
|
||||||
import { useUpdaterAction } from "./updater-action"
|
|
||||||
import {
|
|
||||||
monoDefault,
|
|
||||||
monoFontFamily,
|
|
||||||
monoInput,
|
|
||||||
sansDefault,
|
|
||||||
sansFontFamily,
|
|
||||||
sansInput,
|
|
||||||
terminalDefault,
|
|
||||||
terminalFontFamily,
|
|
||||||
terminalInput,
|
|
||||||
useSettings,
|
|
||||||
} from "@/context/settings"
|
|
||||||
import { decode64 } from "@/utils/base64"
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
type ShellOption = {
|
|
||||||
path: string
|
|
||||||
name: string
|
|
||||||
acceptable: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type ShellSelectOption = {
|
|
||||||
id: string
|
|
||||||
value: string
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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()
|
|
||||||
}
|
|
||||||
clearTimeout(demoSoundState.timeout)
|
|
||||||
demoSoundState.cleanup = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const playDemoSound = (id: string | undefined) => {
|
|
||||||
stopDemoSound()
|
|
||||||
if (!id) return
|
|
||||||
|
|
||||||
const run = ++demoSoundState.run
|
|
||||||
demoSoundState.timeout = setTimeout(() => {
|
|
||||||
void playSoundById(id).then((cleanup) => {
|
|
||||||
if (demoSoundState.run !== run) {
|
|
||||||
cleanup?.()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
demoSoundState.cleanup = cleanup
|
|
||||||
})
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SettingsGeneral: Component = () => {
|
|
||||||
const theme = useTheme()
|
|
||||||
const language = useLanguage()
|
|
||||||
const permission = usePermission()
|
|
||||||
const platform = usePlatform()
|
|
||||||
const dialog = useDialog()
|
|
||||||
const params = useParams()
|
|
||||||
const settings = useSettings()
|
|
||||||
|
|
||||||
const updater = useUpdaterAction()
|
|
||||||
|
|
||||||
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
|
||||||
const dir = createMemo(() => decode64(params.dir))
|
|
||||||
const accepting = createMemo(() => {
|
|
||||||
const value = dir()
|
|
||||||
if (!value) return false
|
|
||||||
if (!params.id) return permission.isAutoAcceptingDirectory(value)
|
|
||||||
return permission.isAutoAccepting(params.id, value)
|
|
||||||
})
|
|
||||||
|
|
||||||
const toggleAccept = (checked: boolean) => {
|
|
||||||
const value = dir()
|
|
||||||
if (!value) return
|
|
||||||
|
|
||||||
if (!params.id) {
|
|
||||||
if (permission.isAutoAcceptingDirectory(value) === checked) return
|
|
||||||
permission.toggleAutoAcceptDirectory(value)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checked) {
|
|
||||||
permission.enableAutoAccept(params.id, value)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
permission.disableAutoAccept(params.id, value)
|
|
||||||
}
|
|
||||||
const desktop = createMemo(() => platform.platform === "desktop")
|
|
||||||
|
|
||||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
|
||||||
|
|
||||||
const serverSync = useServerSync()
|
|
||||||
const serverSdk = useServerSDK()
|
|
||||||
|
|
||||||
const [shells] = createResource(
|
|
||||||
async () => {
|
|
||||||
// TODO: Restore executable shell discovery; V2 shell.list only lists shell processes.
|
|
||||||
return [] as ShellOption[]
|
|
||||||
},
|
|
||||||
{ initialValue: [] as ShellOption[] },
|
|
||||||
)
|
|
||||||
|
|
||||||
const [displayBackend, { refetch: refetchDisplayBackend }] = createResource(
|
|
||||||
() => (linux() && platform.getDisplayBackend ? true : false),
|
|
||||||
() => Promise.resolve(platform.getDisplayBackend?.() ?? null).catch(() => null as DisplayBackend | null),
|
|
||||||
{ initialValue: null as DisplayBackend | null },
|
|
||||||
)
|
|
||||||
|
|
||||||
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
|
|
||||||
() => (desktop() && platform.getPinchZoomEnabled ? true : false),
|
|
||||||
() => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
|
|
||||||
{ initialValue: false },
|
|
||||||
)
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
void theme.loadThemes()
|
|
||||||
})
|
|
||||||
|
|
||||||
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
|
|
||||||
const currentShell = createMemo(() => serverSync().data.config.shell ?? "")
|
|
||||||
|
|
||||||
const shellOptions = createMemo<ShellSelectOption[]>(() => {
|
|
||||||
const list = shells.latest
|
|
||||||
const current = serverSync().data.config.shell
|
|
||||||
|
|
||||||
const nameCounts = new Map<string, number>()
|
|
||||||
for (const s of list) {
|
|
||||||
nameCounts.set(s.name, (nameCounts.get(s.name) || 0) + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = [
|
|
||||||
autoOption,
|
|
||||||
...list.map((s) => {
|
|
||||||
const ambiguousName = (nameCounts.get(s.name) || 0) > 1
|
|
||||||
const text = ambiguousName ? s.path : s.name
|
|
||||||
const label = s.acceptable ? text : `${text} (${language.t("settings.general.row.shell.terminalOnly")})`
|
|
||||||
return {
|
|
||||||
id: s.path,
|
|
||||||
// Prefer name over path - "bash" is much cleaner than the explicit full route even when it may change due to PATH.
|
|
||||||
value: ambiguousName ? s.path : s.name,
|
|
||||||
label,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
|
|
||||||
if (current && !options.some((o) => o.value === current)) {
|
|
||||||
options.push({ id: current, value: current, label: current })
|
|
||||||
}
|
|
||||||
|
|
||||||
return options
|
|
||||||
})
|
|
||||||
|
|
||||||
const onDisplayBackendChange = (checked: boolean) => {
|
|
||||||
const update = platform.setDisplayBackend?.(checked ? "wayland" : "auto")
|
|
||||||
if (!update) return
|
|
||||||
void update.finally(() => {
|
|
||||||
void refetchDisplayBackend()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const onPinchZoomChange = (checked: boolean) => {
|
|
||||||
setPinchZoom(checked)
|
|
||||||
const update = platform.setPinchZoomEnabled?.(checked)
|
|
||||||
if (!update) return
|
|
||||||
void update.catch(() => setPinchZoom(!checked))
|
|
||||||
}
|
|
||||||
|
|
||||||
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
|
|
||||||
{ value: "system", label: language.t("theme.scheme.system") },
|
|
||||||
{ value: "light", label: language.t("theme.scheme.light") },
|
|
||||||
{ value: "dark", label: language.t("theme.scheme.dark") },
|
|
||||||
])
|
|
||||||
|
|
||||||
const languageOptions = createMemo(() =>
|
|
||||||
language.locales.map((locale) => ({
|
|
||||||
value: locale,
|
|
||||||
label: language.label(locale),
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
|
|
||||||
const noneSound = { id: "none", label: "sound.option.none" } as const
|
|
||||||
const soundOptions = [noneSound, ...SOUND_OPTIONS]
|
|
||||||
const mono = () => monoInput(settings.appearance.font())
|
|
||||||
const sans = () => sansInput(settings.appearance.uiFont())
|
|
||||||
const terminal = () => terminalInput(settings.appearance.terminalFont())
|
|
||||||
|
|
||||||
const soundSelectProps = (
|
|
||||||
enabled: () => boolean,
|
|
||||||
current: () => string,
|
|
||||||
setEnabled: (value: boolean) => void,
|
|
||||||
set: (id: string) => void,
|
|
||||||
) => ({
|
|
||||||
options: soundOptions,
|
|
||||||
current: enabled() ? (soundOptions.find((o) => o.id === current()) ?? noneSound) : noneSound,
|
|
||||||
value: (o: (typeof soundOptions)[number]) => o.id,
|
|
||||||
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
|
|
||||||
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
|
|
||||||
if (!option) return
|
|
||||||
playDemoSound(option.id === "none" ? undefined : option.id)
|
|
||||||
},
|
|
||||||
onSelect: (option: (typeof soundOptions)[number] | undefined) => {
|
|
||||||
if (!option) return
|
|
||||||
if (option.id === "none") {
|
|
||||||
setEnabled(false)
|
|
||||||
stopDemoSound()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setEnabled(true)
|
|
||||||
set(option.id)
|
|
||||||
playDemoSound(option.id)
|
|
||||||
},
|
|
||||||
variant: "secondary" as const,
|
|
||||||
size: "small" as const,
|
|
||||||
triggerVariant: "settings" as const,
|
|
||||||
})
|
|
||||||
|
|
||||||
const InterfaceSection = () => (
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<SettingsList>
|
|
||||||
<SettingsRow
|
|
||||||
title={
|
|
||||||
<span class="flex items-center gap-2">
|
|
||||||
{language.t("settings.general.row.newInterface.title")}
|
|
||||||
<Tag variant="accent">{language.t("settings.general.row.newInterface.badge")}</Tag>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
description={language.t("settings.general.row.newInterface.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-new-layout-designs">
|
|
||||||
<Switch
|
|
||||||
checked={settings.general.newLayoutDesigns()}
|
|
||||||
onChange={(checked) => {
|
|
||||||
settings.general.setNewLayoutDesigns(checked)
|
|
||||||
if (!checked) return
|
|
||||||
void import("@/components/settings-v2").then((module) => {
|
|
||||||
void dialog.show(() => <module.DialogSettings />)
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
</SettingsList>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const InterfaceNoticeSection = () => (
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<SettingsList>
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.newInterfaceNotice.title")}
|
|
||||||
description={language.t("settings.general.row.newInterfaceNotice.description")}
|
|
||||||
>
|
|
||||||
<Button size="small" variant="ghost" onClick={settings.general.dismissNewInterfaceNotice}>
|
|
||||||
{language.t("settings.general.row.newInterfaceNotice.dismiss")}
|
|
||||||
</Button>
|
|
||||||
</SettingsRow>
|
|
||||||
</SettingsList>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const GeneralSection = () => (
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<SettingsList>
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.language.title")}
|
|
||||||
description={language.t("settings.general.row.language.description")}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
data-action="settings-language"
|
|
||||||
options={languageOptions()}
|
|
||||||
current={languageOptions().find((o) => o.value === language.locale())}
|
|
||||||
value={(o) => o.value}
|
|
||||||
label={(o) => o.label}
|
|
||||||
onSelect={(option) => option && language.setLocale(option.value)}
|
|
||||||
variant="secondary"
|
|
||||||
size="small"
|
|
||||||
triggerVariant="settings"
|
|
||||||
/>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("command.permissions.autoaccept.enable")}
|
|
||||||
description={language.t("toast.permissions.autoaccept.on.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-auto-accept-permissions">
|
|
||||||
<Switch checked={accepting()} disabled={!dir()} onChange={toggleAccept} />
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.shell.title")}
|
|
||||||
description={language.t("settings.general.row.shell.description")}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
data-action="settings-shell"
|
|
||||||
disabled
|
|
||||||
options={shellOptions()}
|
|
||||||
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
|
|
||||||
value={(o) => o.id}
|
|
||||||
label={(o) => o.label}
|
|
||||||
onSelect={(option) => {
|
|
||||||
if (!option) return
|
|
||||||
if (option.value === currentShell()) return
|
|
||||||
// TODO: Restore config writes when the V2 client exposes a config API.
|
|
||||||
// void serverSync().updateConfig({ shell: option.value })
|
|
||||||
}}
|
|
||||||
variant="secondary"
|
|
||||||
size="small"
|
|
||||||
triggerVariant="settings"
|
|
||||||
triggerStyle={{ "min-width": "180px" }}
|
|
||||||
/>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.reasoningSummaries.title")}
|
|
||||||
description={language.t("settings.general.row.reasoningSummaries.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-feed-reasoning-summaries">
|
|
||||||
<Switch
|
|
||||||
checked={settings.general.showReasoningSummaries()}
|
|
||||||
onChange={(checked) => settings.general.setShowReasoningSummaries(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
|
|
||||||
description={language.t("settings.general.row.shellToolPartsExpanded.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-feed-shell-tool-parts-expanded">
|
|
||||||
<Switch
|
|
||||||
checked={settings.general.shellToolPartsExpanded()}
|
|
||||||
onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.editToolPartsExpanded.title")}
|
|
||||||
description={language.t("settings.general.row.editToolPartsExpanded.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-feed-edit-tool-parts-expanded">
|
|
||||||
<Switch
|
|
||||||
checked={settings.general.editToolPartsExpanded()}
|
|
||||||
onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
</SettingsList>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const AdvancedSection = () => (
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.advanced")}</h3>
|
|
||||||
|
|
||||||
<SettingsList>
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.showFileTree.title")}
|
|
||||||
description={language.t("settings.general.row.showFileTree.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-show-file-tree">
|
|
||||||
<Switch
|
|
||||||
checked={settings.general.showFileTree()}
|
|
||||||
onChange={(checked) => settings.general.setShowFileTree(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.showNavigation.title")}
|
|
||||||
description={language.t("settings.general.row.showNavigation.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-show-navigation">
|
|
||||||
<Switch
|
|
||||||
checked={settings.general.showNavigation()}
|
|
||||||
onChange={(checked) => settings.general.setShowNavigation(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.showSearch.title")}
|
|
||||||
description={language.t("settings.general.row.showSearch.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-show-search">
|
|
||||||
<Switch
|
|
||||||
checked={settings.general.showSearch()}
|
|
||||||
onChange={(checked) => settings.general.setShowSearch(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.showStatus.title")}
|
|
||||||
description={language.t("settings.general.row.showStatus.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-show-status">
|
|
||||||
<Switch
|
|
||||||
checked={settings.general.showStatus()}
|
|
||||||
onChange={(checked) => settings.general.setShowStatus(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.showCustomAgents.title")}
|
|
||||||
description={language.t("settings.general.row.showCustomAgents.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-show-custom-agents">
|
|
||||||
<Switch
|
|
||||||
checked={settings.general.showCustomAgents()}
|
|
||||||
onChange={(checked) => settings.general.setShowCustomAgents(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
</SettingsList>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const AppearanceSection = () => (
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
|
|
||||||
|
|
||||||
<SettingsList>
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.colorScheme.title")}
|
|
||||||
description={language.t("settings.general.row.colorScheme.description")}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
data-action="settings-color-scheme"
|
|
||||||
options={colorSchemeOptions()}
|
|
||||||
current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
|
|
||||||
value={(o) => o.value}
|
|
||||||
label={(o) => o.label}
|
|
||||||
onSelect={(option) => option && theme.setColorScheme(option.value)}
|
|
||||||
variant="secondary"
|
|
||||||
size="small"
|
|
||||||
triggerVariant="settings"
|
|
||||||
triggerStyle={{ "min-width": "220px" }}
|
|
||||||
/>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.theme.title")}
|
|
||||||
description={
|
|
||||||
<>
|
|
||||||
{language.t("settings.general.row.theme.description")}{" "}
|
|
||||||
<Link href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</Link>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
data-action="settings-theme"
|
|
||||||
options={themeOptions()}
|
|
||||||
current={themeOptions().find((o) => o.id === theme.themeId())}
|
|
||||||
value={(o) => o.id}
|
|
||||||
label={(o) => o.name}
|
|
||||||
onSelect={(option) => {
|
|
||||||
if (!option) return
|
|
||||||
theme.setTheme(option.id)
|
|
||||||
}}
|
|
||||||
variant="secondary"
|
|
||||||
size="small"
|
|
||||||
triggerVariant="settings"
|
|
||||||
/>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.uiFont.title")}
|
|
||||||
description={language.t("settings.general.row.uiFont.description")}
|
|
||||||
>
|
|
||||||
<div class="w-full sm:w-[220px]">
|
|
||||||
<TextField
|
|
||||||
data-action="settings-ui-font"
|
|
||||||
label={language.t("settings.general.row.uiFont.title")}
|
|
||||||
hideLabel
|
|
||||||
type="text"
|
|
||||||
value={sans()}
|
|
||||||
onChange={(value) => settings.appearance.setUIFont(value)}
|
|
||||||
placeholder={sansDefault}
|
|
||||||
spellcheck={false}
|
|
||||||
autocorrect="off"
|
|
||||||
autocomplete="off"
|
|
||||||
autocapitalize="off"
|
|
||||||
class="text-12-regular"
|
|
||||||
style={{ "font-family": sansFontFamily(settings.appearance.uiFont()) }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.font.title")}
|
|
||||||
description={language.t("settings.general.row.font.description")}
|
|
||||||
>
|
|
||||||
<div class="w-full sm:w-[220px]">
|
|
||||||
<TextField
|
|
||||||
data-action="settings-code-font"
|
|
||||||
label={language.t("settings.general.row.font.title")}
|
|
||||||
hideLabel
|
|
||||||
type="text"
|
|
||||||
value={mono()}
|
|
||||||
onChange={(value) => settings.appearance.setFont(value)}
|
|
||||||
placeholder={monoDefault}
|
|
||||||
spellcheck={false}
|
|
||||||
autocorrect="off"
|
|
||||||
autocomplete="off"
|
|
||||||
autocapitalize="off"
|
|
||||||
class="text-12-regular"
|
|
||||||
style={{ "font-family": monoFontFamily(settings.appearance.font()) }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.terminalFont.title")}
|
|
||||||
description={language.t("settings.general.row.terminalFont.description")}
|
|
||||||
>
|
|
||||||
<div class="w-full sm:w-[220px]">
|
|
||||||
<TextField
|
|
||||||
data-action="settings-terminal-font"
|
|
||||||
label={language.t("settings.general.row.terminalFont.title")}
|
|
||||||
hideLabel
|
|
||||||
type="text"
|
|
||||||
value={terminal()}
|
|
||||||
onChange={(value) => settings.appearance.setTerminalFont(value)}
|
|
||||||
placeholder={terminalDefault}
|
|
||||||
spellcheck={false}
|
|
||||||
autocorrect="off"
|
|
||||||
autocomplete="off"
|
|
||||||
autocapitalize="off"
|
|
||||||
class="text-12-regular"
|
|
||||||
style={{ "font-family": terminalFontFamily(settings.appearance.terminalFont()) }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
</SettingsList>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const NotificationsSection = () => (
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3>
|
|
||||||
|
|
||||||
<SettingsList>
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.notifications.agent.title")}
|
|
||||||
description={language.t("settings.general.notifications.agent.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-notifications-agent">
|
|
||||||
<Switch
|
|
||||||
checked={settings.notifications.agent()}
|
|
||||||
onChange={(checked) => settings.notifications.setAgent(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.notifications.permissions.title")}
|
|
||||||
description={language.t("settings.general.notifications.permissions.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-notifications-permissions">
|
|
||||||
<Switch
|
|
||||||
checked={settings.notifications.permissions()}
|
|
||||||
onChange={(checked) => settings.notifications.setPermissions(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.notifications.errors.title")}
|
|
||||||
description={language.t("settings.general.notifications.errors.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-notifications-errors">
|
|
||||||
<Switch
|
|
||||||
checked={settings.notifications.errors()}
|
|
||||||
onChange={(checked) => settings.notifications.setErrors(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
</SettingsList>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const SoundsSection = () => (
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3>
|
|
||||||
|
|
||||||
<SettingsList>
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.sounds.agent.title")}
|
|
||||||
description={language.t("settings.general.sounds.agent.description")}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
data-action="settings-sounds-agent"
|
|
||||||
{...soundSelectProps(
|
|
||||||
() => settings.sounds.agentEnabled(),
|
|
||||||
() => settings.sounds.agent(),
|
|
||||||
(value) => settings.sounds.setAgentEnabled(value),
|
|
||||||
(id) => settings.sounds.setAgent(id),
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.sounds.permissions.title")}
|
|
||||||
description={language.t("settings.general.sounds.permissions.description")}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
data-action="settings-sounds-permissions"
|
|
||||||
{...soundSelectProps(
|
|
||||||
() => settings.sounds.permissionsEnabled(),
|
|
||||||
() => settings.sounds.permissions(),
|
|
||||||
(value) => settings.sounds.setPermissionsEnabled(value),
|
|
||||||
(id) => settings.sounds.setPermissions(id),
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.sounds.errors.title")}
|
|
||||||
description={language.t("settings.general.sounds.errors.description")}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
data-action="settings-sounds-errors"
|
|
||||||
{...soundSelectProps(
|
|
||||||
() => settings.sounds.errorsEnabled(),
|
|
||||||
() => settings.sounds.errors(),
|
|
||||||
(value) => settings.sounds.setErrorsEnabled(value),
|
|
||||||
(id) => settings.sounds.setErrors(id),
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</SettingsRow>
|
|
||||||
</SettingsList>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const UpdatesSection = () => (
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
|
|
||||||
|
|
||||||
<SettingsList>
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.releaseNotes.title")}
|
|
||||||
description={language.t("settings.general.row.releaseNotes.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-release-notes">
|
|
||||||
<Switch
|
|
||||||
checked={settings.general.releaseNotes()}
|
|
||||||
onChange={(checked) => settings.general.setReleaseNotes(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.updates.row.check.title")}
|
|
||||||
description={language.t("settings.updates.row.check.description")}
|
|
||||||
>
|
|
||||||
<Button size="small" variant="secondary" disabled={!updater.action().run} onClick={updater.run}>
|
|
||||||
{language.t(updater.action().label)}
|
|
||||||
</Button>
|
|
||||||
</SettingsRow>
|
|
||||||
</SettingsList>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const DisplaySection = () => (
|
|
||||||
<Show when={desktop()}>
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
|
|
||||||
|
|
||||||
<SettingsList>
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.pinchZoom.title")}
|
|
||||||
description={language.t("settings.general.row.pinchZoom.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-pinch-zoom">
|
|
||||||
<Switch checked={pinchZoom.latest} onChange={onPinchZoomChange} />
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<Show when={linux()}>
|
|
||||||
<SettingsRow
|
|
||||||
title={
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span>{language.t("settings.general.row.wayland.title")}</span>
|
|
||||||
<Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
|
|
||||||
<span class="text-text-weak">
|
|
||||||
<Icon name="help" size="small" />
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
description={language.t("settings.general.row.wayland.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-wayland">
|
|
||||||
<Switch checked={displayBackend.latest === "wayland"} onChange={onDisplayBackendChange} />
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
</Show>
|
|
||||||
</SettingsList>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
|
||||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
|
||||||
<div class="flex flex-col gap-1 pt-6 pb-8">
|
|
||||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.tab.general")}</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-8 w-full">
|
|
||||||
<Show when={settings.general.layoutTransitionAvailable()}>
|
|
||||||
<InterfaceSection />
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={settings.general.newInterfaceNoticeVisible()}>
|
|
||||||
<InterfaceNoticeSection />
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<GeneralSection />
|
|
||||||
|
|
||||||
<AppearanceSection />
|
|
||||||
|
|
||||||
<NotificationsSection />
|
|
||||||
|
|
||||||
<SoundsSection />
|
|
||||||
|
|
||||||
<UpdatesSection />
|
|
||||||
|
|
||||||
<DisplaySection />
|
|
||||||
|
|
||||||
<Show when={desktop()}>
|
|
||||||
<AdvancedSection />
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SettingsRowProps {
|
|
||||||
title: string | JSX.Element
|
|
||||||
description: string | JSX.Element
|
|
||||||
children: JSX.Element
|
|
||||||
}
|
|
||||||
|
|
||||||
const SettingsRow: Component<SettingsRowProps> = (props) => {
|
|
||||||
return (
|
|
||||||
<div class="flex flex-wrap items-center gap-4 py-3 border-b border-border-weak-base last:border-none sm:flex-nowrap">
|
|
||||||
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
||||||
<span class="text-14-medium text-text-strong">{props.title}</span>
|
|
||||||
<span class="text-12-regular text-text-weak">{props.description}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex w-full justify-end sm:w-auto sm:shrink-0">{props.children}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
|
||||||
import { Switch } from "@opencode-ai/ui/switch"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
|
||||||
import { type Component, For, Show } from "solid-js"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { useModels } from "@/context/models"
|
|
||||||
import { popularProviders } from "@/hooks/use-providers"
|
|
||||||
import { SettingsList } from "./settings-list"
|
|
||||||
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
|
|
||||||
|
|
||||||
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
|
|
||||||
|
|
||||||
const ListLoadingState: Component<{ label: string }> = (props) => {
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
|
||||||
<span class="text-14-regular text-text-weak">{props.label}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ListEmptyState: Component<{ message: string; filter: string }> = (props) => {
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
|
||||||
<span class="text-14-regular text-text-weak">{props.message}</span>
|
|
||||||
<Show when={props.filter}>
|
|
||||||
<span class="text-14-regular text-text-strong mt-1">"{props.filter}"</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SettingsModels: Component = () => {
|
|
||||||
return (
|
|
||||||
<SettingsServerScope>
|
|
||||||
<SettingsModelsContent />
|
|
||||||
</SettingsServerScope>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const SettingsModelsContent: Component = () => {
|
|
||||||
const language = useLanguage()
|
|
||||||
const models = useModels()
|
|
||||||
|
|
||||||
const list = useFilteredList<ModelItem>({
|
|
||||||
items: (_filter) => models.list(),
|
|
||||||
key: (x) => `${x.provider.id}:${x.id}`,
|
|
||||||
filterKeys: ["provider.name", "name", "id"],
|
|
||||||
sortBy: (a, b) => a.name.localeCompare(b.name),
|
|
||||||
groupBy: (x) => x.provider.id,
|
|
||||||
sortGroupsBy: (a, b) => {
|
|
||||||
const aIndex = popularProviders.indexOf(a.category)
|
|
||||||
const bIndex = popularProviders.indexOf(b.category)
|
|
||||||
const aPopular = aIndex >= 0
|
|
||||||
const bPopular = bIndex >= 0
|
|
||||||
|
|
||||||
if (aPopular && !bPopular) return -1
|
|
||||||
if (!aPopular && bPopular) return 1
|
|
||||||
if (aPopular && bPopular) return aIndex - bIndex
|
|
||||||
|
|
||||||
const aName = a.items[0].provider.name
|
|
||||||
const bName = b.items[0].provider.name
|
|
||||||
return aName.localeCompare(bName)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
|
||||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
|
||||||
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
|
||||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2>
|
|
||||||
<SettingsServerPicker />
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
|
|
||||||
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
|
|
||||||
<TextField
|
|
||||||
variant="ghost"
|
|
||||||
type="text"
|
|
||||||
value={list.filter()}
|
|
||||||
onChange={list.onInput}
|
|
||||||
placeholder={language.t("dialog.model.search.placeholder")}
|
|
||||||
spellcheck={false}
|
|
||||||
autocorrect="off"
|
|
||||||
autocomplete="off"
|
|
||||||
autocapitalize="off"
|
|
||||||
class="flex-1"
|
|
||||||
/>
|
|
||||||
<Show when={list.filter()}>
|
|
||||||
<IconButton icon="circle-x" variant="ghost" onClick={list.clear} />
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-8 max-w-[720px]">
|
|
||||||
<Show
|
|
||||||
when={!list.grouped.loading}
|
|
||||||
fallback={
|
|
||||||
<ListLoadingState label={`${language.t("common.loading")}${language.t("common.loading.ellipsis")}`} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={list.flat().length > 0}
|
|
||||||
fallback={<ListEmptyState message={language.t("dialog.model.empty")} filter={list.filter()} />}
|
|
||||||
>
|
|
||||||
<For each={list.grouped.latest}>
|
|
||||||
{(group) => (
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<div class="flex items-center gap-2 pb-2">
|
|
||||||
<ProviderIcon id={group.category} class="size-5 shrink-0 icon-strong-base" />
|
|
||||||
<span class="text-14-medium text-text-strong">{group.items[0].provider.name}</span>
|
|
||||||
</div>
|
|
||||||
<SettingsList>
|
|
||||||
<For each={group.items}>
|
|
||||||
{(item) => {
|
|
||||||
const key = { providerID: item.provider.id, modelID: item.id }
|
|
||||||
return (
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
|
||||||
<div class="min-w-0">
|
|
||||||
<span class="text-14-regular text-text-strong truncate block">{item.name}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<Switch
|
|
||||||
checked={models.visible(key)}
|
|
||||||
onChange={(checked) => {
|
|
||||||
models.setVisibility(key, checked)
|
|
||||||
}}
|
|
||||||
hideLabel
|
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</SettingsList>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
import { Button } from "@opencode-ai/ui/button"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
|
||||||
import { Tag } from "@opencode-ai/ui/tag"
|
|
||||||
import { showToast } from "@/utils/toast"
|
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
|
||||||
import { createMemo, type Component, For, Show } from "solid-js"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
|
||||||
import { useServerSync } from "@/context/server-sync"
|
|
||||||
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
|
|
||||||
import { DialogCustomProvider } from "./dialog-custom-provider"
|
|
||||||
import { SettingsList } from "./settings-list"
|
|
||||||
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
|
|
||||||
|
|
||||||
type ProviderSource = "env" | "api" | "config" | "custom"
|
|
||||||
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
|
|
||||||
|
|
||||||
const PROVIDER_NOTES = [
|
|
||||||
{ match: (id: string) => id === "opencode", key: "dialog.provider.opencode.note" },
|
|
||||||
{ match: (id: string) => id === "opencode-go", key: "dialog.provider.opencodeGo.tagline" },
|
|
||||||
{ match: (id: string) => id === "anthropic", key: "dialog.provider.anthropic.note" },
|
|
||||||
{ match: (id: string) => id.startsWith("github-copilot"), key: "dialog.provider.copilot.note" },
|
|
||||||
{ match: (id: string) => id === "openai", key: "dialog.provider.openai.note" },
|
|
||||||
{ match: (id: string) => id === "google", key: "dialog.provider.google.note" },
|
|
||||||
{ match: (id: string) => id === "openrouter", key: "dialog.provider.openrouter.note" },
|
|
||||||
{ match: (id: string) => id === "vercel", key: "dialog.provider.vercel.note" },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
export const SettingsProviders: Component<{ onBack?: () => void }> = (props) => {
|
|
||||||
return (
|
|
||||||
<SettingsServerScope>
|
|
||||||
<SettingsProvidersContent onBack={props.onBack} />
|
|
||||||
</SettingsServerScope>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) => {
|
|
||||||
const dialog = useDialog()
|
|
||||||
const language = useLanguage()
|
|
||||||
const serverSDK = useServerSDK()
|
|
||||||
const serverSync = useServerSync()
|
|
||||||
const providers = useProviders(() => undefined)
|
|
||||||
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
|
||||||
|
|
||||||
const connect = (provider?: string) => {
|
|
||||||
providerConnect.select(provider)
|
|
||||||
void dialog.show(() => <DialogConnectProvider controller={providerConnect} />)
|
|
||||||
}
|
|
||||||
|
|
||||||
const connected = createMemo(() => {
|
|
||||||
return providers
|
|
||||||
.connected()
|
|
||||||
.filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input))
|
|
||||||
})
|
|
||||||
|
|
||||||
const popular = createMemo(() => {
|
|
||||||
const connectedIDs = new Set(connected().map((p) => p.id))
|
|
||||||
const items = providers
|
|
||||||
.popular()
|
|
||||||
.filter((p) => !connectedIDs.has(p.id))
|
|
||||||
.slice()
|
|
||||||
items.sort((a, b) => popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id))
|
|
||||||
return items
|
|
||||||
})
|
|
||||||
|
|
||||||
const source = (item: ProviderItem): ProviderSource | undefined => {
|
|
||||||
if (!("source" in item)) return
|
|
||||||
const value = item.source
|
|
||||||
if (value === "env" || value === "api" || value === "config" || value === "custom") return value
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const type = (item: ProviderItem) => {
|
|
||||||
const current = source(item)
|
|
||||||
if (current === "env") return language.t("settings.providers.tag.environment")
|
|
||||||
if (current === "api") return language.t("provider.connect.method.apiKey")
|
|
||||||
if (current === "config") {
|
|
||||||
if (isConfigCustom(item.id)) return language.t("settings.providers.tag.custom")
|
|
||||||
return language.t("settings.providers.tag.config")
|
|
||||||
}
|
|
||||||
if (current === "custom") return language.t("settings.providers.tag.custom")
|
|
||||||
return language.t("settings.providers.tag.other")
|
|
||||||
}
|
|
||||||
|
|
||||||
const canDisconnect = (item: ProviderItem) => source(item) !== "env" && !isConfigCustom(item.id)
|
|
||||||
|
|
||||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
|
||||||
|
|
||||||
const isConfigCustom = (providerID: string) => {
|
|
||||||
const provider = serverSync().data.config.provider?.[providerID]
|
|
||||||
if (!provider) return false
|
|
||||||
if (provider.npm !== "@ai-sdk/openai-compatible") return false
|
|
||||||
if (!provider.models || Object.keys(provider.models).length === 0) return false
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const disableProvider = async (providerID: string, name: string) => {
|
|
||||||
return
|
|
||||||
const before = serverSync().data.config.disabled_providers ?? []
|
|
||||||
const next = before.includes(providerID) ? before : [...before, providerID]
|
|
||||||
serverSync().set("config", "disabled_providers", next)
|
|
||||||
|
|
||||||
await serverSync()
|
|
||||||
.updateConfig({ disabled_providers: next })
|
|
||||||
.then(() => {
|
|
||||||
showToast({
|
|
||||||
variant: "success",
|
|
||||||
icon: "circle-check",
|
|
||||||
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
|
|
||||||
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.catch((err: unknown) => {
|
|
||||||
serverSync().set("config", "disabled_providers", before)
|
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
|
||||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const disconnect = async (providerID: string, name: string) => {
|
|
||||||
await serverSDK()
|
|
||||||
.api.integration.get({ integrationID: providerID })
|
|
||||||
.then(async (integration) => {
|
|
||||||
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
|
|
||||||
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
|
|
||||||
await Promise.all(
|
|
||||||
credentials.map((credential) => serverSDK().api.credential.remove({ credentialID: credential.id })),
|
|
||||||
)
|
|
||||||
showToast({
|
|
||||||
variant: "success",
|
|
||||||
icon: "circle-check",
|
|
||||||
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
|
|
||||||
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.catch((err: unknown) => {
|
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
|
||||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
|
||||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
|
||||||
<div class="flex items-center justify-between gap-4 pt-6 pb-8 max-w-[720px]">
|
|
||||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.providers.title")}</h2>
|
|
||||||
<SettingsServerPicker />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-8 max-w-[720px]">
|
|
||||||
<div class="flex flex-col gap-1" data-component="connected-providers-section">
|
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.connected")}</h3>
|
|
||||||
<SettingsList>
|
|
||||||
<Show
|
|
||||||
when={connected().length > 0}
|
|
||||||
fallback={
|
|
||||||
<div class="py-4 text-14-regular text-text-weak">
|
|
||||||
{language.t("settings.providers.connected.empty")}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<For each={connected()}>
|
|
||||||
{(item) => (
|
|
||||||
<div class="group flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
|
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
|
||||||
<ProviderIcon id={item.id} class="size-5 shrink-0 icon-strong-base" />
|
|
||||||
<span class="text-14-medium text-text-strong truncate">{item.name}</span>
|
|
||||||
<Tag>{type(item)}</Tag>
|
|
||||||
</div>
|
|
||||||
<Show
|
|
||||||
when={canDisconnect(item)}
|
|
||||||
fallback={
|
|
||||||
<span class="text-14-regular text-text-base opacity-0 group-hover:opacity-100 transition-opacity duration-200 pr-3 cursor-default">
|
|
||||||
{language.t("settings.providers.connected.environmentDescription")}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Button size="large" variant="ghost" onClick={() => void disconnect(item.id, item.name)}>
|
|
||||||
{language.t("common.disconnect")}
|
|
||||||
</Button>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</SettingsList>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.popular")}</h3>
|
|
||||||
<SettingsList>
|
|
||||||
<For each={popular()}>
|
|
||||||
{(item) => (
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
|
|
||||||
<div class="flex flex-col min-w-0">
|
|
||||||
<div class="flex items-center gap-x-3">
|
|
||||||
<ProviderIcon id={item.id} class="size-5 shrink-0 icon-strong-base" />
|
|
||||||
<span class="text-14-medium text-text-strong">{item.name}</span>
|
|
||||||
<Show when={item.id === "opencode"}>
|
|
||||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
|
||||||
</Show>
|
|
||||||
<Show when={item.id === "opencode-go"}>
|
|
||||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<Show when={note(item.id)}>
|
|
||||||
{(key) => <span class="text-12-regular text-text-weak pl-8">{language.t(key())}</span>}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<Button size="large" variant="secondary" icon="plus-small" onClick={() => connect(item.id)}>
|
|
||||||
{language.t("common.connect")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
|
|
||||||
<Show when={false}>
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-between gap-4 min-h-16 border-b border-border-weak-base last:border-none flex-wrap py-3"
|
|
||||||
data-component="custom-provider-section"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col min-w-0">
|
|
||||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
||||||
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
|
|
||||||
<span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span>
|
|
||||||
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
|
||||||
</div>
|
|
||||||
<span class="text-12-regular text-text-weak pl-8">
|
|
||||||
{language.t("settings.providers.custom.description")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="large"
|
|
||||||
variant="secondary"
|
|
||||||
icon="plus-small"
|
|
||||||
onClick={() => {
|
|
||||||
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{language.t("common.connect")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</SettingsList>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
class="px-0 py-0 mt-5 text-14-medium text-text-interactive-base text-left justify-start hover:bg-transparent active:bg-transparent"
|
|
||||||
onClick={() => connect()}
|
|
||||||
>
|
|
||||||
{language.t("dialog.provider.viewAll")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import { Button } from "@opencode-ai/ui/button"
|
|
||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
|
||||||
import { QueryClientProvider } from "@tanstack/solid-query"
|
|
||||||
import { createMemo, For, type ParentProps, Show } from "solid-js"
|
|
||||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
|
||||||
import { ModelsProvider } from "@/context/models"
|
|
||||||
import { ServerConnection } from "@/context/server"
|
|
||||||
import { ServerSDKProvider } from "@/context/server-sdk"
|
|
||||||
import { ServerSyncProvider } from "@/context/server-sync"
|
|
||||||
import { useGlobal } from "@/context/global"
|
|
||||||
import { useSettings } from "@/context/settings"
|
|
||||||
|
|
||||||
export function SettingsServerScope(props: ParentProps) {
|
|
||||||
const global = useGlobal()
|
|
||||||
const settings = useSettings()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Show when={settings.general.newLayoutDesigns()} fallback={props.children}>
|
|
||||||
<Show when={global.settings.server.selected()}>
|
|
||||||
{(server) => <SettingsServerDataProviders server={server()}>{props.children}</SettingsServerDataProviders>}
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SettingsServerDataProviders(props: ParentProps<{ server: ServerConnection.Any }>) {
|
|
||||||
const global = useGlobal()
|
|
||||||
const serverCtx = () => global.ensureServerCtx(props.server)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<QueryClientProvider client={serverCtx().queryClient}>
|
|
||||||
<ServerSDKProvider server={() => props.server}>
|
|
||||||
<ServerSyncProvider>
|
|
||||||
<ModelsProvider>{props.children}</ModelsProvider>
|
|
||||||
</ServerSyncProvider>
|
|
||||||
</ServerSDKProvider>
|
|
||||||
</QueryClientProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingsServerPicker() {
|
|
||||||
const global = useGlobal()
|
|
||||||
const settings = useSettings()
|
|
||||||
const selected = createMemo(() =>
|
|
||||||
settings.general.newLayoutDesigns() ? global.settings.server.selected() : undefined,
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Show when={selected()}>
|
|
||||||
{(conn) => (
|
|
||||||
<DropdownMenu gutter={4} placement="bottom-end">
|
|
||||||
<DropdownMenu.Trigger
|
|
||||||
as={Button}
|
|
||||||
variant="secondary"
|
|
||||||
size="large"
|
|
||||||
class="h-8 max-w-[260px] gap-2 px-2 py-1.5 data-[expanded]:bg-surface-base-active"
|
|
||||||
>
|
|
||||||
<ServerHealthIndicator health={global.servers.health[ServerConnection.key(conn())]} />
|
|
||||||
<ServerRow
|
|
||||||
conn={conn()}
|
|
||||||
status={global.servers.health[ServerConnection.key(conn())]}
|
|
||||||
class="flex items-center gap-2 min-w-0 flex-1"
|
|
||||||
nameClass="text-14-regular text-text-base truncate"
|
|
||||||
versionClass="hidden"
|
|
||||||
/>
|
|
||||||
<Icon name="chevron-down" size="small" class="text-icon-weak shrink-0" />
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Portal>
|
|
||||||
<DropdownMenu.Content class="w-[320px] mt-1 [&_[data-slot=dropdown-menu-radio-item]]:pl-2 [&_[data-slot=dropdown-menu-radio-item]]:pr-2">
|
|
||||||
<DropdownMenu.RadioGroup
|
|
||||||
value={global.settings.server.key}
|
|
||||||
onChange={(key) => {
|
|
||||||
if (typeof key === "string") global.settings.server.set(ServerConnection.Key.make(key))
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<For each={global.servers.list()}>
|
|
||||||
{(item) => {
|
|
||||||
const key = ServerConnection.key(item)
|
|
||||||
const blocked = () => global.servers.health[key]?.healthy === false
|
|
||||||
return (
|
|
||||||
<DropdownMenu.RadioItem value={key} disabled={blocked()}>
|
|
||||||
<ServerHealthIndicator health={global.servers.health[key]} />
|
|
||||||
<ServerRow
|
|
||||||
conn={item}
|
|
||||||
dimmed={blocked()}
|
|
||||||
status={global.servers.health[key]}
|
|
||||||
class="flex items-center gap-2 min-w-0 flex-1"
|
|
||||||
nameClass="text-14-regular text-text-base truncate"
|
|
||||||
versionClass="text-12-regular text-text-weak truncate"
|
|
||||||
/>
|
|
||||||
<DropdownMenu.ItemIndicator>
|
|
||||||
<Icon name="check-small" size="small" class="text-icon-weak" />
|
|
||||||
</DropdownMenu.ItemIndicator>
|
|
||||||
</DropdownMenu.RadioItem>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</DropdownMenu.RadioGroup>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Portal>
|
|
||||||
</DropdownMenu>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { Show, type Component } from "solid-js"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { ServerConnectionForm, ServerConnectionList, useServerManagementController } from "./dialog-select-server"
|
|
||||||
|
|
||||||
export const SettingsServers: Component = () => {
|
|
||||||
const language = useLanguage()
|
|
||||||
const controller = useServerManagementController()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
|
||||||
<div class="flex flex-col flex-1 min-h-0 max-w-[720px]">
|
|
||||||
<Show
|
|
||||||
when={controller.isFormMode()}
|
|
||||||
fallback={
|
|
||||||
<>
|
|
||||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
|
||||||
<div class="flex flex-col gap-1 pt-6 pb-8">
|
|
||||||
<h2 class="text-16-medium text-text-strong">{language.t("status.popover.tab.servers")}</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ServerConnectionList controller={controller} />
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="flex flex-1 min-h-0 flex-col gap-4 pt-6">
|
|
||||||
<div class="text-16-medium text-text-strong">{controller.formTitle()}</div>
|
|
||||||
<ServerConnectionForm controller={controller} />
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||||||
import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { type ServerConnection } from "@/context/server"
|
import { type ServerConnection } from "@/context/server"
|
||||||
import { useServerManagementController } from "../dialog-select-server"
|
import { useServerFormController } from "../server/server-management-controller"
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
|
|
||||||
export const DialogServerV2: Component<{
|
export const DialogServerV2: Component<{
|
||||||
@@ -15,39 +15,39 @@ export const DialogServerV2: Component<{
|
|||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const controller = useServerManagementController({
|
const form = useServerFormController({
|
||||||
onSelect: () => dialog.close(),
|
onSelect: () => dialog.close(),
|
||||||
navigateOnAdd: false,
|
navigateOnAdd: false,
|
||||||
})
|
})
|
||||||
const [opened, setOpened] = createSignal(false)
|
const [opened, setOpened] = createSignal(false)
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (props.mode === "add") controller.startAdd()
|
if (props.mode === "add") form.start.add()
|
||||||
if (props.mode === "edit" && props.server) controller.startEdit(props.server)
|
if (props.mode === "edit" && props.server) form.start.edit(props.server)
|
||||||
setOpened(true)
|
setOpened(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
controller.resetForm()
|
form.reset()
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!opened()) return
|
if (!opened()) return
|
||||||
if (controller.isFormMode()) return
|
if (form.state.open()) return
|
||||||
dialog.close()
|
dialog.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
const keyDown = (event: KeyboardEvent) => {
|
const keyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key !== "Enter" || event.isComposing) return
|
if (event.key !== "Enter" || event.isComposing) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
controller.submitForm()
|
form.submit()
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = () =>
|
const title = () =>
|
||||||
props.mode === "add" ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")
|
props.mode === "add" ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")
|
||||||
|
|
||||||
const submitLabel = () => {
|
const submitLabel = () => {
|
||||||
if (controller.formBusy()) return language.t("dialog.server.add.checking")
|
if (form.state.busy()) return language.t("dialog.server.add.checking")
|
||||||
if (props.mode === "add") return language.t("dialog.server.add.button")
|
if (props.mode === "add") return language.t("dialog.server.add.button")
|
||||||
return language.t("common.save")
|
return language.t("common.save")
|
||||||
}
|
}
|
||||||
@@ -66,16 +66,16 @@ export const DialogServerV2: Component<{
|
|||||||
type="text"
|
type="text"
|
||||||
appearance="large"
|
appearance="large"
|
||||||
class="!w-full self-stretch"
|
class="!w-full self-stretch"
|
||||||
value={controller.formValue()}
|
value={form.state.value()}
|
||||||
placeholder={language.t("dialog.server.add.placeholder")}
|
placeholder={language.t("dialog.server.add.placeholder")}
|
||||||
invalid={!!controller.formError()}
|
invalid={!!form.state.error()}
|
||||||
disabled={controller.formBusy()}
|
disabled={form.state.busy()}
|
||||||
autofocus
|
autofocus
|
||||||
onInput={(event) => controller.handleFormChange()(event.currentTarget.value)}
|
onInput={(event) => form.change.value(event.currentTarget.value)}
|
||||||
onKeyDown={keyDown}
|
onKeyDown={keyDown}
|
||||||
/>
|
/>
|
||||||
<Show when={controller.formError()}>
|
<Show when={form.state.error()}>
|
||||||
<span class="settings-v2-server-dialog-error">{controller.formError()}</span>
|
<span class="settings-v2-server-dialog-error">{form.state.error()}</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||||
@@ -84,10 +84,10 @@ export const DialogServerV2: Component<{
|
|||||||
type="text"
|
type="text"
|
||||||
appearance="large"
|
appearance="large"
|
||||||
class="!w-full self-stretch"
|
class="!w-full self-stretch"
|
||||||
value={controller.formName()}
|
value={form.state.name()}
|
||||||
placeholder={language.t("dialog.server.add.namePlaceholder")}
|
placeholder={language.t("dialog.server.add.namePlaceholder")}
|
||||||
disabled={controller.formBusy()}
|
disabled={form.state.busy()}
|
||||||
onInput={(event) => controller.handleFormNameChange()(event.currentTarget.value)}
|
onInput={(event) => form.change.name(event.currentTarget.value)}
|
||||||
onKeyDown={keyDown}
|
onKeyDown={keyDown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,10 +98,10 @@ export const DialogServerV2: Component<{
|
|||||||
type="text"
|
type="text"
|
||||||
appearance="large"
|
appearance="large"
|
||||||
class="!w-full self-stretch"
|
class="!w-full self-stretch"
|
||||||
value={controller.formUsername()}
|
value={form.state.username()}
|
||||||
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
|
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
|
||||||
disabled={controller.formBusy()}
|
disabled={form.state.busy()}
|
||||||
onInput={(event) => controller.handleFormUsernameChange()(event.currentTarget.value)}
|
onInput={(event) => form.change.username(event.currentTarget.value)}
|
||||||
onKeyDown={keyDown}
|
onKeyDown={keyDown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,10 +111,10 @@ export const DialogServerV2: Component<{
|
|||||||
type="password"
|
type="password"
|
||||||
appearance="large"
|
appearance="large"
|
||||||
class="!w-full self-stretch"
|
class="!w-full self-stretch"
|
||||||
value={controller.formPassword()}
|
value={form.state.password()}
|
||||||
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
|
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
|
||||||
disabled={controller.formBusy()}
|
disabled={form.state.busy()}
|
||||||
onInput={(event) => controller.handleFormPasswordChange()(event.currentTarget.value)}
|
onInput={(event) => form.change.password(event.currentTarget.value)}
|
||||||
onKeyDown={keyDown}
|
onKeyDown={keyDown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,10 +122,10 @@ export const DialogServerV2: Component<{
|
|||||||
</div>
|
</div>
|
||||||
</DialogBody>
|
</DialogBody>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<ButtonV2 variant="neutral" disabled={controller.formBusy()} onClick={() => dialog.close()}>
|
<ButtonV2 variant="neutral" disabled={form.state.busy()} onClick={() => dialog.close()}>
|
||||||
{language.t("common.cancel")}
|
{language.t("common.cancel")}
|
||||||
</ButtonV2>
|
</ButtonV2>
|
||||||
<ButtonV2 variant="contrast" disabled={controller.formBusy()} onClick={controller.submitForm}>
|
<ButtonV2 variant="contrast" disabled={form.state.busy()} onClick={form.submit}>
|
||||||
{submitLabel()}
|
{submitLabel()}
|
||||||
</ButtonV2>
|
</ButtonV2>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
|||||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
@@ -27,7 +26,6 @@ import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
|||||||
import { Link } from "../link"
|
import { Link } from "../link"
|
||||||
import { SettingsListV2 } from "./parts/list"
|
import { SettingsListV2 } from "./parts/list"
|
||||||
import { SettingsRowV2 } from "./parts/row"
|
import { SettingsRowV2 } from "./parts/row"
|
||||||
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
|
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
|
|
||||||
let demoSoundState = {
|
let demoSoundState = {
|
||||||
@@ -87,7 +85,6 @@ export const SettingsGeneralV2: Component<{
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const dialog = useDialog()
|
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const mobile = createMediaQuery("(max-width: 767px)")
|
const mobile = createMediaQuery("(max-width: 767px)")
|
||||||
@@ -225,31 +222,6 @@ export const SettingsGeneralV2: Component<{
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const InterfaceSection = () => (
|
|
||||||
<LayoutTransitionToggle
|
|
||||||
title={language.t("settings.general.row.newInterface.title")}
|
|
||||||
badge={language.t("settings.general.row.newInterface.badge")}
|
|
||||||
description={language.t("settings.general.row.newInterface.description")}
|
|
||||||
checked={settings.general.newLayoutDesigns()}
|
|
||||||
onChange={(checked) => {
|
|
||||||
settings.general.setNewLayoutDesigns(checked)
|
|
||||||
if (checked) return
|
|
||||||
void import("@/components/dialog-settings").then((module) => {
|
|
||||||
void dialog.show(() => <module.DialogSettings />)
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
const InterfaceNoticeSection = () => (
|
|
||||||
<LayoutRetirementNotice
|
|
||||||
title={language.t("settings.general.row.newInterfaceNotice.title")}
|
|
||||||
description={language.t("settings.general.row.newInterfaceNotice.description")}
|
|
||||||
dismiss={language.t("settings.general.row.newInterfaceNotice.dismiss")}
|
|
||||||
onDismiss={settings.general.dismissNewInterfaceNotice}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
const GeneralSection = () => (
|
const GeneralSection = () => (
|
||||||
<div class="settings-v2-section">
|
<div class="settings-v2-section">
|
||||||
<SettingsListV2>
|
<SettingsListV2>
|
||||||
@@ -689,14 +661,6 @@ export const SettingsGeneralV2: Component<{
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-v2-tab-body">
|
<div class="settings-v2-tab-body">
|
||||||
<Show when={settings.general.layoutTransitionAvailable()}>
|
|
||||||
<InterfaceSection />
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={settings.general.newInterfaceNoticeVisible()}>
|
|
||||||
<InterfaceNoticeSection />
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<GeneralSection />
|
<GeneralSection />
|
||||||
|
|
||||||
<AppearanceSection />
|
<AppearanceSection />
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
// @ts-nocheck
|
|
||||||
import { Show } from "solid-js"
|
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
|
|
||||||
|
|
||||||
const copy = {
|
|
||||||
title: "New layout",
|
|
||||||
badge: "New",
|
|
||||||
description: "Use the new tabs and home layout. Switch between layouts for a limited time.",
|
|
||||||
noticeTitle: "You're now using new layout",
|
|
||||||
noticeDescription: "The previous layout is no longer available",
|
|
||||||
dismiss: "Dismiss",
|
|
||||||
}
|
|
||||||
|
|
||||||
function Frame(props) {
|
|
||||||
return <div class="w-[640px] max-w-full">{props.children}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToggleExample(props) {
|
|
||||||
const [state, setState] = createStore({ checked: props.checked })
|
|
||||||
return (
|
|
||||||
<Frame>
|
|
||||||
<LayoutTransitionToggle
|
|
||||||
title={copy.title}
|
|
||||||
badge={copy.badge}
|
|
||||||
description={copy.description}
|
|
||||||
checked={state.checked}
|
|
||||||
onChange={(checked) => setState("checked", checked)}
|
|
||||||
/>
|
|
||||||
</Frame>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NoticeExample() {
|
|
||||||
const [state, setState] = createStore({ dismissed: false })
|
|
||||||
return (
|
|
||||||
<Frame>
|
|
||||||
<Show when={!state.dismissed} fallback={<span class="text-v2-text-text-muted">Notice dismissed</span>}>
|
|
||||||
<LayoutRetirementNotice
|
|
||||||
title={copy.noticeTitle}
|
|
||||||
description={copy.noticeDescription}
|
|
||||||
dismiss={copy.dismiss}
|
|
||||||
onDismiss={() => setState("dismissed", true)}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</Frame>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
title: "App/Settings/Layout transition",
|
|
||||||
id: "app-settings-layout-transition",
|
|
||||||
component: LayoutTransitionToggle,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NewLayoutEnabled = {
|
|
||||||
render: () => <ToggleExample checked />,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PreviousLayoutEnabled = {
|
|
||||||
render: () => <ToggleExample checked={false} />,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PreviousLayoutRetired = {
|
|
||||||
render: () => <NoticeExample />,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AllStates = {
|
|
||||||
render: () => (
|
|
||||||
<div class="flex flex-col gap-8">
|
|
||||||
<ToggleExample checked />
|
|
||||||
<ToggleExample checked={false} />
|
|
||||||
<NoticeExample />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
|
||||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
|
||||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
|
||||||
import { SettingsListV2 } from "./parts/list"
|
|
||||||
import { SettingsRowV2 } from "./parts/row"
|
|
||||||
|
|
||||||
export function LayoutTransitionToggle(props: {
|
|
||||||
title: string
|
|
||||||
badge: string
|
|
||||||
description: string
|
|
||||||
checked: boolean
|
|
||||||
onChange: (checked: boolean) => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div class="settings-v2-section">
|
|
||||||
<div class="settings-v2-interface-feature">
|
|
||||||
<SettingsListV2>
|
|
||||||
<SettingsRowV2
|
|
||||||
title={
|
|
||||||
<span class="flex items-center gap-2">
|
|
||||||
{props.title}
|
|
||||||
<Tag variant="accent">{props.badge}</Tag>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
description={props.description}
|
|
||||||
>
|
|
||||||
<div data-action="settings-new-layout-designs">
|
|
||||||
<Switch checked={props.checked} onChange={props.onChange} />
|
|
||||||
</div>
|
|
||||||
</SettingsRowV2>
|
|
||||||
</SettingsListV2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LayoutRetirementNotice(props: {
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
dismiss: string
|
|
||||||
onDismiss: () => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div class="settings-v2-section">
|
|
||||||
<SettingsListV2>
|
|
||||||
<SettingsRowV2 title={props.title} description={props.description}>
|
|
||||||
<ButtonV2 size="small" variant="ghost-muted" onClick={props.onDismiss}>
|
|
||||||
{props.dismiss}
|
|
||||||
</ButtonV2>
|
|
||||||
</SettingsRowV2>
|
|
||||||
</SettingsListV2>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,7 @@ import { ServerRowMenu } from "@/components/server/server-row-menu"
|
|||||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection, serverName } from "@/context/server"
|
import { ServerConnection, serverName } from "@/context/server"
|
||||||
import { useServerManagementController } from "../dialog-select-server"
|
import { useServerCollectionController } from "../server/server-management-controller"
|
||||||
import { DialogServerV2 } from "./dialog-server-v2"
|
import { DialogServerV2 } from "./dialog-server-v2"
|
||||||
import { SettingsListV2 } from "./parts/list"
|
import { SettingsListV2 } from "./parts/list"
|
||||||
import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/wsl/settings"
|
import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/wsl/settings"
|
||||||
@@ -19,16 +19,16 @@ import "./settings-v2.css"
|
|||||||
export const SettingsServersV2: Component = () => {
|
export const SettingsServersV2: Component = () => {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const controller = useServerManagementController()
|
const domain = useServerCollectionController()
|
||||||
const [store, setStore] = createStore({ filter: "" })
|
const [store, setStore] = createStore({ filter: "" })
|
||||||
const wslServers = useFilteredWslServers(() => store.filter)
|
const wslServers = useFilteredWslServers(() => store.filter)
|
||||||
|
|
||||||
const showSearch = createMemo(
|
const showSearch = createMemo(
|
||||||
() => controller.sortedItems().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
|
() => domain.collection.items().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
|
||||||
)
|
)
|
||||||
|
|
||||||
const filtered = createMemo(() => {
|
const filtered = createMemo(() => {
|
||||||
const items = controller.sortedItems().filter((item) => !isWslServer(item))
|
const items = domain.collection.items().filter((item) => !isWslServer(item))
|
||||||
const query = store.filter.trim()
|
const query = store.filter.trim()
|
||||||
if (!query) return items
|
if (!query) return items
|
||||||
return fuzzysort
|
return fuzzysort
|
||||||
@@ -39,11 +39,11 @@ export const SettingsServersV2: Component = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const openAdd = () => {
|
const openAdd = () => {
|
||||||
dialog.push(() => <DialogServerV2 mode="add" />)
|
void dialog.push(() => <DialogServerV2 mode="add" />)
|
||||||
}
|
}
|
||||||
|
|
||||||
const openEdit = (server: ServerConnection.Http) => {
|
const openEdit = (server: ServerConnection.Http) => {
|
||||||
dialog.push(() => <DialogServerV2 mode="edit" server={server} />)
|
void dialog.push(() => <DialogServerV2 mode="edit" server={server} />)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -97,12 +97,12 @@ export const SettingsServersV2: Component = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SettingsListV2>
|
<SettingsListV2>
|
||||||
<WslServerSettings controller={controller} servers={wslServers} />
|
<WslServerSettings domain={domain} servers={wslServers} />
|
||||||
<For each={filtered()}>
|
<For each={filtered()}>
|
||||||
{(item) => {
|
{(item) => {
|
||||||
const key = ServerConnection.key(item)
|
const key = ServerConnection.key(item)
|
||||||
const health = () => controller.status()[key]
|
const health = () => domain.collection.health()[key]
|
||||||
const isDefault = () => controller.defaultKey() === key
|
const isDefault = () => domain.defaults.key() === key
|
||||||
return (
|
return (
|
||||||
<div class="settings-v2-servers-row">
|
<div class="settings-v2-servers-row">
|
||||||
<div class="settings-v2-servers-lead">
|
<div class="settings-v2-servers-lead">
|
||||||
@@ -122,10 +122,10 @@ export const SettingsServersV2: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-v2-servers-actions">
|
<div class="settings-v2-servers-actions">
|
||||||
<Show when={controller.canDefault() && isDefault()}>
|
<Show when={domain.defaults.available() && isDefault()}>
|
||||||
<Tag>{language.t("dialog.server.status.default")}</Tag>
|
<Tag>{language.t("dialog.server.status.default")}</Tag>
|
||||||
</Show>
|
</Show>
|
||||||
<ServerRowMenu server={item} controller={controller} onEdit={openEdit} />
|
<ServerRowMenu server={item} domain={domain} onEdit={openEdit} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -426,8 +426,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
"bg-icon-success-base": status() === "connected",
|
"bg-icon-success-base": status() === "connected",
|
||||||
"bg-icon-critical-base": status() === "failed",
|
"bg-icon-critical-base": status() === "failed",
|
||||||
"bg-border-weak-base": status() === "disabled",
|
"bg-border-weak-base": status() === "disabled",
|
||||||
"bg-icon-warning-base":
|
"bg-icon-warning-base": status() === "needs_auth",
|
||||||
status() === "needs_auth" || status() === "needs_client_registration",
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span class="flex flex-col min-w-0 flex-1">
|
<span class="flex flex-col min-w-0 flex-1">
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ describe("hasNonBlockingServiceIssue", () => {
|
|||||||
test("detects MCP failures that do not block chatting", () => {
|
test("detects MCP failures that do not block chatting", () => {
|
||||||
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
|
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
|
||||||
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
|
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
|
||||||
expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
|
|
||||||
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false)
|
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -48,7 +47,6 @@ describe("hasNonBlockingServiceIssue", () => {
|
|||||||
describe("hasServiceNeedingAttention", () => {
|
describe("hasServiceNeedingAttention", () => {
|
||||||
test("detects MCP states that need user attention", () => {
|
test("detects MCP states that need user attention", () => {
|
||||||
expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true)
|
expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true)
|
||||||
expect(hasServiceNeedingAttention({ mcp: ["needs_client_registration"] })).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("ignores states that do not need user attention", () => {
|
test("ignores states that do not need user attention", () => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { LspStatus } from "@/types"
|
|||||||
import type { McpServer } from "@opencode-ai/client/promise"
|
import type { McpServer } from "@opencode-ai/client/promise"
|
||||||
|
|
||||||
export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) {
|
export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) {
|
||||||
return input.mcp.some((status) => status === "needs_auth" || status === "needs_client_registration")
|
return input.mcp.some((status) => status === "needs_auth")
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasNonBlockingServiceIssue(input: {
|
export function hasNonBlockingServiceIssue(input: {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ export async function toggleMcp(input: {
|
|||||||
needs_auth: input.authenticate,
|
needs_auth: input.authenticate,
|
||||||
disabled: input.connect,
|
disabled: input.connect,
|
||||||
failed: input.connect,
|
failed: input.connect,
|
||||||
needs_client_registration: input.connect,
|
|
||||||
}[input.status]()
|
}[input.status]()
|
||||||
await input.refresh()
|
await input.refresh()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import {
|
|
||||||
hasExistingWebState,
|
|
||||||
isAppUpgrade,
|
|
||||||
layoutTransitionState,
|
|
||||||
maximumSunsetTimeout,
|
|
||||||
newLayoutDesignsDefault,
|
|
||||||
nextSunsetCheckDelay,
|
|
||||||
resolveNewLayoutDesigns,
|
|
||||||
shouldDisplayTabsToast,
|
|
||||||
shouldEnableNewLayout,
|
|
||||||
} from "./settings"
|
|
||||||
|
|
||||||
describe("layout transition", () => {
|
|
||||||
test("blank profiles default to the new layout", () => {
|
|
||||||
expect(newLayoutDesignsDefault).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("hides the transition until a sunset is scheduled", () => {
|
|
||||||
expect(layoutTransitionState(false, true, false, false)).toEqual({ available: false, notice: false })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("existing profiles can switch before sunset", () => {
|
|
||||||
expect(layoutTransitionState(true, true, false, false)).toEqual({ available: true, notice: false })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("classifies web profiles from existing settings or a recorded version", () => {
|
|
||||||
expect(hasExistingWebState("{}", undefined)).toBe(true)
|
|
||||||
expect(hasExistingWebState(null, "1.17.19")).toBe(true)
|
|
||||||
expect(hasExistingWebState(null, undefined)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("preserves explicit and default layout preferences", () => {
|
|
||||||
expect(resolveNewLayoutDesigns(false, false, true)).toBe(false)
|
|
||||||
expect(resolveNewLayoutDesigns(false, undefined, false)).toBe(false)
|
|
||||||
expect(resolveNewLayoutDesigns(false, undefined, true)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("sunset replaces the toggle with a dismissible notice", () => {
|
|
||||||
expect(layoutTransitionState(true, true, true, false)).toEqual({ available: false, notice: true })
|
|
||||||
expect(layoutTransitionState(true, true, true, true)).toEqual({ available: false, notice: false })
|
|
||||||
expect(resolveNewLayoutDesigns(true, false)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("caps checks for sunsets beyond the browser timeout limit", () => {
|
|
||||||
expect(nextSunsetCheckDelay(maximumSunsetTimeout + 1_000, 0)).toBe(maximumSunsetTimeout)
|
|
||||||
expect(nextSunsetCheckDelay(10_000, 9_000)).toBe(1_000)
|
|
||||||
expect(nextSunsetCheckDelay(9_000, 10_000)).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("enables the new layout when upgrading from 1.17.19 or earlier", () => {
|
|
||||||
expect(shouldEnableNewLayout("v1.17.19", "1.17.20")).toBe(true)
|
|
||||||
expect(shouldEnableNewLayout("1.16.9", "2.0.0")).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("enables the new layout when no previous version was recorded", () => {
|
|
||||||
expect(shouldEnableNewLayout(undefined, "1.17.20")).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("detects upgrades only when a previous version is older", () => {
|
|
||||||
expect(isAppUpgrade("1.17.19", "1.17.20")).toBe(true)
|
|
||||||
expect(isAppUpgrade(undefined, "1.17.20")).toBe(false)
|
|
||||||
expect(isAppUpgrade("1.17.20", "1.17.20")).toBe(false)
|
|
||||||
expect(isAppUpgrade("1.17.21", "1.17.20")).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("shows the tabs toast for upgrades and existing installs without a recorded version", () => {
|
|
||||||
expect(shouldDisplayTabsToast("1.17.19", "1.17.20", false)).toBe(true)
|
|
||||||
expect(shouldDisplayTabsToast(undefined, "1.17.20", true)).toBe(true)
|
|
||||||
expect(shouldDisplayTabsToast(undefined, "1.17.20", false)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does not enable the new layout without a qualifying upgrade", () => {
|
|
||||||
expect(shouldEnableNewLayout("1.17.19", "1.17.19")).toBe(false)
|
|
||||||
expect(shouldEnableNewLayout("1.17.20", "1.17.21")).toBe(false)
|
|
||||||
expect(shouldEnableNewLayout(undefined, "1.17.19")).toBe(false)
|
|
||||||
expect(shouldEnableNewLayout("dev", "1.17.20")).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { createStore, reconcile } from "solid-js/store"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
import { createEffect, createMemo } from "solid-js"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { persisted } from "@/utils/persist"
|
import { persisted } from "@/utils/persist"
|
||||||
import { usePlatform } from "@/context/platform"
|
|
||||||
|
|
||||||
export interface NotificationSettings {
|
export interface NotificationSettings {
|
||||||
agent: boolean
|
agent: boolean
|
||||||
@@ -34,10 +33,6 @@ export interface Settings {
|
|||||||
editToolPartsExpanded: boolean
|
editToolPartsExpanded: boolean
|
||||||
showCustomAgents: boolean
|
showCustomAgents: boolean
|
||||||
mobileTitlebarPosition: "top" | "bottom"
|
mobileTitlebarPosition: "top" | "bottom"
|
||||||
newLayoutDesigns?: boolean
|
|
||||||
layoutTransitionEligible?: boolean
|
|
||||||
newInterfaceNoticeDismissed?: boolean
|
|
||||||
shouldDisplayTabsToast?: boolean
|
|
||||||
}
|
}
|
||||||
appearance: {
|
appearance: {
|
||||||
fontSize: number
|
fontSize: number
|
||||||
@@ -56,75 +51,6 @@ export interface Settings {
|
|||||||
export const monoDefault = "System Mono"
|
export const monoDefault = "System Mono"
|
||||||
export const sansDefault = "System Sans"
|
export const sansDefault = "System Sans"
|
||||||
export const terminalDefault = "JetBrainsMono Nerd Font Mono"
|
export const terminalDefault = "JetBrainsMono Nerd Font Mono"
|
||||||
const legacyNewLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
|
||||||
export const newLayoutDesignsDefault = true
|
|
||||||
// Existing users can switch layouts until local midnight on this date. Set new Date(YYYY, M-1, D) to show.
|
|
||||||
export const oldInterfaceSunset = new Date(2026, 8, 14)
|
|
||||||
const newLayoutDesignsUpgradeCutoff = "1.17.19"
|
|
||||||
|
|
||||||
function compareVersions(a: string, b: string) {
|
|
||||||
const parse = (version: string) => {
|
|
||||||
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/i.exec(version.trim())
|
|
||||||
if (!match) return
|
|
||||||
return match.slice(1).map(Number)
|
|
||||||
}
|
|
||||||
const left = parse(a)
|
|
||||||
const right = parse(b)
|
|
||||||
if (!left || !right) return
|
|
||||||
const index = left.findIndex((part, index) => part !== right[index])
|
|
||||||
return index === -1 ? 0 : left[index]! - right[index]!
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isAppUpgrade(previous: string | undefined, current: string | undefined) {
|
|
||||||
if (!previous || !current) return false
|
|
||||||
const comparison = compareVersions(current, previous)
|
|
||||||
return comparison !== undefined && comparison > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldDisplayTabsToast(
|
|
||||||
previous: string | undefined,
|
|
||||||
current: string | undefined,
|
|
||||||
existingInstall: boolean,
|
|
||||||
) {
|
|
||||||
return isAppUpgrade(previous, current) || (!previous && existingInstall)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasExistingWebState(settings: Promise<string> | string | null, previousVersion: string | undefined) {
|
|
||||||
return settings !== null || previousVersion !== undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldEnableNewLayout(previous: string | undefined, current: string | undefined) {
|
|
||||||
if (!current) return false
|
|
||||||
const currentComparison = compareVersions(current, newLayoutDesignsUpgradeCutoff)
|
|
||||||
if (!previous) return currentComparison !== undefined && currentComparison > 0
|
|
||||||
if (!isAppUpgrade(previous, current)) return false
|
|
||||||
const previousComparison = compareVersions(previous, newLayoutDesignsUpgradeCutoff)
|
|
||||||
return (
|
|
||||||
previousComparison !== undefined &&
|
|
||||||
currentComparison !== undefined &&
|
|
||||||
previousComparison <= 0 &&
|
|
||||||
currentComparison > 0
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function layoutTransitionState(scheduled: boolean, eligible: boolean, retired: boolean, dismissed: boolean) {
|
|
||||||
return {
|
|
||||||
available: scheduled && eligible && !retired,
|
|
||||||
notice: scheduled && eligible && retired && !dismissed,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const maximumSunsetTimeout = 2_147_483_647
|
|
||||||
|
|
||||||
export function nextSunsetCheckDelay(sunset: number, now: number) {
|
|
||||||
return Math.min(Math.max(0, sunset - now), maximumSunsetTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveNewLayoutDesigns(retired: boolean, preference: boolean | undefined, fallback = true) {
|
|
||||||
if (retired) return true
|
|
||||||
return preference ?? fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
const monoFallback =
|
const monoFallback =
|
||||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||||
const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
|
const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
|
||||||
@@ -223,17 +149,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
name: "Settings",
|
name: "Settings",
|
||||||
gate: false,
|
gate: false,
|
||||||
init: () => {
|
init: () => {
|
||||||
const platform = usePlatform()
|
const [store, setStore, , ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
|
||||||
const [store, setStore, settingsInit, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
|
|
||||||
const [launch, setLaunch, , launchReady] = persisted(
|
|
||||||
"app-version.v1",
|
|
||||||
createStore<{ version?: string }>({ version: undefined }),
|
|
||||||
)
|
|
||||||
const [launchState, setLaunchState] = createStore({
|
|
||||||
classified: false,
|
|
||||||
migrationApplied: false,
|
|
||||||
previous: undefined as string | undefined,
|
|
||||||
})
|
|
||||||
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
|
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
|
||||||
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
|
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
|
||||||
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
|
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
|
||||||
@@ -241,93 +157,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
() => store.general?.showCustomAgents,
|
() => store.general?.showCustomAgents,
|
||||||
defaultSettings.general.showCustomAgents,
|
defaultSettings.general.showCustomAgents,
|
||||||
)
|
)
|
||||||
const sunset = oldInterfaceSunset
|
|
||||||
const [oldInterfaceRetired, setOldInterfaceRetired] = createSignal(sunset ? Date.now() >= sunset.getTime() : false)
|
|
||||||
const layoutTransitionClassified = createMemo(() => typeof store.general?.layoutTransitionEligible === "boolean")
|
|
||||||
const layoutTransitionEligible = withFallback(() => store.general?.layoutTransitionEligible, false)
|
|
||||||
const newInterfaceNoticeDismissed = withFallback(() => store.general?.newInterfaceNoticeDismissed, false)
|
|
||||||
const layoutUpgrade = createMemo(() =>
|
|
||||||
launchState.classified && !launchState.migrationApplied
|
|
||||||
? shouldEnableNewLayout(launchState.previous, platform.version)
|
|
||||||
: false,
|
|
||||||
)
|
|
||||||
const layoutTransition = createMemo(() =>
|
|
||||||
layoutTransitionState(!!sunset, layoutTransitionEligible(), oldInterfaceRetired(), newInterfaceNoticeDismissed()),
|
|
||||||
)
|
|
||||||
const newLayoutDesigns = createMemo(() => {
|
|
||||||
if (layoutUpgrade()) return true
|
|
||||||
if (!ready() && !oldInterfaceRetired()) return legacyNewLayoutDesignsDefault
|
|
||||||
if (!layoutTransitionClassified()) {
|
|
||||||
return resolveNewLayoutDesigns(
|
|
||||||
oldInterfaceRetired(),
|
|
||||||
store.general?.newLayoutDesigns,
|
|
||||||
legacyNewLayoutDesignsDefault,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return resolveNewLayoutDesigns(
|
|
||||||
oldInterfaceRetired(),
|
|
||||||
store.general?.newLayoutDesigns,
|
|
||||||
layoutTransitionEligible() ? legacyNewLayoutDesignsDefault : newLayoutDesignsDefault,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference())
|
|
||||||
|
|
||||||
if (sunset && !oldInterfaceRetired()) {
|
|
||||||
const timeout = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
|
||||||
const checkSunset = () => {
|
|
||||||
if (Date.now() >= sunset.getTime()) {
|
|
||||||
setOldInterfaceRetired(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
timeout.current = setTimeout(checkSunset, nextSunsetCheckDelay(sunset.getTime(), Date.now()))
|
|
||||||
}
|
|
||||||
checkSunset()
|
|
||||||
onCleanup(() => {
|
|
||||||
if (timeout.current !== undefined) clearTimeout(timeout.current)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!launchReady() || launchState.classified) return
|
|
||||||
setLaunchState({
|
|
||||||
classified: true,
|
|
||||||
previous: launch.version,
|
|
||||||
})
|
|
||||||
if (!platform.version || launch.version === platform.version) return
|
|
||||||
setLaunch("version", platform.version)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!ready() || !launchState.classified || platform.platform !== "web") return
|
|
||||||
if (layoutTransitionClassified()) return
|
|
||||||
setStore("general", "layoutTransitionEligible", hasExistingWebState(settingsInit, launchState.previous))
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!ready() || !launchState.classified || launchState.migrationApplied) return
|
|
||||||
if (layoutUpgrade() && store.general?.newLayoutDesigns !== true) {
|
|
||||||
setStore("general", "newLayoutDesigns", true)
|
|
||||||
}
|
|
||||||
setLaunchState("migrationApplied", true)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!ready() || !launchState.classified) return
|
|
||||||
if (typeof store.general?.shouldDisplayTabsToast === "boolean") return
|
|
||||||
if (!launchState.previous && !layoutTransitionClassified()) return
|
|
||||||
setStore(
|
|
||||||
"general",
|
|
||||||
"shouldDisplayTabsToast",
|
|
||||||
shouldDisplayTabsToast(launchState.previous, platform.version, layoutTransitionEligible()),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!ready() || !oldInterfaceRetired()) return
|
|
||||||
if (store.general?.newLayoutDesigns === true) return
|
|
||||||
setStore("general", "newLayoutDesigns", true)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (typeof document === "undefined") return
|
if (typeof document === "undefined") return
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
@@ -413,34 +242,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
setMobileTitlebarPosition(value: "top" | "bottom") {
|
setMobileTitlebarPosition(value: "top" | "bottom") {
|
||||||
setStore("general", "mobileTitlebarPosition", value)
|
setStore("general", "mobileTitlebarPosition", value)
|
||||||
},
|
},
|
||||||
newLayoutDesigns,
|
newLayoutDesigns: () => true,
|
||||||
setNewLayoutDesigns(value: boolean) {
|
|
||||||
const next = oldInterfaceRetired() ? true : value
|
|
||||||
if (newLayoutDesigns() === next) return
|
|
||||||
setStore("general", "newLayoutDesigns", next)
|
|
||||||
if (typeof window !== "undefined") setTimeout(() => window.location.reload())
|
|
||||||
},
|
|
||||||
layoutTransitionClassified,
|
|
||||||
setOldLayoutEligible(eligible: boolean) {
|
|
||||||
const current = store.general?.layoutTransitionEligible
|
|
||||||
if (typeof current === "boolean") return
|
|
||||||
setStore("general", "layoutTransitionEligible", eligible)
|
|
||||||
},
|
|
||||||
layoutTransitionAvailable: createMemo(() => ready() && layoutTransition().available),
|
|
||||||
newInterfaceNoticeVisible: createMemo(() => ready() && layoutTransition().notice),
|
|
||||||
dismissNewInterfaceNotice() {
|
|
||||||
setStore("general", "newInterfaceNoticeDismissed", true)
|
|
||||||
},
|
|
||||||
shouldDisplayTabsToast: withFallback(() => store.general?.shouldDisplayTabsToast, false),
|
|
||||||
dismissTabsToast() {
|
|
||||||
setStore("general", "shouldDisplayTabsToast", false)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
visibility: {
|
visibility: {
|
||||||
fileTree: visible(showFileTree),
|
fileTree: showFileTree,
|
||||||
search: visible(showSearch),
|
search: showSearch,
|
||||||
status: visible(showStatus),
|
status: showStatus,
|
||||||
customAgents: visible(showCustomAgents),
|
customAgents: showCustomAgents,
|
||||||
},
|
},
|
||||||
appearance: {
|
appearance: {
|
||||||
fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
|
fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { createHomeSessionSearchController } from "./home/home-session-search-co
|
|||||||
import { createHomeSessionsController } from "./home/home-sessions-controller"
|
import { createHomeSessionsController } from "./home/home-sessions-controller"
|
||||||
import { HomeSessions } from "./home/home-sessions"
|
import { HomeSessions } from "./home/home-sessions"
|
||||||
|
|
||||||
export function NewHome() {
|
export function Home() {
|
||||||
const home = createHomeController()
|
const home = createHomeController()
|
||||||
const projects = createHomeProjectsController(home)
|
const projects = createHomeProjectsController(home)
|
||||||
const sessions = createHomeSessionsController(home)
|
const sessions = createHomeSessionsController(home)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||||
import { useServerManagementController } from "@/components/dialog-select-server"
|
import { useServerActionsController } from "@/components/server/server-management-controller"
|
||||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||||
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
||||||
import { type LocalProject } from "@/context/layout"
|
import { type LocalProject } from "@/context/layout"
|
||||||
@@ -22,7 +22,7 @@ export function createHomeProjectsController(home: HomeController) {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const notification = useNotification()
|
const notification = useNotification()
|
||||||
const openSettings = useSettingsCommand()
|
const openSettings = useSettingsCommand()
|
||||||
const serverManagement = useServerManagementController({ navigateOnAdd: false })
|
const serverManagement = useServerActionsController()
|
||||||
const [_state, setState, _, ready] = persisted(
|
const [_state, setState, _, ready] = persisted(
|
||||||
Persist.global("home.servers", ["home.servers.v1"]),
|
Persist.global("home.servers", ["home.servers.v1"]),
|
||||||
createStore({ collapsed: {} as Record<string, boolean> }),
|
createStore({ collapsed: {} as Record<string, boolean> }),
|
||||||
@@ -56,12 +56,12 @@ export function createHomeProjectsController(home: HomeController) {
|
|||||||
const key = ServerConnection.key(conn)
|
const key = ServerConnection.key(conn)
|
||||||
setState("collapsed", key, !state().collapsed[key])
|
setState("collapsed", key, !state().collapsed[key])
|
||||||
},
|
},
|
||||||
canDefault: serverManagement.canDefault,
|
canDefault: serverManagement.defaults.available,
|
||||||
defaultKey: serverManagement.defaultKey,
|
defaultKey: serverManagement.defaults.key,
|
||||||
setDefault: (conn: ServerConnection.Any | undefined) =>
|
setDefault: (conn: ServerConnection.Any | undefined) =>
|
||||||
serverManagement.setDefault(conn ? ServerConnection.key(conn) : null),
|
serverManagement.defaults.set(conn ? ServerConnection.key(conn) : null),
|
||||||
canRemove: (conn: ServerConnection.Any) => serverManagement.canRemove(ServerConnection.key(conn)),
|
canRemove: (conn: ServerConnection.Any) => serverManagement.connection.canRemove(ServerConnection.key(conn)),
|
||||||
remove: (conn: ServerConnection.Any) => serverManagement.handleRemove(ServerConnection.key(conn)),
|
remove: (conn: ServerConnection.Any) => serverManagement.connection.remove(ServerConnection.key(conn)),
|
||||||
edit: (conn: ServerConnection.Http) => dialog.show(() => <DialogServerV2 mode="edit" server={conn} />),
|
edit: (conn: ServerConnection.Http) => dialog.show(() => <DialogServerV2 mode="edit" server={conn} />),
|
||||||
focus: home.selection.focusServer,
|
focus: home.selection.focusServer,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
import { DialogSelectServer } from "@/components/dialog-select-server"
|
|
||||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
|
||||||
import { useGlobal } from "@/context/global"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { type ServerConnection, useServer } from "@/context/server"
|
|
||||||
import { useServerSync } from "@/context/server-sync"
|
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
|
||||||
import { Logo } from "@opencode-ai/ui/logo"
|
|
||||||
import { useNavigate } from "@solidjs/router"
|
|
||||||
import { DateTime } from "luxon"
|
|
||||||
import { createMemo, For, Match, Switch } from "solid-js"
|
|
||||||
|
|
||||||
export function LegacyHome() {
|
|
||||||
const sync = useServerSync()
|
|
||||||
const pickDirectory = useDirectoryPicker()
|
|
||||||
const dialog = useDialog()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const global = useGlobal()
|
|
||||||
const server = useServer()
|
|
||||||
const language = useLanguage()
|
|
||||||
const homedir = createMemo(() => sync().data.path.home)
|
|
||||||
const serverUnreachable = createMemo(() => global.servers.health[server.key]?.healthy === false)
|
|
||||||
const recent = createMemo(() => {
|
|
||||||
return sync()
|
|
||||||
.data.project.slice()
|
|
||||||
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
|
|
||||||
.slice(0, 5)
|
|
||||||
})
|
|
||||||
|
|
||||||
const serverDotClass = createMemo(() => {
|
|
||||||
const healthy = global.servers.health[server.key]?.healthy
|
|
||||||
if (healthy === true) return "bg-icon-success-base"
|
|
||||||
if (healthy === false) return "bg-icon-critical-base"
|
|
||||||
return "bg-border-weak-base"
|
|
||||||
})
|
|
||||||
|
|
||||||
function openProject(conn: ServerConnection.Any, directory: string) {
|
|
||||||
const serverCtx = global.ensureServerCtx(conn)
|
|
||||||
serverCtx.projects.open(directory)
|
|
||||||
serverCtx.projects.touch(directory)
|
|
||||||
navigate(`/${base64Encode(directory)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function chooseProject() {
|
|
||||||
if (serverUnreachable()) return
|
|
||||||
const conn = server.current
|
|
||||||
if (!conn) return
|
|
||||||
|
|
||||||
const resolve = (result: string | string[] | null) => {
|
|
||||||
if (Array.isArray(result)) {
|
|
||||||
result.forEach((directory) => openProject(conn, directory))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (result) openProject(conn, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
pickDirectory({
|
|
||||||
server: conn,
|
|
||||||
title: language.t("command.project.open"),
|
|
||||||
multiple: true,
|
|
||||||
onSelect: resolve,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="mx-auto mt-55 w-full md:w-auto px-4">
|
|
||||||
<Logo class="md:w-xl opacity-12" />
|
|
||||||
<Button
|
|
||||||
size="large"
|
|
||||||
variant="ghost"
|
|
||||||
class="mt-4 mx-auto text-14-regular text-text-weak"
|
|
||||||
onClick={() => dialog.show(() => <DialogSelectServer />)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
classList={{
|
|
||||||
"size-2 rounded-full": true,
|
|
||||||
[serverDotClass()]: true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{server.name}
|
|
||||||
</Button>
|
|
||||||
<Switch>
|
|
||||||
<Match when={sync().data.project.length > 0}>
|
|
||||||
<div class="mt-20 w-full flex flex-col gap-4">
|
|
||||||
<div class="flex gap-2 items-center justify-between pl-3">
|
|
||||||
<div class="text-14-medium text-text-strong">{language.t("home.recentProjects")}</div>
|
|
||||||
<Button
|
|
||||||
icon="folder-add-left"
|
|
||||||
size="normal"
|
|
||||||
class="pl-2 pr-3"
|
|
||||||
disabled={serverUnreachable()}
|
|
||||||
onClick={chooseProject}
|
|
||||||
>
|
|
||||||
{language.t("command.project.open")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<ul class="flex flex-col gap-2">
|
|
||||||
<For each={recent()}>
|
|
||||||
{(project) => (
|
|
||||||
<Button
|
|
||||||
size="large"
|
|
||||||
variant="ghost"
|
|
||||||
class="text-14-mono text-left justify-between px-3"
|
|
||||||
onClick={() => openProject(server.current!, project.worktree)}
|
|
||||||
>
|
|
||||||
{project.worktree.replace(homedir(), "~")}
|
|
||||||
<div class="text-14-regular text-text-weak">
|
|
||||||
{DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</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" disabled={serverUnreachable()} 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" />
|
|
||||||
<div class="flex flex-col gap-1 items-center justify-center">
|
|
||||||
<div class="text-14-medium text-text-strong">{language.t("home.empty.title")}</div>
|
|
||||||
<div class="text-12-regular text-text-weak">{language.t("home.empty.description")}</div>
|
|
||||||
</div>
|
|
||||||
<Button class="px-3 mt-1" disabled={serverUnreachable()} onClick={chooseProject}>
|
|
||||||
{language.t("command.project.open")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { createEffect, Suspense, type ParentProps } from "solid-js"
|
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { useNavigate } from "@solidjs/router"
|
|
||||||
import { DebugBar } from "@/components/debug-bar"
|
|
||||||
import { TabsInfoPopup } from "@/components/help-button"
|
|
||||||
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
|
||||||
import { usePlatform } from "@/context/platform"
|
|
||||||
import { setNavigate } from "@/utils/notification-click"
|
|
||||||
import { setV2Toast, ToastRegion } from "@/utils/toast"
|
|
||||||
|
|
||||||
export default function NewLayout(props: ParentProps) {
|
|
||||||
const platform = usePlatform()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
setNavigate(navigate)
|
|
||||||
const [state, setState] = createStore({ debugTools: true })
|
|
||||||
|
|
||||||
createEffect(() => setV2Toast(true))
|
|
||||||
|
|
||||||
const update: TitlebarUpdate = {
|
|
||||||
version: () => {
|
|
||||||
const state = platform.updater?.state()
|
|
||||||
if (state?.status !== "ready") return
|
|
||||||
return state.version
|
|
||||||
},
|
|
||||||
installing: () => platform.updater?.state().status === "installing",
|
|
||||||
install: () => void platform.updater?.install(),
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"
|
|
||||||
style={{
|
|
||||||
"padding-top": "env(safe-area-inset-top, 0px)",
|
|
||||||
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Titlebar
|
|
||||||
update={update}
|
|
||||||
debugTools={
|
|
||||||
import.meta.env.DEV
|
|
||||||
? { visible: state.debugTools, toggle: () => setState("debugTools", (value) => !value) }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
|
|
||||||
<Suspense>{props.children}</Suspense>
|
|
||||||
</main>
|
|
||||||
{import.meta.env.DEV && state.debugTools && <DebugBar inline />}
|
|
||||||
<TabsInfoPopup />
|
|
||||||
<ToastRegion v2 />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+22
-2388
File diff suppressed because it is too large
Load Diff
@@ -1,126 +0,0 @@
|
|||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { onCleanup, Show, type Accessor } from "solid-js"
|
|
||||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
|
||||||
|
|
||||||
export function createInlineEditorController() {
|
|
||||||
// This controller intentionally supports one active inline editor at a time.
|
|
||||||
const [editor, setEditor] = createStore({
|
|
||||||
active: "" as string,
|
|
||||||
value: "",
|
|
||||||
})
|
|
||||||
|
|
||||||
const editorOpen = (id: string) => editor.active === id
|
|
||||||
const editorValue = () => editor.value
|
|
||||||
const openEditor = (id: string, value: string) => {
|
|
||||||
if (!id) return
|
|
||||||
setEditor({ active: id, value })
|
|
||||||
}
|
|
||||||
const closeEditor = () => setEditor({ active: "", value: "" })
|
|
||||||
|
|
||||||
const saveEditor = (callback: (next: string) => void) => {
|
|
||||||
const next = editor.value.trim()
|
|
||||||
if (!next) {
|
|
||||||
closeEditor()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
closeEditor()
|
|
||||||
callback(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
const editorKeyDown = (event: KeyboardEvent, callback: (next: string) => void) => {
|
|
||||||
if (event.key === "Enter") {
|
|
||||||
event.preventDefault()
|
|
||||||
saveEditor(callback)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (event.key !== "Escape") return
|
|
||||||
event.preventDefault()
|
|
||||||
closeEditor()
|
|
||||||
}
|
|
||||||
|
|
||||||
const InlineEditor = (props: {
|
|
||||||
id: string
|
|
||||||
value: Accessor<string>
|
|
||||||
onSave: (next: string) => void
|
|
||||||
class?: string
|
|
||||||
displayClass?: string
|
|
||||||
editing?: boolean
|
|
||||||
stopPropagation?: boolean
|
|
||||||
openOnDblClick?: boolean
|
|
||||||
}) => {
|
|
||||||
let frame: number | undefined
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
if (frame === undefined) return
|
|
||||||
cancelAnimationFrame(frame)
|
|
||||||
})
|
|
||||||
|
|
||||||
const isEditing = () => props.editing ?? editorOpen(props.id)
|
|
||||||
const stopEvents = () => props.stopPropagation ?? false
|
|
||||||
const allowDblClick = () => props.openOnDblClick ?? true
|
|
||||||
const stopPropagation = (event: Event) => {
|
|
||||||
if (!stopEvents()) return
|
|
||||||
event.stopPropagation()
|
|
||||||
}
|
|
||||||
const handleDblClick = (event: MouseEvent) => {
|
|
||||||
if (!allowDblClick()) return
|
|
||||||
stopPropagation(event)
|
|
||||||
openEditor(props.id, props.value())
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Show
|
|
||||||
when={isEditing()}
|
|
||||||
fallback={
|
|
||||||
<span
|
|
||||||
class={props.displayClass ?? props.class}
|
|
||||||
onDblClick={handleDblClick}
|
|
||||||
onPointerDown={stopPropagation}
|
|
||||||
onMouseDown={stopPropagation}
|
|
||||||
onClick={stopPropagation}
|
|
||||||
onTouchStart={stopPropagation}
|
|
||||||
>
|
|
||||||
{props.value()}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<InlineInput
|
|
||||||
ref={(el) => {
|
|
||||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
|
||||||
frame = requestAnimationFrame(() => {
|
|
||||||
frame = undefined
|
|
||||||
if (!el.isConnected) return
|
|
||||||
el.focus()
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
value={editorValue()}
|
|
||||||
class={props.class}
|
|
||||||
onInput={(event) => setEditor("value", event.currentTarget.value)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
editorKeyDown(event, props.onSave)
|
|
||||||
}}
|
|
||||||
onBlur={closeEditor}
|
|
||||||
onPointerDown={stopPropagation}
|
|
||||||
onClick={stopPropagation}
|
|
||||||
onDblClick={stopPropagation}
|
|
||||||
onMouseDown={stopPropagation}
|
|
||||||
onMouseUp={stopPropagation}
|
|
||||||
onTouchStart={stopPropagation}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
editor,
|
|
||||||
editorOpen,
|
|
||||||
editorValue,
|
|
||||||
openEditor,
|
|
||||||
closeEditor,
|
|
||||||
saveEditor,
|
|
||||||
editorKeyDown,
|
|
||||||
setEditor,
|
|
||||||
InlineEditor,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,340 +0,0 @@
|
|||||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
|
||||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
|
||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
|
||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
|
||||||
import { A, useParams } from "@solidjs/router"
|
|
||||||
import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js"
|
|
||||||
import { useServerSync } from "@/context/server-sync"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
|
|
||||||
import { useNotification } from "@/context/notification"
|
|
||||||
import { usePermission } from "@/context/permission"
|
|
||||||
import { messageAgentColor } from "@/utils/agent"
|
|
||||||
import { sessionLabel } from "@/utils/session-title"
|
|
||||||
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
|
|
||||||
import { childSessionOnPath, getProjectAvatarSource, hasProjectPermissions } from "./helpers"
|
|
||||||
|
|
||||||
export const ProjectIcon = (props: {
|
|
||||||
project: LocalProject
|
|
||||||
class?: string
|
|
||||||
notify?: boolean
|
|
||||||
working?: boolean
|
|
||||||
}): JSX.Element => {
|
|
||||||
const serverSync = useServerSync()
|
|
||||||
const notification = useNotification()
|
|
||||||
const permission = usePermission()
|
|
||||||
const dirs = createMemo(() => [props.project.worktree, ...(props.project.sandboxes ?? [])])
|
|
||||||
const unseenCount = createMemo(() =>
|
|
||||||
dirs().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
|
|
||||||
)
|
|
||||||
const hasError = createMemo(() => dirs().some((directory) => notification.project.unseenHasError(directory)))
|
|
||||||
const hasPermissions = createMemo(() =>
|
|
||||||
dirs().some((directory) => {
|
|
||||||
return hasProjectPermissions(serverSync().session.data.permission, (item) => {
|
|
||||||
if (serverSync().session.get(item.sessionID)?.location.directory !== directory) return false
|
|
||||||
return !permission.autoResponds(item, directory)
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const notify = createMemo(() => props.notify && (hasPermissions() || unseenCount() > 0))
|
|
||||||
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}>
|
|
||||||
<div class="size-full rounded overflow-clip">
|
|
||||||
<Avatar
|
|
||||||
fallback={name()}
|
|
||||||
src={getProjectAvatarSource(props.project.id, props.project.icon)}
|
|
||||||
{...getAvatarColors(props.project.icon?.color)}
|
|
||||||
class="size-full rounded"
|
|
||||||
classList={{ "badge-mask": notify() }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Show when={notify()}>
|
|
||||||
<div
|
|
||||||
classList={{
|
|
||||||
"absolute top-px right-px size-1.5 rounded-full z-10": true,
|
|
||||||
"bg-surface-warning-strong": hasPermissions(),
|
|
||||||
"bg-icon-critical-base": !hasPermissions() && hasError(),
|
|
||||||
"bg-text-interactive-base": !hasPermissions() && !hasError(),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
<Show when={props.working}>
|
|
||||||
<div class="absolute bottom-px right-px size-3 rounded-full bg-background-base z-10 flex items-center justify-center">
|
|
||||||
<Spinner class="size-[9px]" />
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SessionItemProps = {
|
|
||||||
session: SessionInfo
|
|
||||||
list: SessionInfo[]
|
|
||||||
navList?: Accessor<SessionInfo[]>
|
|
||||||
slug: string
|
|
||||||
mobile?: boolean
|
|
||||||
dense?: boolean
|
|
||||||
showTooltip?: boolean
|
|
||||||
showChild?: boolean
|
|
||||||
level?: number
|
|
||||||
sidebarExpanded: Accessor<boolean>
|
|
||||||
clearHoverProjectSoon: () => void
|
|
||||||
prefetchSession: (session: SessionInfo, priority?: "high" | "low") => void
|
|
||||||
archiveSession: (session: SessionInfo) => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
const SessionRow = (props: {
|
|
||||||
session: SessionInfo
|
|
||||||
slug: string
|
|
||||||
mobile?: boolean
|
|
||||||
dense?: boolean
|
|
||||||
tint: Accessor<string | undefined>
|
|
||||||
isWorking: Accessor<boolean>
|
|
||||||
hasPermissions: Accessor<boolean>
|
|
||||||
hasError: Accessor<boolean>
|
|
||||||
unseenCount: Accessor<number>
|
|
||||||
clearHoverProjectSoon: () => void
|
|
||||||
sidebarOpened: Accessor<boolean>
|
|
||||||
warmPress: () => void
|
|
||||||
warmFocus: () => void
|
|
||||||
}): JSX.Element => {
|
|
||||||
const title = () => sessionLabel(props.session)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<A
|
|
||||||
href={`/${props.slug}/session/${props.session.id}`}
|
|
||||||
class={`flex items-center gap-2 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
|
|
||||||
onPointerDown={props.warmPress}
|
|
||||||
onFocus={props.warmFocus}
|
|
||||||
onClick={() => {
|
|
||||||
if (props.sidebarOpened()) return
|
|
||||||
props.clearHoverProjectSoon()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Show when={props.isWorking() || props.hasPermissions() || props.hasError() || props.unseenCount() > 0}>
|
|
||||||
<div
|
|
||||||
class="shrink-0 size-6 flex items-center justify-center"
|
|
||||||
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
|
|
||||||
>
|
|
||||||
<Switch>
|
|
||||||
<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>
|
|
||||||
</Show>
|
|
||||||
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{title()}</span>
|
|
||||||
</A>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
|
||||||
const params = useParams()
|
|
||||||
const layout = useLayout()
|
|
||||||
const language = useLanguage()
|
|
||||||
const notification = useNotification()
|
|
||||||
const permission = usePermission()
|
|
||||||
const serverSync = useServerSync()
|
|
||||||
const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id))
|
|
||||||
const hasError = createMemo(() => notification.session.unseenHasError(props.session.id))
|
|
||||||
const [sessionStore] = serverSync().child(props.session.location.directory)
|
|
||||||
const hasPermissions = createMemo(() => {
|
|
||||||
return !!sessionPermissionRequest(
|
|
||||||
sessionStore.session,
|
|
||||||
serverSync().session.data.permission,
|
|
||||||
props.session.id,
|
|
||||||
(item) => {
|
|
||||||
return !permission.autoResponds(item, props.session.location.directory)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
const isWorking = createMemo(() => {
|
|
||||||
if (hasPermissions()) return false
|
|
||||||
return serverSync().session.data.session_working(props.session.id)
|
|
||||||
})
|
|
||||||
|
|
||||||
const tint = createMemo(() =>
|
|
||||||
messageAgentColor(serverSync().session.data.message[props.session.id], sessionStore.agent),
|
|
||||||
)
|
|
||||||
const tooltip = createMemo(() => props.showTooltip ?? (props.mobile || !props.sidebarExpanded()))
|
|
||||||
const currentChild = createMemo(() => {
|
|
||||||
if (!props.showChild) return
|
|
||||||
return childSessionOnPath(sessionStore.session, props.session.id, params.id)
|
|
||||||
})
|
|
||||||
|
|
||||||
const warm = (span: number, priority: "high" | "low") => {
|
|
||||||
const nav = props.navList?.()
|
|
||||||
const list = nav?.some(
|
|
||||||
(item) => item.id === props.session.id && item.location.directory === props.session.location.directory,
|
|
||||||
)
|
|
||||||
? nav
|
|
||||||
: props.list
|
|
||||||
|
|
||||||
props.prefetchSession(props.session, priority)
|
|
||||||
|
|
||||||
const idx = list.findIndex(
|
|
||||||
(item) => item.id === props.session.id && item.location.directory === props.session.location.directory,
|
|
||||||
)
|
|
||||||
if (idx === -1) return
|
|
||||||
|
|
||||||
for (let step = 1; step <= span; step++) {
|
|
||||||
const next = list[idx + step]
|
|
||||||
if (next) props.prefetchSession(next, step === 1 ? "high" : priority)
|
|
||||||
|
|
||||||
const prev = list[idx - step]
|
|
||||||
if (prev) props.prefetchSession(prev, step === 1 ? "high" : priority)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = (
|
|
||||||
<SessionRow
|
|
||||||
session={props.session}
|
|
||||||
slug={props.slug}
|
|
||||||
mobile={props.mobile}
|
|
||||||
dense={props.dense}
|
|
||||||
tint={tint}
|
|
||||||
isWorking={isWorking}
|
|
||||||
hasPermissions={hasPermissions}
|
|
||||||
hasError={hasError}
|
|
||||||
unseenCount={unseenCount}
|
|
||||||
clearHoverProjectSoon={props.clearHoverProjectSoon}
|
|
||||||
sidebarOpened={layout.sidebar.opened}
|
|
||||||
warmPress={() => warm(2, "high")}
|
|
||||||
warmFocus={() => warm(2, "high")}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
data-session-id={props.session.id}
|
|
||||||
class="group/session relative w-full min-w-0 rounded-md cursor-default 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"
|
|
||||||
style={{ "padding-left": `${8 + (props.level ?? 0) * 16}px` }}
|
|
||||||
>
|
|
||||||
<div class="flex min-w-0 items-center gap-1">
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<Show
|
|
||||||
when={!tooltip()}
|
|
||||||
fallback={
|
|
||||||
<Tooltip
|
|
||||||
placement={props.mobile ? "bottom" : "right"}
|
|
||||||
value={sessionLabel(props.session)}
|
|
||||||
gutter={10}
|
|
||||||
class="min-w-0 w-full"
|
|
||||||
>
|
|
||||||
{item}
|
|
||||||
</Tooltip>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{item}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* TODO: Restore the archive action when the V2 client exposes session archive. */}
|
|
||||||
<Show when={false}>
|
|
||||||
<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,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Show when={currentChild()} keyed>
|
|
||||||
{(child) => (
|
|
||||||
<div class="w-full">
|
|
||||||
<SessionItem {...props} session={child} level={(props.level ?? 0) + 1} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NewSessionItem = (props: {
|
|
||||||
slug: string
|
|
||||||
mobile?: boolean
|
|
||||||
dense?: boolean
|
|
||||||
sidebarExpanded: Accessor<boolean>
|
|
||||||
clearHoverProjectSoon: () => void
|
|
||||||
}): JSX.Element => {
|
|
||||||
const layout = useLayout()
|
|
||||||
const language = useLanguage()
|
|
||||||
const label = language.t("command.session.new")
|
|
||||||
const tooltip = () => props.mobile || !props.sidebarExpanded()
|
|
||||||
const item = (
|
|
||||||
<A
|
|
||||||
href={`/${props.slug}/session`}
|
|
||||||
end
|
|
||||||
class={`flex items-center gap-2 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
|
|
||||||
onClick={() => {
|
|
||||||
if (layout.sidebar.opened()) return
|
|
||||||
props.clearHoverProjectSoon()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="shrink-0 size-6 flex items-center justify-center">
|
|
||||||
<IconV2 name="edit" 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 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} class="min-w-0 w-full">
|
|
||||||
{item}
|
|
||||||
</Tooltip>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{item}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SessionSkeleton = (props: { count?: number }): JSX.Element => {
|
|
||||||
const items = Array.from({ length: props.count ?? 4 }, (_, index) => index)
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<For each={items}>
|
|
||||||
{() => <div class="h-8 w-full rounded-md bg-surface-raised-base opacity-60 animate-pulse" />}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,377 +0,0 @@
|
|||||||
import { createMemo, For, Show, type Accessor, type JSX } from "solid-js"
|
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
|
||||||
import { ContextMenu } from "@opencode-ai/ui/context-menu"
|
|
||||||
import { HoverCard } from "@opencode-ai/ui/hover-card"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
|
||||||
import { createSortable } from "@thisbeyond/solid-dnd"
|
|
||||||
import { useLayout, type LocalProject } from "@/context/layout"
|
|
||||||
import { useServerSync } from "@/context/server-sync"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { useNotification } from "@/context/notification"
|
|
||||||
import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items"
|
|
||||||
import { displayName, sortedRootSessions } from "./helpers"
|
|
||||||
|
|
||||||
export type ProjectSidebarContext = {
|
|
||||||
currentDir: Accessor<string>
|
|
||||||
currentProject: Accessor<LocalProject | undefined>
|
|
||||||
sidebarOpened: Accessor<boolean>
|
|
||||||
sidebarHovering: Accessor<boolean>
|
|
||||||
hoverProject: Accessor<string | undefined>
|
|
||||||
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
|
|
||||||
showEditProjectDialog: (project: LocalProject) => void
|
|
||||||
toggleProjectWorkspaces: (project: LocalProject) => void
|
|
||||||
workspacesEnabled: (project: LocalProject) => boolean
|
|
||||||
workspaceIds: (project: LocalProject) => string[]
|
|
||||||
workspaceLabel: (directory: string, branch?: string, projectId?: string) => string
|
|
||||||
sessionProps: Omit<SessionItemProps, "session" | "list" | "slug" | "mobile" | "dense">
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ProjectDragOverlay = (props: {
|
|
||||||
projects: Accessor<LocalProject[]>
|
|
||||||
activeProject: Accessor<string | undefined>
|
|
||||||
}): JSX.Element => {
|
|
||||||
const project = createMemo(() => props.projects().find((p) => p.worktree === props.activeProject()))
|
|
||||||
return (
|
|
||||||
<Show when={project()}>
|
|
||||||
{(p) => (
|
|
||||||
<div class="bg-background-base rounded-xl p-1">
|
|
||||||
<ProjectIcon project={p()} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProjectTile = (props: {
|
|
||||||
project: LocalProject
|
|
||||||
mobile?: boolean
|
|
||||||
sidebarHovering: Accessor<boolean>
|
|
||||||
selected: Accessor<boolean>
|
|
||||||
active: Accessor<boolean>
|
|
||||||
isWorking: Accessor<boolean>
|
|
||||||
overlay: Accessor<boolean>
|
|
||||||
suppressHover: Accessor<boolean>
|
|
||||||
dirs: Accessor<string[]>
|
|
||||||
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
|
|
||||||
onProjectMouseLeave: (worktree: string) => void
|
|
||||||
onProjectFocus: (worktree: string) => void
|
|
||||||
navigateToProject: (directory: string) => void
|
|
||||||
showEditProjectDialog: (project: LocalProject) => void
|
|
||||||
toggleProjectWorkspaces: (project: LocalProject) => void
|
|
||||||
workspacesEnabled: (project: LocalProject) => boolean
|
|
||||||
closeProject: (directory: string) => void
|
|
||||||
setMenu: (value: boolean) => void
|
|
||||||
setOpen: (value: boolean) => void
|
|
||||||
setSuppressHover: (value: boolean) => void
|
|
||||||
language: ReturnType<typeof useLanguage>
|
|
||||||
}): JSX.Element => {
|
|
||||||
const notification = useNotification()
|
|
||||||
const layout = useLayout()
|
|
||||||
const unseenCount = createMemo(() =>
|
|
||||||
props.dirs().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
const clear = () =>
|
|
||||||
props
|
|
||||||
.dirs()
|
|
||||||
.filter((directory) => notification.project.unseenCount(directory) > 0)
|
|
||||||
.forEach((directory) => notification.project.markViewed(directory))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ContextMenu
|
|
||||||
modal={!props.sidebarHovering()}
|
|
||||||
onOpenChange={(value) => {
|
|
||||||
props.setMenu(value)
|
|
||||||
props.setSuppressHover(value)
|
|
||||||
if (value) props.setOpen(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ContextMenu.Trigger
|
|
||||||
as="button"
|
|
||||||
type="button"
|
|
||||||
aria-label={displayName(props.project)}
|
|
||||||
data-action="project-switch"
|
|
||||||
data-project={base64Encode(props.project.worktree)}
|
|
||||||
classList={{
|
|
||||||
"flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
|
|
||||||
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": props.selected(),
|
|
||||||
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
|
|
||||||
!props.selected() && !props.active(),
|
|
||||||
"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()
|
|
||||||
}}
|
|
||||||
onMouseEnter={(event: MouseEvent) => {
|
|
||||||
if (!props.overlay()) return
|
|
||||||
if (props.suppressHover()) return
|
|
||||||
props.onProjectMouseEnter(props.project.worktree, event)
|
|
||||||
}}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
if (props.suppressHover()) props.setSuppressHover(false)
|
|
||||||
if (!props.overlay()) return
|
|
||||||
props.onProjectMouseLeave(props.project.worktree)
|
|
||||||
}}
|
|
||||||
onFocus={() => {
|
|
||||||
if (!props.overlay()) return
|
|
||||||
if (props.suppressHover()) return
|
|
||||||
props.onProjectFocus(props.project.worktree)
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
props.setOpen(false)
|
|
||||||
if (props.selected()) {
|
|
||||||
layout.sidebar.toggle()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
props.navigateToProject(props.project.worktree)
|
|
||||||
}}
|
|
||||||
onBlur={() => props.setOpen(false)}
|
|
||||||
>
|
|
||||||
<ProjectIcon project={props.project} notify working={props.isWorking()} />
|
|
||||||
</ContextMenu.Trigger>
|
|
||||||
<ContextMenu.Portal>
|
|
||||||
<ContextMenu.Content>
|
|
||||||
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
|
|
||||||
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
|
|
||||||
</ContextMenu.Item>
|
|
||||||
<ContextMenu.Item
|
|
||||||
data-action="project-workspaces-toggle"
|
|
||||||
data-project={base64Encode(props.project.worktree)}
|
|
||||||
disabled={props.project.vcs !== "git" && !props.workspacesEnabled(props.project)}
|
|
||||||
onSelect={() => props.toggleProjectWorkspaces(props.project)}
|
|
||||||
>
|
|
||||||
<ContextMenu.ItemLabel>
|
|
||||||
{props.workspacesEnabled(props.project)
|
|
||||||
? props.language.t("sidebar.workspaces.disable")
|
|
||||||
: props.language.t("sidebar.workspaces.enable")}
|
|
||||||
</ContextMenu.ItemLabel>
|
|
||||||
</ContextMenu.Item>
|
|
||||||
<ContextMenu.Item
|
|
||||||
data-action="project-clear-notifications"
|
|
||||||
data-project={base64Encode(props.project.worktree)}
|
|
||||||
disabled={unseenCount() === 0}
|
|
||||||
onSelect={clear}
|
|
||||||
>
|
|
||||||
<ContextMenu.ItemLabel>{props.language.t("sidebar.project.clearNotifications")}</ContextMenu.ItemLabel>
|
|
||||||
</ContextMenu.Item>
|
|
||||||
<ContextMenu.Separator />
|
|
||||||
<ContextMenu.Item
|
|
||||||
data-action="project-close-menu"
|
|
||||||
data-project={base64Encode(props.project.worktree)}
|
|
||||||
onSelect={() => props.closeProject(props.project.worktree)}
|
|
||||||
>
|
|
||||||
<ContextMenu.ItemLabel>{props.language.t("common.close")}</ContextMenu.ItemLabel>
|
|
||||||
</ContextMenu.Item>
|
|
||||||
</ContextMenu.Content>
|
|
||||||
</ContextMenu.Portal>
|
|
||||||
</ContextMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProjectPreviewPanel = (props: {
|
|
||||||
project: LocalProject
|
|
||||||
mobile?: boolean
|
|
||||||
selected: Accessor<boolean>
|
|
||||||
workspaceEnabled: Accessor<boolean>
|
|
||||||
workspaces: Accessor<string[]>
|
|
||||||
label: (directory: string) => string
|
|
||||||
projectSessions: Accessor<ReturnType<typeof sortedRootSessions>>
|
|
||||||
workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions>
|
|
||||||
ctx: ProjectSidebarContext
|
|
||||||
language: ReturnType<typeof useLanguage>
|
|
||||||
}): JSX.Element => (
|
|
||||||
<div class="-m-3 p-2 flex flex-col w-72">
|
|
||||||
<div class="px-4 pt-2 pb-1 flex items-center gap-2">
|
|
||||||
<div class="text-14-medium text-text-strong truncate grow">{displayName(props.project)}</div>
|
|
||||||
</div>
|
|
||||||
<div class="px-4 pb-2 text-12-medium text-text-weak">{props.language.t("sidebar.project.recentSessions")}</div>
|
|
||||||
<div class="px-2 pb-2 flex flex-col gap-2">
|
|
||||||
<Show
|
|
||||||
when={props.workspaceEnabled()}
|
|
||||||
fallback={
|
|
||||||
<For each={props.projectSessions().slice(0, 2)}>
|
|
||||||
{(session) => (
|
|
||||||
<SessionItem
|
|
||||||
{...props.ctx.sessionProps}
|
|
||||||
session={session}
|
|
||||||
list={props.projectSessions()}
|
|
||||||
slug={base64Encode(props.project.worktree)}
|
|
||||||
dense
|
|
||||||
showTooltip
|
|
||||||
mobile={props.mobile}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<For each={props.workspaces()}>
|
|
||||||
{(directory) => {
|
|
||||||
const sessions = createMemo(() => props.workspaceSessions(directory))
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<div class="px-2 py-0.5 flex items-center gap-1 min-w-0">
|
|
||||||
<div class="shrink-0 size-6 flex items-center justify-center">
|
|
||||||
<Icon name="branch" size="small" class="text-icon-base" />
|
|
||||||
</div>
|
|
||||||
<span class="truncate text-14-medium text-text-base">{props.label(directory)}</span>
|
|
||||||
</div>
|
|
||||||
<For each={sessions().slice(0, 2)}>
|
|
||||||
{(session) => (
|
|
||||||
<SessionItem
|
|
||||||
{...props.ctx.sessionProps}
|
|
||||||
session={session}
|
|
||||||
list={sessions()}
|
|
||||||
slug={base64Encode(directory)}
|
|
||||||
dense
|
|
||||||
showTooltip
|
|
||||||
mobile={props.mobile}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<div class="px-2 py-2 border-t border-border-weak-base">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
|
|
||||||
onClick={() => {
|
|
||||||
props.ctx.openSidebar()
|
|
||||||
props.ctx.onHoverOpenChanged(props.project.worktree, false)
|
|
||||||
if (props.selected()) return
|
|
||||||
props.ctx.navigateToProject(props.project.worktree)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{props.language.t("sidebar.project.viewAllSessions")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
export const SortableProject = (props: {
|
|
||||||
project: LocalProject
|
|
||||||
mobile?: boolean
|
|
||||||
ctx: ProjectSidebarContext
|
|
||||||
sortNow: Accessor<number>
|
|
||||||
}): JSX.Element => {
|
|
||||||
const serverSync = useServerSync()
|
|
||||||
const language = useLanguage()
|
|
||||||
const sortable = createSortable(props.project.worktree)
|
|
||||||
const selected = createMemo(() => props.ctx.currentProject()?.worktree === props.project.worktree)
|
|
||||||
const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
|
|
||||||
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
|
|
||||||
const dirs = createMemo(() => props.ctx.workspaceIds(props.project))
|
|
||||||
const [state, setState] = createStore({
|
|
||||||
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() ? isHoverProject() : overlay() && isHoverProject()))
|
|
||||||
|
|
||||||
const hoverOpen = () => isHoverProject() && preview() && !selected() && !state.menu
|
|
||||||
|
|
||||||
const label = (directory: string) => {
|
|
||||||
const [data] = serverSync().child(directory, { bootstrap: false })
|
|
||||||
const kind =
|
|
||||||
directory === props.project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
|
|
||||||
const name = props.ctx.workspaceLabel(directory, data.vcs?.branch, props.project.id)
|
|
||||||
return `${kind} : ${name}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectStore = createMemo(() => serverSync().child(props.project.worktree, { bootstrap: false })[0])
|
|
||||||
const isWorking = createMemo(() =>
|
|
||||||
dirs().some((directory) => {
|
|
||||||
return Object.keys(serverSync().session.data.session_status).some((id) => {
|
|
||||||
if (serverSync().session.get(id)?.location.directory !== directory) return false
|
|
||||||
return serverSync().session.data.session_working(id)
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()))
|
|
||||||
const workspaceSessions = (directory: string) => {
|
|
||||||
const [data] = serverSync().child(directory, { bootstrap: false })
|
|
||||||
return sortedRootSessions(data, props.sortNow())
|
|
||||||
}
|
|
||||||
const tile = () => (
|
|
||||||
<ProjectTile
|
|
||||||
project={props.project}
|
|
||||||
mobile={props.mobile}
|
|
||||||
sidebarHovering={props.ctx.sidebarHovering}
|
|
||||||
selected={selected}
|
|
||||||
active={active}
|
|
||||||
isWorking={isWorking}
|
|
||||||
overlay={overlay}
|
|
||||||
suppressHover={() => state.suppressHover}
|
|
||||||
dirs={dirs}
|
|
||||||
onProjectMouseEnter={props.ctx.onProjectMouseEnter}
|
|
||||||
onProjectMouseLeave={props.ctx.onProjectMouseLeave}
|
|
||||||
onProjectFocus={props.ctx.onProjectFocus}
|
|
||||||
navigateToProject={props.ctx.navigateToProject}
|
|
||||||
showEditProjectDialog={props.ctx.showEditProjectDialog}
|
|
||||||
toggleProjectWorkspaces={props.ctx.toggleProjectWorkspaces}
|
|
||||||
workspacesEnabled={props.ctx.workspacesEnabled}
|
|
||||||
closeProject={props.ctx.closeProject}
|
|
||||||
setMenu={(value) => setState("menu", value)}
|
|
||||||
setOpen={(value) => props.ctx.onHoverOpenChanged(props.project.worktree, value)}
|
|
||||||
setSuppressHover={(value) => setState("suppressHover", value)}
|
|
||||||
language={language}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
// @ts-ignore
|
|
||||||
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
|
|
||||||
<Show when={preview() && !selected()} fallback={tile()}>
|
|
||||||
<HoverCard
|
|
||||||
open={!state.suppressHover && hoverOpen() && !state.menu}
|
|
||||||
openDelay={0}
|
|
||||||
closeDelay={0}
|
|
||||||
placement="right-start"
|
|
||||||
gutter={6}
|
|
||||||
trigger={tile()}
|
|
||||||
onOpenChange={(value) => {
|
|
||||||
if (state.menu) return
|
|
||||||
if (value && state.suppressHover) return
|
|
||||||
props.ctx.onHoverOpenChanged(props.project.worktree, value)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ProjectPreviewPanel
|
|
||||||
project={props.project}
|
|
||||||
mobile={props.mobile}
|
|
||||||
selected={selected}
|
|
||||||
workspaceEnabled={workspaceEnabled}
|
|
||||||
workspaces={workspaces}
|
|
||||||
label={label}
|
|
||||||
projectSessions={projectSessions}
|
|
||||||
workspaceSessions={workspaceSessions}
|
|
||||||
ctx={props.ctx}
|
|
||||||
language={language}
|
|
||||||
/>
|
|
||||||
</HoverCard>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
|
|
||||||
import {
|
|
||||||
DragDropProvider,
|
|
||||||
DragDropSensors,
|
|
||||||
DragOverlay,
|
|
||||||
SortableProvider,
|
|
||||||
closestCenter,
|
|
||||||
type DragEvent,
|
|
||||||
} from "@thisbeyond/solid-dnd"
|
|
||||||
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
|
||||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
|
||||||
import { type LocalProject } from "@/context/layout"
|
|
||||||
|
|
||||||
export const SidebarContent = (props: {
|
|
||||||
mobile?: boolean
|
|
||||||
opened: Accessor<boolean>
|
|
||||||
aimMove: (event: MouseEvent) => void
|
|
||||||
projects: Accessor<LocalProject[]>
|
|
||||||
renderProject: (project: LocalProject) => JSX.Element
|
|
||||||
handleDragStart: (event: unknown) => void
|
|
||||||
handleDragEnd: () => void
|
|
||||||
handleDragOver: (event: DragEvent) => void
|
|
||||||
openProjectLabel: JSX.Element
|
|
||||||
openProjectKeybind: Accessor<string | undefined>
|
|
||||||
onOpenProject: () => void
|
|
||||||
renderProjectOverlay: () => JSX.Element
|
|
||||||
settingsLabel: Accessor<string>
|
|
||||||
settingsKeybind: Accessor<string | undefined>
|
|
||||||
onOpenSettings: () => void
|
|
||||||
helpLabel: Accessor<string>
|
|
||||||
onOpenHelp: () => void
|
|
||||||
renderPanel: () => JSX.Element
|
|
||||||
}): JSX.Element => {
|
|
||||||
const expanded = createMemo(() => !!props.mobile || props.opened())
|
|
||||||
const placement = () => (props.mobile ? "bottom" : "right")
|
|
||||||
let panel: HTMLDivElement | undefined
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const el = panel
|
|
||||||
if (!el) return
|
|
||||||
if (expanded()) {
|
|
||||||
el.removeAttribute("inert")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
el.setAttribute("inert", "")
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex h-full w-full min-w-0 overflow-hidden">
|
|
||||||
<div
|
|
||||||
data-component="sidebar-rail"
|
|
||||||
class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden"
|
|
||||||
onMouseMove={props.aimMove}
|
|
||||||
>
|
|
||||||
<div class="flex-1 min-h-0 w-full">
|
|
||||||
<DragDropProvider
|
|
||||||
onDragStart={props.handleDragStart}
|
|
||||||
onDragEnd={props.handleDragEnd}
|
|
||||||
onDragOver={props.handleDragOver}
|
|
||||||
collisionDetector={closestCenter}
|
|
||||||
>
|
|
||||||
<DragDropSensors />
|
|
||||||
<ConstrainDragXAxis />
|
|
||||||
<div class="h-full w-full flex flex-col items-center gap-3 px-3 py-3 overflow-y-auto no-scrollbar">
|
|
||||||
<SortableProvider ids={props.projects().map((p) => p.worktree)}>
|
|
||||||
<For each={props.projects()}>{(project) => props.renderProject(project)}</For>
|
|
||||||
</SortableProvider>
|
|
||||||
<Tooltip
|
|
||||||
placement={placement()}
|
|
||||||
value={
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span>{props.openProjectLabel}</span>
|
|
||||||
<Show when={!props.mobile && !!props.openProjectKeybind()}>
|
|
||||||
<span class="text-icon-base text-12-medium">{props.openProjectKeybind()}</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
icon="plus"
|
|
||||||
variant="ghost"
|
|
||||||
size="large"
|
|
||||||
onClick={props.onOpenProject}
|
|
||||||
aria-label={typeof props.openProjectLabel === "string" ? props.openProjectLabel : undefined}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<DragOverlay>{props.renderProjectOverlay()}</DragOverlay>
|
|
||||||
</DragDropProvider>
|
|
||||||
</div>
|
|
||||||
<div class="shrink-0 w-full pt-3 pb-6 flex flex-col items-center gap-2">
|
|
||||||
<TooltipKeybind placement={placement()} title={props.settingsLabel()} keybind={props.settingsKeybind() ?? ""}>
|
|
||||||
<IconButton
|
|
||||||
icon="settings-gear"
|
|
||||||
variant="ghost"
|
|
||||||
size="large"
|
|
||||||
onClick={props.onOpenSettings}
|
|
||||||
aria-label={props.settingsLabel()}
|
|
||||||
/>
|
|
||||||
</TooltipKeybind>
|
|
||||||
<Tooltip placement={placement()} value={props.helpLabel()}>
|
|
||||||
<IconButton
|
|
||||||
icon="help"
|
|
||||||
variant="ghost"
|
|
||||||
size="large"
|
|
||||||
onClick={props.onOpenHelp}
|
|
||||||
aria-label={props.helpLabel()}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
ref={(el) => {
|
|
||||||
panel = el
|
|
||||||
}}
|
|
||||||
classList={{ "flex-1 flex h-full min-h-0 min-w-0 overflow-hidden": true, "pointer-events-none": !expanded() }}
|
|
||||||
aria-hidden={!expanded()}
|
|
||||||
>
|
|
||||||
{props.renderPanel()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,489 +0,0 @@
|
|||||||
import { useNavigate, useParams } from "@solidjs/router"
|
|
||||||
import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
|
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { createSortable } from "@thisbeyond/solid-dnd"
|
|
||||||
import { createMediaQuery } from "@solid-primitives/media"
|
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
|
||||||
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
|
||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
|
||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
|
||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
|
||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
|
||||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
|
||||||
import { type LocalProject } from "@/context/layout"
|
|
||||||
import { useServerSync, useQueryOptions } from "@/context/server-sync"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { pathKey } from "@/utils/path-key"
|
|
||||||
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
|
|
||||||
import { sortedRootSessions } from "./helpers"
|
|
||||||
import { useIsFetching } from "@tanstack/solid-query"
|
|
||||||
|
|
||||||
type InlineEditorComponent = (props: {
|
|
||||||
id: string
|
|
||||||
value: Accessor<string>
|
|
||||||
onSave: (next: string) => void
|
|
||||||
class?: string
|
|
||||||
displayClass?: string
|
|
||||||
editing?: boolean
|
|
||||||
stopPropagation?: boolean
|
|
||||||
openOnDblClick?: boolean
|
|
||||||
}) => JSX.Element
|
|
||||||
|
|
||||||
export type WorkspaceSidebarContext = {
|
|
||||||
currentDir: Accessor<string>
|
|
||||||
navList: Accessor<SessionInfo[]>
|
|
||||||
sidebarExpanded: Accessor<boolean>
|
|
||||||
sidebarHovering: Accessor<boolean>
|
|
||||||
clearHoverProjectSoon: () => void
|
|
||||||
prefetchSession: (session: SessionInfo, priority?: "high" | "low") => void
|
|
||||||
archiveSession: (session: SessionInfo) => Promise<void>
|
|
||||||
workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined
|
|
||||||
renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void
|
|
||||||
editorOpen: (id: string) => boolean
|
|
||||||
openEditor: (id: string, value: string) => void
|
|
||||||
closeEditor: () => void
|
|
||||||
setEditor: (key: "value", value: string) => void
|
|
||||||
InlineEditor: InlineEditorComponent
|
|
||||||
isBusy: (directory: string) => boolean
|
|
||||||
workspaceExpanded: (directory: string, local: boolean) => boolean
|
|
||||||
setWorkspaceExpanded: (directory: string, value: boolean) => void
|
|
||||||
showResetWorkspaceDialog: (root: string, directory: string) => void
|
|
||||||
showDeleteWorkspaceDialog: (root: string, directory: string) => void
|
|
||||||
setScrollContainerRef: (el: HTMLDivElement | undefined, mobile?: boolean) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const WorkspaceDragOverlay = (props: {
|
|
||||||
sidebarProject: Accessor<LocalProject | undefined>
|
|
||||||
activeWorkspace: Accessor<string | undefined>
|
|
||||||
workspaceLabel: (directory: string, branch?: string, projectId?: string) => string
|
|
||||||
}): JSX.Element => {
|
|
||||||
const serverSync = useServerSync()
|
|
||||||
const language = useLanguage()
|
|
||||||
const label = createMemo(() => {
|
|
||||||
const project = props.sidebarProject()
|
|
||||||
if (!project) return
|
|
||||||
const directory = props.activeWorkspace()
|
|
||||||
if (!directory) return
|
|
||||||
|
|
||||||
const [workspaceStore] = serverSync().child(directory, { bootstrap: false })
|
|
||||||
const kind =
|
|
||||||
directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
|
|
||||||
const name = props.workspaceLabel(directory, workspaceStore.vcs?.branch, project.id)
|
|
||||||
return `${kind} : ${name}`
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Show when={label()}>
|
|
||||||
{(value) => <div class="bg-background-base rounded-md px-2 py-1 text-14-medium text-text-strong">{value()}</div>}
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const WorkspaceHeader = (props: {
|
|
||||||
local: Accessor<boolean>
|
|
||||||
busy: Accessor<boolean>
|
|
||||||
open: Accessor<boolean>
|
|
||||||
directory: string
|
|
||||||
language: ReturnType<typeof useLanguage>
|
|
||||||
branch: Accessor<string | undefined>
|
|
||||||
workspaceValue: Accessor<string>
|
|
||||||
workspaceEditActive: Accessor<boolean>
|
|
||||||
InlineEditor: WorkspaceSidebarContext["InlineEditor"]
|
|
||||||
renameWorkspace: WorkspaceSidebarContext["renameWorkspace"]
|
|
||||||
setEditor: WorkspaceSidebarContext["setEditor"]
|
|
||||||
projectId?: string
|
|
||||||
}): JSX.Element => (
|
|
||||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
|
||||||
<div class="flex items-center justify-center shrink-0 size-6">
|
|
||||||
<Show when={props.busy()} fallback={<Icon name="branch" size="small" />}>
|
|
||||||
<Spinner class="size-[15px]" />
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<span class="text-14-medium text-text-base shrink-0">
|
|
||||||
{props.local() ? props.language.t("workspace.type.local") : props.language.t("workspace.type.sandbox")} :
|
|
||||||
</span>
|
|
||||||
<Show
|
|
||||||
when={!props.local()}
|
|
||||||
fallback={
|
|
||||||
<span class="text-14-medium text-text-base min-w-0 truncate">
|
|
||||||
{props.branch() ?? getFilename(props.directory)}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<props.InlineEditor
|
|
||||||
id={`workspace:${props.directory}`}
|
|
||||||
value={props.workspaceValue}
|
|
||||||
onSave={(next) => {
|
|
||||||
const trimmed = next.trim()
|
|
||||||
if (!trimmed) return
|
|
||||||
props.renameWorkspace(props.directory, trimmed, props.projectId, props.branch())
|
|
||||||
props.setEditor("value", props.workspaceValue())
|
|
||||||
}}
|
|
||||||
class="text-14-medium text-text-base min-w-0 truncate"
|
|
||||||
displayClass="text-14-medium text-text-base min-w-0 truncate"
|
|
||||||
editing={props.workspaceEditActive()}
|
|
||||||
stopPropagation={false}
|
|
||||||
openOnDblClick={false}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
<div class="flex items-center justify-center shrink-0 overflow-hidden w-0 opacity-0 transition-all duration-200 group-hover/workspace:w-3.5 group-hover/workspace:opacity-100 group-focus-within/workspace:w-3.5 group-focus-within/workspace:opacity-100">
|
|
||||||
<Icon name={props.open() ? "chevron-down" : "chevron-right"} size="small" class="text-icon-base" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const WorkspaceActions = (props: {
|
|
||||||
directory: string
|
|
||||||
local: Accessor<boolean>
|
|
||||||
busy: Accessor<boolean>
|
|
||||||
menuOpen: Accessor<boolean>
|
|
||||||
pendingRename: Accessor<boolean>
|
|
||||||
setMenuOpen: (open: boolean) => void
|
|
||||||
setPendingRename: (value: boolean) => void
|
|
||||||
sidebarHovering: Accessor<boolean>
|
|
||||||
touch: Accessor<boolean>
|
|
||||||
language: ReturnType<typeof useLanguage>
|
|
||||||
workspaceValue: Accessor<string>
|
|
||||||
openEditor: WorkspaceSidebarContext["openEditor"]
|
|
||||||
showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"]
|
|
||||||
showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"]
|
|
||||||
root: string
|
|
||||||
clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"]
|
|
||||||
navigateToNewSession: () => void
|
|
||||||
}): JSX.Element => (
|
|
||||||
<div
|
|
||||||
class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 transition-opacity"
|
|
||||||
classList={{
|
|
||||||
"opacity-100 pointer-events-auto": props.menuOpen(),
|
|
||||||
"opacity-0 pointer-events-none": !props.menuOpen(),
|
|
||||||
"group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto": true,
|
|
||||||
"group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto": true,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu
|
|
||||||
modal={!props.sidebarHovering()}
|
|
||||||
open={props.menuOpen()}
|
|
||||||
onOpenChange={(open) => props.setMenuOpen(open)}
|
|
||||||
>
|
|
||||||
<Tooltip value={props.language.t("common.moreOptions")} placement="top">
|
|
||||||
<DropdownMenu.Trigger
|
|
||||||
as={IconButton}
|
|
||||||
icon="dot-grid"
|
|
||||||
variant="ghost"
|
|
||||||
class="size-6 rounded-md"
|
|
||||||
data-action="workspace-menu"
|
|
||||||
data-workspace={base64Encode(props.directory)}
|
|
||||||
aria-label={props.language.t("common.moreOptions")}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<DropdownMenu.Portal>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
onCloseAutoFocus={(event) => {
|
|
||||||
if (!props.pendingRename()) return
|
|
||||||
event.preventDefault()
|
|
||||||
props.setPendingRename(false)
|
|
||||||
props.openEditor(`workspace:${props.directory}`, props.workspaceValue())
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
disabled={props.local()}
|
|
||||||
onSelect={() => {
|
|
||||||
props.setPendingRename(true)
|
|
||||||
props.setMenuOpen(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemLabel>{props.language.t("common.rename")}</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
// TODO: Restore reset when V2 exposes project-copy reset and instance disposal.
|
|
||||||
// onSelect={() => props.showResetWorkspaceDialog(props.root, props.directory)}
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemLabel>{props.language.t("common.reset")}</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
disabled={props.local() || props.busy()}
|
|
||||||
onSelect={() => props.showDeleteWorkspaceDialog(props.root, props.directory)}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemLabel>{props.language.t("common.delete")}</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Portal>
|
|
||||||
</DropdownMenu>
|
|
||||||
<Show when={!props.touch()}>
|
|
||||||
<Tooltip value={props.language.t("command.session.new")} placement="top">
|
|
||||||
<IconButtonV2
|
|
||||||
icon={<IconV2 name="edit" size="small" />}
|
|
||||||
variant="ghost"
|
|
||||||
size="small"
|
|
||||||
class="size-6 rounded-md opacity-0 pointer-events-none group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto"
|
|
||||||
data-action="workspace-new-session"
|
|
||||||
data-workspace={base64Encode(props.directory)}
|
|
||||||
aria-label={props.language.t("command.session.new")}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
props.clearHoverProjectSoon()
|
|
||||||
props.navigateToNewSession()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const WorkspaceSessionList = (props: {
|
|
||||||
slug: Accessor<string>
|
|
||||||
mobile?: boolean
|
|
||||||
ctx: WorkspaceSidebarContext
|
|
||||||
showNew: Accessor<boolean>
|
|
||||||
loading: Accessor<boolean>
|
|
||||||
sessions: Accessor<SessionInfo[]>
|
|
||||||
hasMore: Accessor<boolean>
|
|
||||||
loadMore: () => Promise<void>
|
|
||||||
language: ReturnType<typeof useLanguage>
|
|
||||||
}): JSX.Element => (
|
|
||||||
<nav class="flex flex-col gap-1">
|
|
||||||
<Show when={props.showNew()}>
|
|
||||||
<NewSessionItem
|
|
||||||
slug={props.slug()}
|
|
||||||
mobile={props.mobile}
|
|
||||||
sidebarExpanded={props.ctx.sidebarExpanded}
|
|
||||||
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
<Show when={props.loading()}>
|
|
||||||
<SessionSkeleton />
|
|
||||||
</Show>
|
|
||||||
<For each={props.sessions()}>
|
|
||||||
{(session) => (
|
|
||||||
<SessionItem
|
|
||||||
session={session}
|
|
||||||
list={props.sessions()}
|
|
||||||
navList={props.ctx.navList}
|
|
||||||
slug={props.slug()}
|
|
||||||
mobile={props.mobile}
|
|
||||||
showChild
|
|
||||||
sidebarExpanded={props.ctx.sidebarExpanded}
|
|
||||||
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
|
||||||
prefetchSession={props.ctx.prefetchSession}
|
|
||||||
archiveSession={props.ctx.archiveSession}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
<Show when={props.hasMore()}>
|
|
||||||
<div class="relative w-full py-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
class="flex w-full text-left justify-start text-14-regular text-text-weak pl-2 pr-10"
|
|
||||||
size="large"
|
|
||||||
onClick={(e: MouseEvent) => {
|
|
||||||
void props.loadMore()
|
|
||||||
;(e.currentTarget as HTMLButtonElement).blur()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{props.language.t("common.loadMore")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</nav>
|
|
||||||
)
|
|
||||||
|
|
||||||
export const SortableWorkspace = (props: {
|
|
||||||
ctx: WorkspaceSidebarContext
|
|
||||||
directory: string
|
|
||||||
project: LocalProject
|
|
||||||
sortNow: Accessor<number>
|
|
||||||
mobile?: boolean
|
|
||||||
}): JSX.Element => {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const params = useParams()
|
|
||||||
const serverSync = useServerSync()
|
|
||||||
const queryOptions = useQueryOptions()
|
|
||||||
const language = useLanguage()
|
|
||||||
const sortable = createSortable(props.directory)
|
|
||||||
const [workspaceStore, setWorkspaceStore] = serverSync().child(props.directory, { bootstrap: false })
|
|
||||||
const [menu, setMenu] = createStore({
|
|
||||||
open: false,
|
|
||||||
pendingRename: false,
|
|
||||||
})
|
|
||||||
const slug = createMemo(() => base64Encode(props.directory))
|
|
||||||
const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow()))
|
|
||||||
const local = createMemo(() => props.directory === props.project.worktree)
|
|
||||||
const active = createMemo(() => pathKey(props.ctx.currentDir()) === pathKey(props.directory))
|
|
||||||
const workspaceValue = createMemo(() => {
|
|
||||||
const branch = workspaceStore.vcs?.branch
|
|
||||||
const name = branch ?? getFilename(props.directory)
|
|
||||||
return props.ctx.workspaceName(props.directory, props.project.id, branch) ?? name
|
|
||||||
})
|
|
||||||
const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local()))
|
|
||||||
const boot = createMemo(() => open() || active())
|
|
||||||
const count = createMemo(() => sessions()?.length ?? 0)
|
|
||||||
const hasMore = createMemo(() => workspaceStore.sessionTotal > count())
|
|
||||||
const fetching = useIsFetching(() => queryOptions().sessions(pathKey(props.directory)))
|
|
||||||
const busy = createMemo(() => props.ctx.isBusy(props.directory))
|
|
||||||
const loading = () => fetching() > 0 && count() === 0
|
|
||||||
const touch = createMediaQuery("(hover: none)")
|
|
||||||
const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id)))
|
|
||||||
const loadMore = async () => {
|
|
||||||
setWorkspaceStore("limit", (limit) => (limit ?? 0) + 5)
|
|
||||||
await serverSync().project.loadSessions(props.directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspaceEditActive = createMemo(() => props.ctx.editorOpen(`workspace:${props.directory}`))
|
|
||||||
const header = () => (
|
|
||||||
<WorkspaceHeader
|
|
||||||
local={local}
|
|
||||||
busy={busy}
|
|
||||||
open={open}
|
|
||||||
directory={props.directory}
|
|
||||||
language={language}
|
|
||||||
branch={() => workspaceStore.vcs?.branch}
|
|
||||||
workspaceValue={workspaceValue}
|
|
||||||
workspaceEditActive={workspaceEditActive}
|
|
||||||
InlineEditor={props.ctx.InlineEditor}
|
|
||||||
renameWorkspace={props.ctx.renameWorkspace}
|
|
||||||
setEditor={props.ctx.setEditor}
|
|
||||||
projectId={props.project.id}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
const openWrapper = (value: boolean) => {
|
|
||||||
props.ctx.setWorkspaceExpanded(props.directory, value)
|
|
||||||
if (value) return
|
|
||||||
if (props.ctx.editorOpen(`workspace:${props.directory}`)) props.ctx.closeEditor()
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!boot()) return
|
|
||||||
serverSync().child(props.directory, { bootstrap: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
// @ts-ignore
|
|
||||||
use:sortable
|
|
||||||
classList={{
|
|
||||||
"opacity-30": sortable.isActiveDraggable,
|
|
||||||
"opacity-50 pointer-events-none": busy(),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Collapsible variant="ghost" open={open()} class="shrink-0" onOpenChange={openWrapper}>
|
|
||||||
<div class="py-1">
|
|
||||||
<div
|
|
||||||
class="group/workspace relative"
|
|
||||||
data-component="workspace-item"
|
|
||||||
data-workspace={base64Encode(props.directory)}
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<Show
|
|
||||||
when={workspaceEditActive()}
|
|
||||||
fallback={
|
|
||||||
<Collapsible.Trigger
|
|
||||||
class={`flex items-center justify-between w-full pl-2 py-1.5 rounded-md hover:bg-surface-raised-base-hover transition-[padding] duration-200 ${
|
|
||||||
menu.open ? "pr-16" : "pr-2"
|
|
||||||
} group-hover/workspace:pr-16 group-focus-within/workspace:pr-16`}
|
|
||||||
data-action="workspace-toggle"
|
|
||||||
data-workspace={base64Encode(props.directory)}
|
|
||||||
>
|
|
||||||
{header()}
|
|
||||||
</Collapsible.Trigger>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class={`flex items-center justify-between w-full pl-2 py-1.5 rounded-md transition-[padding] duration-200 ${
|
|
||||||
menu.open ? "pr-16" : "pr-2"
|
|
||||||
} group-hover/workspace:pr-16 group-focus-within/workspace:pr-16`}
|
|
||||||
>
|
|
||||||
{header()}
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<WorkspaceActions
|
|
||||||
directory={props.directory}
|
|
||||||
local={local}
|
|
||||||
busy={busy}
|
|
||||||
menuOpen={() => menu.open}
|
|
||||||
pendingRename={() => menu.pendingRename}
|
|
||||||
setMenuOpen={(open) => setMenu("open", open)}
|
|
||||||
setPendingRename={(value) => setMenu("pendingRename", value)}
|
|
||||||
sidebarHovering={props.ctx.sidebarHovering}
|
|
||||||
touch={touch}
|
|
||||||
language={language}
|
|
||||||
workspaceValue={workspaceValue}
|
|
||||||
openEditor={props.ctx.openEditor}
|
|
||||||
showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog}
|
|
||||||
showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog}
|
|
||||||
root={props.project.worktree}
|
|
||||||
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
|
||||||
navigateToNewSession={() => navigate(`/${slug()}/session`)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Collapsible.Content>
|
|
||||||
<WorkspaceSessionList
|
|
||||||
slug={slug}
|
|
||||||
mobile={props.mobile}
|
|
||||||
ctx={props.ctx}
|
|
||||||
showNew={showNew}
|
|
||||||
loading={loading}
|
|
||||||
sessions={sessions}
|
|
||||||
hasMore={hasMore}
|
|
||||||
loadMore={loadMore}
|
|
||||||
language={language}
|
|
||||||
/>
|
|
||||||
</Collapsible.Content>
|
|
||||||
</Collapsible>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LocalWorkspace = (props: {
|
|
||||||
ctx: WorkspaceSidebarContext
|
|
||||||
project: LocalProject
|
|
||||||
sortNow: Accessor<number>
|
|
||||||
mobile?: boolean
|
|
||||||
}): JSX.Element => {
|
|
||||||
const serverSync = useServerSync()
|
|
||||||
const queryOptions = useQueryOptions()
|
|
||||||
const language = useLanguage()
|
|
||||||
const workspace = createMemo(() => {
|
|
||||||
const [store, setStore] = serverSync().child(props.project.worktree)
|
|
||||||
return { store, setStore }
|
|
||||||
})
|
|
||||||
const slug = createMemo(() => base64Encode(props.project.worktree))
|
|
||||||
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
|
|
||||||
const count = createMemo(() => sessions()?.length ?? 0)
|
|
||||||
const fetching = useIsFetching(() => queryOptions().sessions(pathKey(props.project.worktree)))
|
|
||||||
const hasMore = createMemo(() => workspace().store.sessionTotal > count())
|
|
||||||
const loading = () => fetching() > 0 && count() === 0
|
|
||||||
const loadMore = async () => {
|
|
||||||
workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
|
|
||||||
await serverSync().project.loadSessions(props.project.worktree)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={(el) => props.ctx.setScrollContainerRef(el, props.mobile)}
|
|
||||||
class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]"
|
|
||||||
>
|
|
||||||
<WorkspaceSessionList
|
|
||||||
slug={slug}
|
|
||||||
mobile={props.mobile}
|
|
||||||
ctx={props.ctx}
|
|
||||||
showNew={() => false}
|
|
||||||
loading={loading}
|
|
||||||
sessions={sessions}
|
|
||||||
hasMore={hasMore}
|
|
||||||
loadMore={loadMore}
|
|
||||||
language={language}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@ import { For } from "solid-js"
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { DockShell } from "@opencode-ai/ui/dock-surface"
|
import { DockShell } from "@opencode-ai/ui/dock-surface"
|
||||||
import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
|
import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
|
||||||
import { SettingsProvider, useSettings } from "@/context/settings"
|
import { SettingsProvider } from "@/context/settings"
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: "Composer/Revert Dock",
|
title: "Composer/Revert Dock",
|
||||||
@@ -21,9 +21,6 @@ Real \`SessionRevertDock\` from app code, rendered above a mock composer card.
|
|||||||
The live composer overlaps the dock's bottom by 18px (\`session-composer-region-controller.ts\` \`lift()\`).
|
The live composer overlaps the dock's bottom by 18px (\`session-composer-region-controller.ts\` \`lift()\`).
|
||||||
The card below reproduces that overlap so the collapsed/expanded cutoff behavior can be verified in isolation.
|
The card below reproduces that overlap so the collapsed/expanded cutoff behavior can be verified in isolation.
|
||||||
|
|
||||||
### Layout split
|
|
||||||
Use the **Layout** button to toggle \`newLayoutDesigns\` and preview both the v2 dock and the legacy (v1) \`DockTray\` fallback.
|
|
||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
- \`onRestore\` only mutates local story state, so nothing in the real session is affected.
|
- \`onRestore\` only mutates local story state, so nothing in the real session is affected.
|
||||||
- Click the header to expand/collapse. Click "Restore message" to remove a row.`,
|
- Click the header to expand/collapse. Click "Restore message" to remove a row.`,
|
||||||
@@ -53,11 +50,9 @@ const btn = (accent?: boolean) =>
|
|||||||
}) as const
|
}) as const
|
||||||
|
|
||||||
function Stage(props: { count: number }) {
|
function Stage(props: { count: number }) {
|
||||||
const settings = useSettings()
|
|
||||||
const seed = () => messages.slice(0, props.count).map((text, index) => ({ id: `rolled-${index}`, text }))
|
const seed = () => messages.slice(0, props.count).map((text, index) => ({ id: `rolled-${index}`, text }))
|
||||||
const [store, setStore] = createStore({ items: seed() })
|
const [store, setStore] = createStore({ items: seed() })
|
||||||
|
|
||||||
const v2 = () => settings.general.newLayoutDesigns()
|
|
||||||
const reset = () => setStore("items", seed())
|
const reset = () => setStore("items", seed())
|
||||||
const restore = (id: string) =>
|
const restore = (id: string) =>
|
||||||
setStore(
|
setStore(
|
||||||
@@ -71,22 +66,15 @@ function Stage(props: { count: number }) {
|
|||||||
<button style={btn()} onClick={reset}>
|
<button style={btn()} onClick={reset}>
|
||||||
Reset ({props.count})
|
Reset ({props.count})
|
||||||
</button>
|
</button>
|
||||||
<button style={btn(v2())} onClick={() => settings.general.setNewLayoutDesigns(!v2())}>
|
|
||||||
Layout: {v2() ? "v2" : "v1"}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reproduce the real composer stack: dock + card overlapping the dock's bottom by lift() = 18px */}
|
{/* Reproduce the real composer stack: dock + card overlapping the dock's bottom by lift() = 18px */}
|
||||||
<div style={{ display: "flex", "flex-direction": "column" }}>
|
<div style={{ display: "flex", "flex-direction": "column" }}>
|
||||||
<SessionRevertDock items={store.items} onRestore={restore} />
|
<SessionRevertDock items={store.items} onRestore={restore} />
|
||||||
<DockShell
|
<DockShell
|
||||||
data-dock-border-underlay={v2() ? "v2" : "legacy"}
|
data-dock-border-underlay="v2"
|
||||||
style={{ position: "relative", "z-index": 70, "margin-top": "-18px" }}
|
style={{ position: "relative", "z-index": 70, "margin-top": "-18px" }}
|
||||||
classList={{
|
class="min-h-24 w-full rounded-[12px] bg-v2-background-bg-base px-4 py-3 text-[13px] text-v2-text-text-faint"
|
||||||
"min-h-24 w-full rounded-[12px] px-4 py-3 text-[13px]": true,
|
|
||||||
"bg-v2-background-bg-base text-v2-text-text-faint": v2(),
|
|
||||||
"text-text-weak": !v2(),
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Ask anything...
|
Ask anything...
|
||||||
</DockShell>
|
</DockShell>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
|||||||
import { useMutation } from "@tanstack/solid-query"
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
import fuzzysort from "fuzzysort"
|
import fuzzysort from "fuzzysort"
|
||||||
import { type Accessor, For, Show, createMemo } from "solid-js"
|
import { type Accessor, For, Show, createMemo } from "solid-js"
|
||||||
import type { useServerManagementController } from "@/components/dialog-select-server"
|
import type { ServerCollectionController } from "@/components/server/server-management-controller"
|
||||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
@@ -17,8 +17,6 @@ import { DialogAddWslServer } from "./dialog-add-server"
|
|||||||
import { useWslServers } from "./context"
|
import { useWslServers } from "./context"
|
||||||
import { wslOpencodeAction, wslRuntimeRetryable } from "./settings-model"
|
import { wslOpencodeAction, wslRuntimeRetryable } from "./settings-model"
|
||||||
|
|
||||||
type Controller = ReturnType<typeof useServerManagementController>
|
|
||||||
|
|
||||||
export function isWslServer(server: ServerConnection.Any) {
|
export function isWslServer(server: ServerConnection.Any) {
|
||||||
return server.type === "sidecar" && server.variant === "wsl"
|
return server.type === "sidecar" && server.variant === "wsl"
|
||||||
}
|
}
|
||||||
@@ -28,7 +26,7 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const openAddWsl = () => {
|
const openAddWsl = () => {
|
||||||
dialog.push(() => <DialogAddWslServer />)
|
void dialog.push(() => <DialogAddWslServer />)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Show
|
<Show
|
||||||
@@ -67,7 +65,7 @@ export function useFilteredWslServers(filter: Accessor<string>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WslServerSettings(props: {
|
export function WslServerSettings(props: {
|
||||||
controller: Controller
|
domain: Pick<ServerCollectionController, "collection" | "defaults" | "connection">
|
||||||
servers: ReturnType<typeof useFilteredWslServers>
|
servers: ReturnType<typeof useFilteredWslServers>
|
||||||
}) {
|
}) {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
@@ -86,7 +84,7 @@ export function WslServerSettings(props: {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const remove = (key: ServerConnection.Key) => {
|
const remove = (key: ServerConnection.Key) => {
|
||||||
request.mutate(() => props.controller.handleRemove(key))
|
request.mutate(() => props.domain.connection.remove(key))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -100,7 +98,7 @@ export function WslServerSettings(props: {
|
|||||||
return (
|
return (
|
||||||
<div class="settings-v2-servers-row">
|
<div class="settings-v2-servers-row">
|
||||||
<div class="settings-v2-servers-lead">
|
<div class="settings-v2-servers-lead">
|
||||||
<ServerHealthIndicator health={props.controller.status()[key]} />
|
<ServerHealthIndicator health={props.domain.collection.health()[key]} />
|
||||||
<div class="settings-v2-servers-copy">
|
<div class="settings-v2-servers-copy">
|
||||||
<span class="flex min-w-0 items-center gap-1">
|
<span class="flex min-w-0 items-center gap-1">
|
||||||
<span class="settings-v2-servers-name">{item.config.distro}</span>
|
<span class="settings-v2-servers-name">{item.config.distro}</span>
|
||||||
@@ -114,7 +112,7 @@ export function WslServerSettings(props: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-v2-servers-actions">
|
<div class="settings-v2-servers-actions">
|
||||||
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
|
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
|
||||||
<Tag>{language.t("dialog.server.status.default")}</Tag>
|
<Tag>{language.t("dialog.server.status.default")}</Tag>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={opencodeAction()}>
|
<Show when={opencodeAction()}>
|
||||||
@@ -145,13 +143,13 @@ export function WslServerSettings(props: {
|
|||||||
{language.t("wsl.server.retryStart")}
|
{language.t("wsl.server.retryStart")}
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}>
|
<Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
|
||||||
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
|
<MenuV2.Item onSelect={() => props.domain.defaults.set(key)}>
|
||||||
{language.t("dialog.server.menu.default")}
|
{language.t("dialog.server.menu.default")}
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
|
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
|
||||||
<MenuV2.Item onSelect={() => props.controller.setDefault(null)}>
|
<MenuV2.Item onSelect={() => props.domain.defaults.set(null)}>
|
||||||
{language.t("dialog.server.menu.defaultRemove")}
|
{language.t("dialog.server.menu.defaultRemove")}
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -68,7 +68,10 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
|||||||
}),
|
}),
|
||||||
Spec.make("debug", {
|
Spec.make("debug", {
|
||||||
description: "Debugging and troubleshooting tools",
|
description: "Debugging and troubleshooting tools",
|
||||||
commands: [Spec.make("agents", { description: "List all agents" })],
|
commands: [
|
||||||
|
Spec.make("agents", { description: "List all agents" }),
|
||||||
|
Spec.make("config", { description: "Show resolved configuration" }),
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
Spec.make("console", {
|
Spec.make("console", {
|
||||||
description: "Manage OpenCode Console access",
|
description: "Manage OpenCode Console access",
|
||||||
@@ -137,6 +140,32 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
|||||||
description: "List all available models",
|
description: "List all available models",
|
||||||
params: ServerParams,
|
params: ServerParams,
|
||||||
}),
|
}),
|
||||||
|
Spec.make("export", {
|
||||||
|
description: "Export session data as JSON",
|
||||||
|
params: {
|
||||||
|
...ServerParams,
|
||||||
|
session: Flag.string("session").pipe(
|
||||||
|
Flag.withAlias("s"),
|
||||||
|
Flag.withDescription("Session ID to export to stdout"),
|
||||||
|
Flag.optional,
|
||||||
|
),
|
||||||
|
sanitize: Flag.boolean("sanitize").pipe(
|
||||||
|
Flag.withDescription("Redact sensitive transcript and file data"),
|
||||||
|
Flag.withDefault(false),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Spec.make("import", {
|
||||||
|
description: "Import session data from a JSON file or URL",
|
||||||
|
params: {
|
||||||
|
...ServerParams,
|
||||||
|
file: Argument.string("file").pipe(Argument.withDescription("JSON file or URL to import")),
|
||||||
|
directory: Flag.string("directory").pipe(
|
||||||
|
Flag.withDescription("Directory in which to import the session"),
|
||||||
|
Flag.optional,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}),
|
||||||
Spec.make("mini", {
|
Spec.make("mini", {
|
||||||
description: "Start the minimal interactive interface",
|
description: "Start the minimal interactive interface",
|
||||||
params: {
|
params: {
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { EOL } from "os"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { OpenCode } from "@opencode-ai/client"
|
||||||
|
import { Service } from "@opencode-ai/client/effect/service"
|
||||||
|
import { Commands } from "../../commands"
|
||||||
|
import { Runtime } from "../../../framework/runtime"
|
||||||
|
import { ServiceConfig } from "../../../services/service-config"
|
||||||
|
|
||||||
|
export default Runtime.handler(
|
||||||
|
Commands.commands.debug.commands.config,
|
||||||
|
Effect.fn("cli.debug.config")(function* () {
|
||||||
|
const options = yield* ServiceConfig.options()
|
||||||
|
const found = yield* Service.discover(options)
|
||||||
|
const endpoint = found ?? (yield* Service.ensure(options))
|
||||||
|
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||||
|
const entries = yield* Effect.promise(() => client.config.get({ location: { directory: process.cwd() } }))
|
||||||
|
process.stdout.write(JSON.stringify(entries, null, 2) + EOL)
|
||||||
|
}),
|
||||||
|
)
|
||||||
@@ -22,6 +22,7 @@ export default Runtime.handler(Commands, (input) =>
|
|||||||
const server = yield* ServerConnection.resolve({
|
const server = yield* ServerConnection.resolve({
|
||||||
server: Option.getOrUndefined(input.server),
|
server: Option.getOrUndefined(input.server),
|
||||||
standalone: input.standalone,
|
standalone: input.standalone,
|
||||||
|
mismatch: "replace",
|
||||||
onStart: (reason, previousVersion) => {
|
onStart: (reason, previousVersion) => {
|
||||||
if (reason === "version-mismatch" && preflight.begin(previousVersion)) return
|
if (reason === "version-mismatch" && preflight.begin(previousVersion)) return
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { OpenCode, type SessionInfo } from "@opencode-ai/client"
|
||||||
|
import { Service } from "@opencode-ai/client/effect/service"
|
||||||
|
import { Effect, Option } from "effect"
|
||||||
|
import { EOL, tmpdir } from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
import { emitKeypressEvents, type Key } from "node:readline"
|
||||||
|
import { Commands } from "../commands"
|
||||||
|
import { Runtime } from "../../framework/runtime"
|
||||||
|
import { ServerConnection } from "../../services/server-connection"
|
||||||
|
|
||||||
|
export default Runtime.handler(
|
||||||
|
Commands.commands.export,
|
||||||
|
Effect.fn("cli.export")(function* (input) {
|
||||||
|
const server = yield* ServerConnection.resolve({
|
||||||
|
server: Option.getOrUndefined(input.server),
|
||||||
|
standalone: input.standalone,
|
||||||
|
})
|
||||||
|
const client = OpenCode.make({
|
||||||
|
baseUrl: server.endpoint.url,
|
||||||
|
headers: Service.headers(server.endpoint),
|
||||||
|
})
|
||||||
|
const requested = Option.getOrUndefined(input.session)
|
||||||
|
const selected = requested
|
||||||
|
? undefined
|
||||||
|
: yield* Effect.promise(async () => {
|
||||||
|
const location = await client.location.get({ location: { directory: process.cwd() } })
|
||||||
|
const page = await client.session.list({
|
||||||
|
directory: location.directory,
|
||||||
|
workspace: location.workspaceID,
|
||||||
|
parentID: null,
|
||||||
|
order: "desc",
|
||||||
|
limit: 50,
|
||||||
|
})
|
||||||
|
if (page.data.length === 0) {
|
||||||
|
process.stderr.write(`No sessions found${EOL}`)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return selectSession(page.data, input.sanitize)
|
||||||
|
})
|
||||||
|
const sessionID = requested ?? selected?.session.id
|
||||||
|
if (!sessionID) return
|
||||||
|
const data = yield* Effect.promise(() =>
|
||||||
|
client.session.export({ sessionID, sanitize: selected?.sanitize ?? input.sanitize }),
|
||||||
|
)
|
||||||
|
process.stdout.write(yield* Effect.promise(() => writeExport(data, sessionID, requested !== undefined)))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
type Selection = { session: SessionInfo; sanitize: boolean }
|
||||||
|
|
||||||
|
function selectSession(sessions: SessionInfo[], initialSanitize: boolean) {
|
||||||
|
if (!process.stdin.isTTY) return Promise.reject(new Error("Session ID is required when stdin is not interactive"))
|
||||||
|
const input = process.stdin
|
||||||
|
const output = process.stderr
|
||||||
|
const wasRaw = input.isRaw
|
||||||
|
const wasPaused = input.isPaused()
|
||||||
|
const date = new Intl.DateTimeFormat(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
})
|
||||||
|
const columns = output.columns ?? 100
|
||||||
|
const titleWidth = Math.max(8, Math.min(48, columns - 34))
|
||||||
|
let selected = 0
|
||||||
|
let offset = 0
|
||||||
|
let sanitize = initialSanitize
|
||||||
|
let height = 0
|
||||||
|
|
||||||
|
const render = () => {
|
||||||
|
const visible = sessions.slice(offset, offset + 10)
|
||||||
|
const lines = [" \x1b[36mExport session\x1b[0m", ""]
|
||||||
|
lines.push(
|
||||||
|
...visible.map((session) => {
|
||||||
|
const index = sessions.indexOf(session)
|
||||||
|
const title = (session.title ?? "Untitled session").slice(0, titleWidth).padEnd(titleWidth)
|
||||||
|
const updated = date.format(session.time.updated).slice(0, 18).padEnd(18)
|
||||||
|
const row = `${index === selected ? ">" : " "} ${title} ${updated} ${session.id.slice(-8)}`
|
||||||
|
return index === selected ? `\x1b[1m${row}\x1b[0m` : row
|
||||||
|
}),
|
||||||
|
"",
|
||||||
|
` [${sanitize ? "x" : " "}] sanitize sensitive data`,
|
||||||
|
"",
|
||||||
|
" navigate \x1b[2mup/down\x1b[0m sanitize \x1b[2mspace\x1b[0m export \x1b[2menter\x1b[0m cancel \x1b[2mesc\x1b[0m",
|
||||||
|
)
|
||||||
|
if (height > 0) output.write(`\x1b[${height}F\x1b[J`)
|
||||||
|
output.write(lines.join(EOL) + EOL)
|
||||||
|
height = lines.length
|
||||||
|
}
|
||||||
|
const clear = () => {
|
||||||
|
if (height > 0) output.write(`\x1b[${height}F\x1b[J`)
|
||||||
|
output.write("\x1b[?25h")
|
||||||
|
input.removeListener("keypress", onKeypress)
|
||||||
|
input.setRawMode(wasRaw ?? false)
|
||||||
|
if (wasPaused) input.pause()
|
||||||
|
}
|
||||||
|
const onKeypress = (value: string | undefined, key: Key) => {
|
||||||
|
if (key.name === "up") {
|
||||||
|
selected = (selected - 1 + sessions.length) % sessions.length
|
||||||
|
if (selected === sessions.length - 1) offset = Math.max(0, sessions.length - 10)
|
||||||
|
if (selected < offset) offset = selected
|
||||||
|
}
|
||||||
|
if (key.name === "down") {
|
||||||
|
selected = (selected + 1) % sessions.length
|
||||||
|
if (selected === 0) offset = 0
|
||||||
|
if (selected >= offset + 10) offset = selected - 9
|
||||||
|
}
|
||||||
|
if (key.name === "space" || value === " ") sanitize = !sanitize
|
||||||
|
if (key.name === "return") return finish(sessions[selected])
|
||||||
|
if (key.name === "escape" || (key.ctrl && key.name === "c")) return cancel()
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
const finish = (session: SessionInfo) => {
|
||||||
|
clear()
|
||||||
|
resolveSelection?.({ session, sanitize })
|
||||||
|
}
|
||||||
|
const cancel = () => {
|
||||||
|
clear()
|
||||||
|
resolveSelection?.()
|
||||||
|
}
|
||||||
|
let resolveSelection: ((selection?: Selection) => void) | undefined
|
||||||
|
|
||||||
|
emitKeypressEvents(input)
|
||||||
|
input.setRawMode(true)
|
||||||
|
input.resume()
|
||||||
|
input.on("keypress", onKeypress)
|
||||||
|
output.write("\x1b[?25l")
|
||||||
|
render()
|
||||||
|
return new Promise<Selection | undefined>((resolve) => {
|
||||||
|
resolveSelection = resolve
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeExport(data: unknown, sessionID: string, stdout: boolean) {
|
||||||
|
const json = JSON.stringify(data, null, 2) + EOL
|
||||||
|
if (stdout) return json
|
||||||
|
const file = path.join(tmpdir(), `opencode-session-${sessionID}-${crypto.randomUUID().slice(0, 8)}.json`)
|
||||||
|
await Bun.write(file, json)
|
||||||
|
return file + EOL
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { OpenCode } from "@opencode-ai/client"
|
||||||
|
import { Service } from "@opencode-ai/client/effect/service"
|
||||||
|
import { Session } from "@opencode-ai/schema/session"
|
||||||
|
import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
|
||||||
|
import { Effect, Option, Schema } from "effect"
|
||||||
|
import { EOL } from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
import { Commands } from "../commands"
|
||||||
|
import { Runtime } from "../../framework/runtime"
|
||||||
|
import { ServerConnection } from "../../services/server-connection"
|
||||||
|
|
||||||
|
export default Runtime.handler(
|
||||||
|
Commands.commands.import,
|
||||||
|
Effect.fn("cli.import")(function* (input) {
|
||||||
|
const text = yield* Effect.tryPromise({
|
||||||
|
try: () =>
|
||||||
|
input.file.startsWith("http://") || input.file.startsWith("https://")
|
||||||
|
? fetch(input.file).then((response) => {
|
||||||
|
if (!response.ok) throw new Error(`Failed to fetch session data: ${response.statusText}`)
|
||||||
|
return response.text()
|
||||||
|
})
|
||||||
|
: Bun.file(input.file).text(),
|
||||||
|
catch: (cause) => new Error(`Failed to read session data: ${cause instanceof Error ? cause.message : String(cause)}`),
|
||||||
|
})
|
||||||
|
const data = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(SessionTransfer.Data))(text)
|
||||||
|
const encoded = Schema.encodeSync(SessionTransfer.Data)(data)
|
||||||
|
const server = yield* ServerConnection.resolve({
|
||||||
|
server: Option.getOrUndefined(input.server),
|
||||||
|
standalone: input.standalone,
|
||||||
|
})
|
||||||
|
const client = OpenCode.make({
|
||||||
|
baseUrl: server.endpoint.url,
|
||||||
|
headers: Service.headers(server.endpoint),
|
||||||
|
})
|
||||||
|
const location = yield* Effect.promise(() =>
|
||||||
|
client.location.get({
|
||||||
|
location: { directory: path.resolve(Option.getOrElse(input.directory, () => process.cwd())) },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const response = yield* Effect.promise(() =>
|
||||||
|
fetch(new URL("/api/session/import", server.endpoint.url), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...Service.headers(server.endpoint), "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
...encoded,
|
||||||
|
location: { directory: location.directory, workspaceID: location.workspaceID },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (response.status === 409) {
|
||||||
|
process.stderr.write(`Session already exists${EOL}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!response.ok) yield* Effect.fail(new Error(`Failed to import session: ${response.statusText}`))
|
||||||
|
const imported = yield* Schema.decodeUnknownEffect(
|
||||||
|
Schema.fromJsonString(Schema.Struct({ data: Session.Info })),
|
||||||
|
)(yield* Effect.promise(() => response.text()))
|
||||||
|
process.stdout.write(`Imported session: ${imported.data.id}${EOL}`)
|
||||||
|
}),
|
||||||
|
)
|
||||||
@@ -34,7 +34,6 @@ function icon(status: McpServer["status"]) {
|
|||||||
case "needs_auth":
|
case "needs_auth":
|
||||||
return "⚠"
|
return "⚠"
|
||||||
case "failed":
|
case "failed":
|
||||||
case "needs_client_registration":
|
|
||||||
return "✗"
|
return "✗"
|
||||||
default:
|
default:
|
||||||
return "○"
|
return "○"
|
||||||
@@ -45,8 +44,6 @@ function describe(status: McpServer["status"]) {
|
|||||||
switch (status.status) {
|
switch (status.status) {
|
||||||
case "needs_auth":
|
case "needs_auth":
|
||||||
return "needs authentication"
|
return "needs authentication"
|
||||||
case "needs_client_registration":
|
|
||||||
return `needs client registration: ${status.error}`
|
|
||||||
case "failed":
|
case "failed":
|
||||||
return `failed: ${status.error}`
|
return `failed: ${status.error}`
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
|
|||||||
const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini"))
|
const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini"))
|
||||||
yield* Effect.promise(async () => validateMiniTerminal())
|
yield* Effect.promise(async () => validateMiniTerminal())
|
||||||
const serverURL = Option.getOrUndefined(input.server)
|
const serverURL = Option.getOrUndefined(input.server)
|
||||||
const server = yield* ServerConnection.resolve({ server: serverURL, standalone: input.standalone })
|
const server = yield* ServerConnection.resolve({
|
||||||
|
server: serverURL,
|
||||||
|
standalone: input.standalone,
|
||||||
|
mismatch: "replace",
|
||||||
|
})
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const resolved = resolve(yield* config.get(), { terminalSuspend: process.platform !== "win32" })
|
const resolved = resolve(yield* config.get(), { terminalSuspend: process.platform !== "win32" })
|
||||||
const fileSystem = yield* FileSystem.FileSystem
|
const fileSystem = yield* FileSystem.FileSystem
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const Handlers = Runtime.handlers(Commands, {
|
|||||||
},
|
},
|
||||||
debug: {
|
debug: {
|
||||||
agents: () => import("./commands/handlers/debug/agents"),
|
agents: () => import("./commands/handlers/debug/agents"),
|
||||||
|
config: () => import("./commands/handlers/debug/config"),
|
||||||
},
|
},
|
||||||
console: {
|
console: {
|
||||||
login: () => import("./commands/handlers/console/login"),
|
login: () => import("./commands/handlers/console/login"),
|
||||||
@@ -36,6 +37,8 @@ const Handlers = Runtime.handlers(Commands, {
|
|||||||
list: () => import("./commands/handlers/plugin/list"),
|
list: () => import("./commands/handlers/plugin/list"),
|
||||||
},
|
},
|
||||||
models: () => import("./commands/handlers/models"),
|
models: () => import("./commands/handlers/models"),
|
||||||
|
export: () => import("./commands/handlers/export"),
|
||||||
|
import: () => import("./commands/handlers/import"),
|
||||||
mini: () => import("./commands/handlers/mini"),
|
mini: () => import("./commands/handlers/mini"),
|
||||||
run: () => import("./commands/handlers/run"),
|
run: () => import("./commands/handlers/run"),
|
||||||
pair: () => import("./commands/handlers/pair"),
|
pair: () => import("./commands/handlers/pair"),
|
||||||
|
|||||||
@@ -42,9 +42,10 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
|
|||||||
return { endpoint: yield* Standalone.start() } satisfies Resolved
|
return { endpoint: yield* Standalone.start() } satisfies Resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = yield* ServiceConfig.options()
|
const mismatch = args.mismatch ?? "ignore"
|
||||||
|
const options = yield* ServiceConfig.options({ checkVersion: mismatch !== "ignore" })
|
||||||
return {
|
return {
|
||||||
endpoint: yield* resolveManaged({ ...options, onStart: args.onStart }, args.mismatch ?? "replace"),
|
endpoint: yield* resolveManaged({ ...options, onStart: args.onStart }, mismatch),
|
||||||
service: managedService(options),
|
service: managedService(options),
|
||||||
} satisfies Resolved
|
} satisfies Resolved
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -98,12 +98,12 @@ const paths = Effect.gen(function* () {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export const options = Effect.fnUntraced(function* () {
|
export const options = Effect.fnUntraced(function* (input: { readonly checkVersion?: boolean } = {}) {
|
||||||
const { file, legacyRegistrationFiles } = yield* paths
|
const { file, legacyRegistrationFiles } = yield* paths
|
||||||
yield* Effect.forEach(legacyRegistrationFiles, (legacy) => migrateRegistration(legacy, file))
|
yield* Effect.forEach(legacyRegistrationFiles, (legacy) => migrateRegistration(legacy, file))
|
||||||
return {
|
return {
|
||||||
file,
|
file,
|
||||||
version: OPENCODE_VERSION,
|
version: input.checkVersion ? OPENCODE_VERSION : undefined,
|
||||||
command: [...selfCommand(), "serve", "--service"],
|
command: [...selfCommand(), "serve", "--service"],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import fs from "node:fs/promises"
|
||||||
|
import os from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
import { OPENCODE_VERSION } from "../src/version"
|
||||||
|
|
||||||
|
describe("debug config command", () => {
|
||||||
|
test("is included in troubleshooting help", async () => {
|
||||||
|
const [debug, config] = await Promise.all([cli(["debug", "--help"]), cli(["debug", "config", "--help"])])
|
||||||
|
|
||||||
|
expect(debug.exitCode).toBe(0)
|
||||||
|
expect(debug.stdout).toContain("config")
|
||||||
|
expect(debug.stdout).toContain("Show resolved configuration")
|
||||||
|
expect(config.exitCode).toBe(0)
|
||||||
|
expect(config.stdout).toContain("opencode debug config [flags]")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("prints config entries from the invoking directory without reordering permissions", async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-debug-config-"))
|
||||||
|
const project = path.join(import.meta.dir, "..")
|
||||||
|
const registration = path.join(root, "state", "opencode", "service-local.json")
|
||||||
|
const entries = [
|
||||||
|
{
|
||||||
|
type: "document",
|
||||||
|
path: path.join(project, "opencode.json"),
|
||||||
|
info: {
|
||||||
|
permissions: [
|
||||||
|
{ action: "shell", resource: "*", effect: "ask" },
|
||||||
|
{ action: "shell", resource: "git status", effect: "allow" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: "file", path: path.join(project, "opencode.json") },
|
||||||
|
]
|
||||||
|
let requested: URL | undefined
|
||||||
|
const authorization: Array<string | null> = []
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch(request) {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
if (url.pathname === "/api/health") {
|
||||||
|
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
|
||||||
|
}
|
||||||
|
requested = url
|
||||||
|
authorization.push(request.headers.get("authorization"))
|
||||||
|
return Response.json(entries)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.mkdir(path.dirname(registration), { recursive: true })
|
||||||
|
await fs.writeFile(
|
||||||
|
registration,
|
||||||
|
JSON.stringify({ version: OPENCODE_VERSION, url: server.url.toString(), pid: process.pid, password: "secret" }),
|
||||||
|
)
|
||||||
|
const result = await cli(["debug", "config"], project, { XDG_STATE_HOME: path.join(root, "state") })
|
||||||
|
|
||||||
|
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
|
||||||
|
expect(JSON.parse(result.stdout)).toEqual(entries)
|
||||||
|
expect(requested?.pathname).toBe("/api/config")
|
||||||
|
expect(requested?.searchParams.get("location[directory]")).toBe(project)
|
||||||
|
expect(authorization).toEqual([`Basic ${btoa("opencode:secret")}`])
|
||||||
|
} finally {
|
||||||
|
server.stop(true)
|
||||||
|
await fs.rm(root, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function cli(args: string[], cwd = path.join(import.meta.dir, ".."), env?: Record<string, string>) {
|
||||||
|
const child = Bun.spawn([process.execPath, "run", path.join(import.meta.dir, "../src/index.ts"), ...args], {
|
||||||
|
cwd,
|
||||||
|
env: { ...process.env, ...env },
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
})
|
||||||
|
const [stdout, stderr, exitCode] = await Promise.all([
|
||||||
|
new Response(child.stdout).text(),
|
||||||
|
new Response(child.stderr).text(),
|
||||||
|
child.exited,
|
||||||
|
])
|
||||||
|
return { stdout, stderr, exitCode }
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import fs from "node:fs/promises"
|
||||||
|
import os from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
import { OPENCODE_VERSION } from "../src/version"
|
||||||
|
import { writeExport } from "../src/commands/handlers/export"
|
||||||
|
|
||||||
|
const info = {
|
||||||
|
id: "ses_export_test",
|
||||||
|
projectID: "global",
|
||||||
|
cost: 0,
|
||||||
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
time: { created: 1, updated: 2 },
|
||||||
|
title: "Exported session",
|
||||||
|
location: { directory: "/project" },
|
||||||
|
}
|
||||||
|
const transfer = {
|
||||||
|
info,
|
||||||
|
messages: [
|
||||||
|
{ id: "msg_first", type: "user", text: "First", time: { created: 1 } },
|
||||||
|
{ id: "msg_second", type: "user", text: "Second", time: { created: 2 } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const sanitizedTransfer = {
|
||||||
|
info: {
|
||||||
|
...info,
|
||||||
|
title: "[redacted:session-title:ses_export_test]",
|
||||||
|
location: { directory: "/[redacted:session-directory:ses_export_test]" },
|
||||||
|
},
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: "msg_first",
|
||||||
|
type: "user",
|
||||||
|
text: "[redacted:text:msg_first]",
|
||||||
|
time: { created: 1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "msg_second",
|
||||||
|
type: "user",
|
||||||
|
text: "[redacted:text:msg_second]",
|
||||||
|
time: { created: 2 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const health = () => Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
|
||||||
|
|
||||||
|
function run(args: string[], stdin?: string) {
|
||||||
|
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
|
||||||
|
cwd: path.join(import.meta.dir, ".."),
|
||||||
|
stdin: stdin === undefined ? undefined : new Blob([stdin]),
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
})
|
||||||
|
return Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited])
|
||||||
|
}
|
||||||
|
|
||||||
|
test("export is raw by default and supports explicit sanitization", async () => {
|
||||||
|
const sanitization: string[] = []
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch(request) {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
if (url.pathname === "/api/health") return health()
|
||||||
|
if (url.pathname === `/api/session/${info.id}`) return Response.json({ data: info })
|
||||||
|
if (url.pathname === `/api/session/${info.id}/export`) {
|
||||||
|
sanitization.push(url.searchParams.get("sanitize") ?? "")
|
||||||
|
return Response.json({ data: url.searchParams.get("sanitize") === "true" ? sanitizedTransfer : transfer })
|
||||||
|
}
|
||||||
|
return new Response("Not found", { status: 404 })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [stdout, , exitCode] = await run(["export", "-s", info.id, "--server", server.url.toString()])
|
||||||
|
const exported = JSON.parse(stdout)
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0)
|
||||||
|
expect(exported).toEqual(transfer)
|
||||||
|
|
||||||
|
const [sanitized, , sanitizedExitCode] = await run([
|
||||||
|
"export",
|
||||||
|
"-s",
|
||||||
|
info.id,
|
||||||
|
"--sanitize",
|
||||||
|
"--server",
|
||||||
|
server.url.toString(),
|
||||||
|
])
|
||||||
|
expect(sanitizedExitCode).toBe(0)
|
||||||
|
expect(JSON.parse(sanitized)).toEqual(sanitizedTransfer)
|
||||||
|
expect(sanitization).toEqual(["false", "true"])
|
||||||
|
} finally {
|
||||||
|
await server.stop(true)
|
||||||
|
}
|
||||||
|
}, 15_000)
|
||||||
|
|
||||||
|
test("export reports an empty session list without a stack trace", async () => {
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch(request) {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
if (url.pathname === "/api/health") return health()
|
||||||
|
if (url.pathname === "/api/location") {
|
||||||
|
return Response.json({
|
||||||
|
directory: "/project",
|
||||||
|
project: { id: "global", directory: "/project", canonical: "/project" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/session") return Response.json({ data: [], cursor: {} })
|
||||||
|
return new Response("Not found", { status: 404 })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [stdout, stderr, exitCode] = await run(["export", "--server", server.url.toString()])
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0)
|
||||||
|
expect(stdout).toBe("")
|
||||||
|
expect(stderr).toBe(`No sessions found${os.EOL}`)
|
||||||
|
} finally {
|
||||||
|
await server.stop(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("interactive export writes a temporary JSON file", async () => {
|
||||||
|
const output = await writeExport(transfer, info.id, false)
|
||||||
|
const file = output.trim()
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(path.dirname(file)).toBe(os.tmpdir())
|
||||||
|
expect(await Bun.file(file).json()).toEqual(transfer)
|
||||||
|
} finally {
|
||||||
|
await fs.rm(file, { force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("import validates a file and sends it to the resolved location", async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-import-"))
|
||||||
|
const file = path.join(root, "session.json")
|
||||||
|
await fs.writeFile(file, JSON.stringify(transfer))
|
||||||
|
let imported: unknown
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
async fetch(request) {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
if (url.pathname === "/api/health") return health()
|
||||||
|
if (url.pathname === "/api/location") {
|
||||||
|
return Response.json({
|
||||||
|
directory: root,
|
||||||
|
project: { id: "global", directory: root, canonical: root },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/session/import") {
|
||||||
|
imported = await request.json()
|
||||||
|
return Response.json({ data: { ...info, location: { directory: root } } })
|
||||||
|
}
|
||||||
|
return new Response("Not found", { status: 404 })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [stdout, , exitCode] = await run([
|
||||||
|
"import",
|
||||||
|
file,
|
||||||
|
"--directory",
|
||||||
|
root,
|
||||||
|
"--server",
|
||||||
|
server.url.toString(),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0)
|
||||||
|
expect(stdout).toBe(`Imported session: ${info.id}${os.EOL}`)
|
||||||
|
expect(imported).toEqual({ ...transfer, location: { directory: root } })
|
||||||
|
} finally {
|
||||||
|
await server.stop(true)
|
||||||
|
await fs.rm(root, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("import reports an existing session without a stack trace", async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-import-conflict-"))
|
||||||
|
const file = path.join(root, "session.json")
|
||||||
|
await fs.writeFile(file, JSON.stringify(transfer))
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch(request) {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
if (url.pathname === "/api/health") return health()
|
||||||
|
if (url.pathname === "/api/location") {
|
||||||
|
return Response.json({
|
||||||
|
directory: root,
|
||||||
|
project: { id: "global", directory: root, canonical: root },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/session/import") return new Response("Conflict", { status: 409 })
|
||||||
|
return new Response("Not found", { status: 404 })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [stdout, stderr, exitCode] = await run(["import", file, "--server", server.url.toString()])
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0)
|
||||||
|
expect(stdout).toBe("")
|
||||||
|
expect(stderr).toBe(`Session already exists${os.EOL}`)
|
||||||
|
} finally {
|
||||||
|
await server.stop(true)
|
||||||
|
await fs.rm(root, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -55,3 +55,17 @@ test("resolution groups Effect-native lifecycle operations only for the managed
|
|||||||
await fs.rm(root, { recursive: true, force: true })
|
await fs.rm(root, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("service options only require a matching version when requested", async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-options-"))
|
||||||
|
const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })
|
||||||
|
const runPromise = <A, E>(effect: Effect.Effect<A, E, Global.Service | FileSystem.FileSystem | Scope.Scope>) =>
|
||||||
|
Effect.runPromise(effect.pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer), Effect.scoped))
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect((await runPromise(ServiceConfig.options())).version).toBeUndefined()
|
||||||
|
expect((await runPromise(ServiceConfig.options({ checkVersion: true }))).version).toBe(OPENCODE_VERSION)
|
||||||
|
} finally {
|
||||||
|
await fs.rm(root, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from "@opencode-ai/protocol/client"
|
} from "@opencode-ai/protocol/client"
|
||||||
import { Agent } from "@opencode-ai/schema/agent"
|
import { Agent } from "@opencode-ai/schema/agent"
|
||||||
import { Command } from "@opencode-ai/schema/command"
|
import { Command } from "@opencode-ai/schema/command"
|
||||||
|
import { Config } from "@opencode-ai/schema/config"
|
||||||
import { Credential } from "@opencode-ai/schema/credential"
|
import { Credential } from "@opencode-ai/schema/credential"
|
||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
import { EventLog } from "@opencode-ai/schema/event-log"
|
import { EventLog } from "@opencode-ai/schema/event-log"
|
||||||
@@ -48,6 +49,7 @@ const effectContract = compile(ClientApi, { groupNames, omitEndpoints: effectOmi
|
|||||||
const effectTypeReferences = [
|
const effectTypeReferences = [
|
||||||
...namespaceTypes("Agent", "@opencode-ai/schema/agent", Agent),
|
...namespaceTypes("Agent", "@opencode-ai/schema/agent", Agent),
|
||||||
...namespaceTypes("Command", "@opencode-ai/schema/command", Command),
|
...namespaceTypes("Command", "@opencode-ai/schema/command", Command),
|
||||||
|
...namespaceTypes("Config", "@opencode-ai/schema/config", Config),
|
||||||
...namespaceTypes("Credential", "@opencode-ai/schema/credential", Credential),
|
...namespaceTypes("Credential", "@opencode-ai/schema/credential", Credential),
|
||||||
...namespaceTypes("Event", "@opencode-ai/schema/event", Event),
|
...namespaceTypes("Event", "@opencode-ai/schema/event", Event),
|
||||||
...namespaceTypes("EventLog", "@opencode-ai/schema/event-log", EventLog),
|
...namespaceTypes("EventLog", "@opencode-ai/schema/event-log", EventLog),
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import type { ProjectCopy } from "@opencode-ai/schema/project-copy"
|
|||||||
import type { Vcs } from "@opencode-ai/schema/vcs"
|
import type { Vcs } from "@opencode-ai/schema/vcs"
|
||||||
import type { FileDiff } from "@opencode-ai/schema/file-diff"
|
import type { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||||
import type { WebSearch } from "@opencode-ai/schema/websearch"
|
import type { WebSearch } from "@opencode-ai/schema/websearch"
|
||||||
|
import type { Config } from "@opencode-ai/schema/config"
|
||||||
|
|
||||||
export type Endpoint0_0Output = { readonly healthy: true; readonly version: string; readonly pid: number }
|
export type Endpoint0_0Output = { readonly healthy: true; readonly version: string; readonly pid: number }
|
||||||
export type HealthGetOperation<E = never> = () => Effect.Effect<Endpoint0_0Output, E>
|
export type HealthGetOperation<E = never> = () => Effect.Effect<Endpoint0_0Output, E>
|
||||||
@@ -126,42 +127,54 @@ export type Endpoint5_1Input = {
|
|||||||
export type Endpoint5_1Output = Session.Info
|
export type Endpoint5_1Output = Session.Info
|
||||||
export type SessionCreateOperation<E = never> = (input?: Endpoint5_1Input) => Effect.Effect<Endpoint5_1Output, E>
|
export type SessionCreateOperation<E = never> = (input?: Endpoint5_1Input) => Effect.Effect<Endpoint5_1Output, E>
|
||||||
|
|
||||||
export type Endpoint5_2Output = { readonly [x: Session.ID]: { readonly type: "running" } }
|
export type Endpoint5_2Input = {
|
||||||
export type SessionActiveOperation<E = never> = () => Effect.Effect<Endpoint5_2Output, E>
|
readonly info: Session.Info
|
||||||
|
readonly messages: ReadonlyArray<SessionMessage.Info>
|
||||||
|
readonly location?: Location.Ref | undefined
|
||||||
|
}
|
||||||
|
export type Endpoint5_2Output = Session.Info
|
||||||
|
export type SessionImportOperation<E = never> = (input: Endpoint5_2Input) => Effect.Effect<Endpoint5_2Output, E>
|
||||||
|
|
||||||
export type Endpoint5_3Input = { readonly sessionID: Session.ID }
|
export type Endpoint5_3Input = { readonly sessionID: Session.ID; readonly sanitize?: boolean | undefined }
|
||||||
export type Endpoint5_3Output = Session.Info
|
export type Endpoint5_3Output = { readonly info: Session.Info; readonly messages: ReadonlyArray<SessionMessage.Info> }
|
||||||
export type SessionGetOperation<E = never> = (input: Endpoint5_3Input) => Effect.Effect<Endpoint5_3Output, E>
|
export type SessionExportOperation<E = never> = (input: Endpoint5_3Input) => Effect.Effect<Endpoint5_3Output, E>
|
||||||
|
|
||||||
export type Endpoint5_4Input = { readonly sessionID: Session.ID }
|
export type Endpoint5_4Output = { readonly [x: Session.ID]: { readonly type: "running" } }
|
||||||
export type Endpoint5_4Output = void
|
export type SessionActiveOperation<E = never> = () => Effect.Effect<Endpoint5_4Output, E>
|
||||||
export type SessionRemoveOperation<E = never> = (input: Endpoint5_4Input) => Effect.Effect<Endpoint5_4Output, E>
|
|
||||||
|
|
||||||
export type Endpoint5_5Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary }
|
export type Endpoint5_5Input = { readonly sessionID: Session.ID }
|
||||||
export type Endpoint5_5Output = Session.Info
|
export type Endpoint5_5Output = Session.Info
|
||||||
export type SessionForkOperation<E = never> = (input: Endpoint5_5Input) => Effect.Effect<Endpoint5_5Output, E>
|
export type SessionGetOperation<E = never> = (input: Endpoint5_5Input) => Effect.Effect<Endpoint5_5Output, E>
|
||||||
|
|
||||||
export type Endpoint5_6Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
|
export type Endpoint5_6Input = { readonly sessionID: Session.ID }
|
||||||
export type Endpoint5_6Output = void
|
export type Endpoint5_6Output = void
|
||||||
export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_6Input) => Effect.Effect<Endpoint5_6Output, E>
|
export type SessionRemoveOperation<E = never> = (input: Endpoint5_6Input) => Effect.Effect<Endpoint5_6Output, E>
|
||||||
|
|
||||||
export type Endpoint5_7Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
|
export type Endpoint5_7Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary }
|
||||||
export type Endpoint5_7Output = void
|
export type Endpoint5_7Output = Session.Info
|
||||||
export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_7Input) => Effect.Effect<Endpoint5_7Output, E>
|
export type SessionForkOperation<E = never> = (input: Endpoint5_7Input) => Effect.Effect<Endpoint5_7Output, E>
|
||||||
|
|
||||||
export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly title: string }
|
export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
|
||||||
export type Endpoint5_8Output = void
|
export type Endpoint5_8Output = void
|
||||||
export type SessionRenameOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
|
export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
|
||||||
|
|
||||||
export type Endpoint5_9Input = {
|
export type Endpoint5_9Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
|
||||||
|
export type Endpoint5_9Output = void
|
||||||
|
export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
|
||||||
|
|
||||||
|
export type Endpoint5_10Input = { readonly sessionID: Session.ID; readonly title: string }
|
||||||
|
export type Endpoint5_10Output = void
|
||||||
|
export type SessionRenameOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
|
||||||
|
|
||||||
|
export type Endpoint5_11Input = {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly directory: AbsolutePath
|
readonly directory: AbsolutePath
|
||||||
readonly workspaceID?: Workspace.ID | undefined
|
readonly workspaceID?: Workspace.ID | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint5_9Output = void
|
export type Endpoint5_11Output = void
|
||||||
export type SessionMoveOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
|
export type SessionMoveOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
|
||||||
|
|
||||||
export type Endpoint5_10Input = {
|
export type Endpoint5_12Input = {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly id?: SessionMessage.ID | undefined
|
readonly id?: SessionMessage.ID | undefined
|
||||||
readonly text: string
|
readonly text: string
|
||||||
@@ -171,10 +184,10 @@ export type Endpoint5_10Input = {
|
|||||||
readonly delivery?: "steer" | "queue" | undefined
|
readonly delivery?: "steer" | "queue" | undefined
|
||||||
readonly resume?: boolean | undefined
|
readonly resume?: boolean | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint5_10Output = SessionPending.User
|
export type Endpoint5_12Output = SessionPending.User
|
||||||
export type SessionPromptOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
|
export type SessionPromptOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
|
||||||
|
|
||||||
export type Endpoint5_11Input = {
|
export type Endpoint5_13Input = {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly id?: SessionMessage.ID | undefined
|
readonly id?: SessionMessage.ID | undefined
|
||||||
readonly command: string
|
readonly command: string
|
||||||
@@ -186,19 +199,19 @@ export type Endpoint5_11Input = {
|
|||||||
readonly delivery?: "steer" | "queue" | undefined
|
readonly delivery?: "steer" | "queue" | undefined
|
||||||
readonly resume?: boolean | undefined
|
readonly resume?: boolean | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint5_11Output = SessionPending.User
|
export type Endpoint5_13Output = SessionPending.User
|
||||||
export type SessionCommandOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
|
export type SessionCommandOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
|
||||||
|
|
||||||
export type Endpoint5_12Input = {
|
export type Endpoint5_14Input = {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly id?: SessionMessage.ID | undefined
|
readonly id?: SessionMessage.ID | undefined
|
||||||
readonly skill: Skill.ID
|
readonly skill: Skill.ID
|
||||||
readonly resume?: boolean | undefined
|
readonly resume?: boolean | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint5_12Output = void
|
export type Endpoint5_14Output = void
|
||||||
export type SessionSkillOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
|
export type SessionSkillOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
|
||||||
|
|
||||||
export type Endpoint5_13Input = {
|
export type Endpoint5_15Input = {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly id?: SessionMessage.ID | undefined
|
readonly id?: SessionMessage.ID | undefined
|
||||||
readonly text: string
|
readonly text: string
|
||||||
@@ -207,81 +220,81 @@ export type Endpoint5_13Input = {
|
|||||||
readonly delivery?: "steer" | "queue" | undefined
|
readonly delivery?: "steer" | "queue" | undefined
|
||||||
readonly resume?: boolean | undefined
|
readonly resume?: boolean | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint5_13Output = SessionPending.Synthetic
|
export type Endpoint5_15Output = SessionPending.Synthetic
|
||||||
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
|
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
|
||||||
|
|
||||||
export type Endpoint5_14Input = {
|
export type Endpoint5_16Input = {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly id?: Event.ID | undefined
|
readonly id?: Event.ID | undefined
|
||||||
readonly command: string
|
readonly command: string
|
||||||
}
|
}
|
||||||
export type Endpoint5_14Output = void
|
|
||||||
export type SessionShellOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
|
|
||||||
|
|
||||||
export type Endpoint5_15Input = { readonly sessionID: Session.ID; readonly id?: SessionMessage.ID | undefined }
|
|
||||||
export type Endpoint5_15Output = SessionPending.Compaction
|
|
||||||
export type SessionCompactOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
|
|
||||||
|
|
||||||
export type Endpoint5_16Input = { readonly sessionID: Session.ID }
|
|
||||||
export type Endpoint5_16Output = void
|
export type Endpoint5_16Output = void
|
||||||
export type SessionWaitOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
|
export type SessionShellOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
|
||||||
|
|
||||||
export type Endpoint5_17Input = {
|
export type Endpoint5_17Input = { readonly sessionID: Session.ID; readonly id?: SessionMessage.ID | undefined }
|
||||||
|
export type Endpoint5_17Output = SessionPending.Compaction
|
||||||
|
export type SessionCompactOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
|
||||||
|
|
||||||
|
export type Endpoint5_18Input = { readonly sessionID: Session.ID }
|
||||||
|
export type Endpoint5_18Output = void
|
||||||
|
export type SessionWaitOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
|
||||||
|
|
||||||
|
export type Endpoint5_19Input = {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly messageID: SessionMessage.ID
|
readonly messageID: SessionMessage.ID
|
||||||
readonly files?: boolean | undefined
|
readonly files?: boolean | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint5_17Output = Session.Revert
|
export type Endpoint5_19Output = Session.Revert
|
||||||
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
|
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
|
||||||
|
|
||||||
export type Endpoint5_18Input = { readonly sessionID: Session.ID }
|
|
||||||
export type Endpoint5_18Output = void
|
|
||||||
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
|
|
||||||
|
|
||||||
export type Endpoint5_19Input = { readonly sessionID: Session.ID }
|
|
||||||
export type Endpoint5_19Output = void
|
|
||||||
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
|
|
||||||
|
|
||||||
export type Endpoint5_20Input = { readonly sessionID: Session.ID }
|
export type Endpoint5_20Input = { readonly sessionID: Session.ID }
|
||||||
export type Endpoint5_20Output = ReadonlyArray<SessionMessage.Info>
|
export type Endpoint5_20Output = void
|
||||||
export type SessionContextOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
|
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
|
||||||
|
|
||||||
export type Endpoint5_21Input = { readonly sessionID: Session.ID }
|
export type Endpoint5_21Input = { readonly sessionID: Session.ID }
|
||||||
export type Endpoint5_21Output = ReadonlyArray<SessionPending.Info>
|
export type Endpoint5_21Output = void
|
||||||
export type SessionPendingListOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
|
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
|
||||||
|
|
||||||
export type Endpoint5_22Input = { readonly sessionID: Session.ID }
|
export type Endpoint5_22Input = { readonly sessionID: Session.ID }
|
||||||
export type Endpoint5_22Output = ReadonlyArray<InstructionEntry.Info>
|
export type Endpoint5_22Output = ReadonlyArray<SessionMessage.Info>
|
||||||
export type SessionInstructionsEntryListOperation<E = never> = (
|
export type SessionContextOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
|
||||||
input: Endpoint5_22Input,
|
|
||||||
) => Effect.Effect<Endpoint5_22Output, E>
|
|
||||||
|
|
||||||
export type Endpoint5_23Input = {
|
export type Endpoint5_23Input = { readonly sessionID: Session.ID }
|
||||||
|
export type Endpoint5_23Output = ReadonlyArray<SessionPending.Info>
|
||||||
|
export type SessionPendingListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
|
||||||
|
|
||||||
|
export type Endpoint5_24Input = { readonly sessionID: Session.ID }
|
||||||
|
export type Endpoint5_24Output = ReadonlyArray<InstructionEntry.Info>
|
||||||
|
export type SessionInstructionsEntryListOperation<E = never> = (
|
||||||
|
input: Endpoint5_24Input,
|
||||||
|
) => Effect.Effect<Endpoint5_24Output, E>
|
||||||
|
|
||||||
|
export type Endpoint5_25Input = {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly key: InstructionEntry.Key
|
readonly key: InstructionEntry.Key
|
||||||
readonly value: Schema.Json
|
readonly value: Schema.Json
|
||||||
}
|
}
|
||||||
export type Endpoint5_23Output = void
|
export type Endpoint5_25Output = void
|
||||||
export type SessionInstructionsEntryPutOperation<E = never> = (
|
export type SessionInstructionsEntryPutOperation<E = never> = (
|
||||||
input: Endpoint5_23Input,
|
input: Endpoint5_25Input,
|
||||||
) => Effect.Effect<Endpoint5_23Output, E>
|
) => Effect.Effect<Endpoint5_25Output, E>
|
||||||
|
|
||||||
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
|
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
|
||||||
export type Endpoint5_24Output = void
|
export type Endpoint5_26Output = void
|
||||||
export type SessionInstructionsEntryRemoveOperation<E = never> = (
|
export type SessionInstructionsEntryRemoveOperation<E = never> = (
|
||||||
input: Endpoint5_24Input,
|
input: Endpoint5_26Input,
|
||||||
) => Effect.Effect<Endpoint5_24Output, E>
|
) => Effect.Effect<Endpoint5_26Output, E>
|
||||||
|
|
||||||
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly prompt: string }
|
export type Endpoint5_27Input = { readonly sessionID: Session.ID; readonly prompt: string }
|
||||||
export type Endpoint5_25Output = { readonly text: string }
|
export type Endpoint5_27Output = { readonly text: string }
|
||||||
export type SessionGenerateOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
|
export type SessionGenerateOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
|
||||||
|
|
||||||
export type Endpoint5_26Input = {
|
export type Endpoint5_28Input = {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly after?: Event.Seq | undefined
|
readonly after?: Event.Seq | undefined
|
||||||
readonly follow?: boolean | undefined
|
readonly follow?: boolean | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint5_26Output =
|
export type Endpoint5_28Output =
|
||||||
| (
|
| (
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
@@ -849,23 +862,25 @@ export type Endpoint5_26Output =
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
| EventLog.Synced
|
| EventLog.Synced
|
||||||
export type SessionLogOperation<E = never> = (input: Endpoint5_26Input) => Stream.Stream<Endpoint5_26Output, E>
|
export type SessionLogOperation<E = never> = (input: Endpoint5_28Input) => Stream.Stream<Endpoint5_28Output, E>
|
||||||
|
|
||||||
export type Endpoint5_27Input = { readonly sessionID: Session.ID }
|
export type Endpoint5_29Input = { readonly sessionID: Session.ID }
|
||||||
export type Endpoint5_27Output = void
|
export type Endpoint5_29Output = void
|
||||||
export type SessionInterruptOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
|
export type SessionInterruptOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
|
||||||
|
|
||||||
export type Endpoint5_28Input = { readonly sessionID: Session.ID }
|
export type Endpoint5_30Input = { readonly sessionID: Session.ID }
|
||||||
export type Endpoint5_28Output = void
|
export type Endpoint5_30Output = void
|
||||||
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_28Input) => Effect.Effect<Endpoint5_28Output, E>
|
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
|
||||||
|
|
||||||
export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
|
export type Endpoint5_31Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
|
||||||
export type Endpoint5_29Output = SessionMessage.Info
|
export type Endpoint5_31Output = SessionMessage.Info
|
||||||
export type SessionMessageOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
|
export type SessionMessageOperation<E = never> = (input: Endpoint5_31Input) => Effect.Effect<Endpoint5_31Output, E>
|
||||||
|
|
||||||
export interface SessionApi<E = never> {
|
export interface SessionApi<E = never> {
|
||||||
readonly list: SessionListOperation<E>
|
readonly list: SessionListOperation<E>
|
||||||
readonly create: SessionCreateOperation<E>
|
readonly create: SessionCreateOperation<E>
|
||||||
|
readonly import: SessionImportOperation<E>
|
||||||
|
readonly export: SessionExportOperation<E>
|
||||||
readonly active: SessionActiveOperation<E>
|
readonly active: SessionActiveOperation<E>
|
||||||
readonly get: SessionGetOperation<E>
|
readonly get: SessionGetOperation<E>
|
||||||
readonly remove: SessionRemoveOperation<E>
|
readonly remove: SessionRemoveOperation<E>
|
||||||
@@ -1595,6 +1610,16 @@ export interface WebsearchApi<E = never> {
|
|||||||
readonly query: WebsearchQueryOperation<E>
|
readonly query: WebsearchQueryOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Endpoint29_0Input = {
|
||||||
|
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||||
|
}
|
||||||
|
export type Endpoint29_0Output = ReadonlyArray<Config.Entry>
|
||||||
|
export type ConfigGetOperation<E = never> = (input?: Endpoint29_0Input) => Effect.Effect<Endpoint29_0Output, E>
|
||||||
|
|
||||||
|
export interface ConfigApi<E = never> {
|
||||||
|
readonly get: ConfigGetOperation<E>
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppApi<E = never> {
|
export interface AppApi<E = never> {
|
||||||
readonly health: HealthApi<E>
|
readonly health: HealthApi<E>
|
||||||
readonly server: ServerApi<E>
|
readonly server: ServerApi<E>
|
||||||
@@ -1625,4 +1650,5 @@ export interface AppApi<E = never> {
|
|||||||
readonly debug: DebugApi<E>
|
readonly debug: DebugApi<E>
|
||||||
readonly migration: MigrationApi<E>
|
readonly migration: MigrationApi<E>
|
||||||
readonly websearch: WebsearchApi<E>
|
readonly websearch: WebsearchApi<E>
|
||||||
|
readonly config: ConfigApi<E>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ import type {
|
|||||||
Endpoint5_0Output,
|
Endpoint5_0Output,
|
||||||
Endpoint5_1Input,
|
Endpoint5_1Input,
|
||||||
Endpoint5_1Output,
|
Endpoint5_1Output,
|
||||||
|
Endpoint5_2Input,
|
||||||
Endpoint5_2Output,
|
Endpoint5_2Output,
|
||||||
Endpoint5_3Input,
|
Endpoint5_3Input,
|
||||||
Endpoint5_3Output,
|
Endpoint5_3Output,
|
||||||
Endpoint5_4Input,
|
|
||||||
Endpoint5_4Output,
|
Endpoint5_4Output,
|
||||||
Endpoint5_5Input,
|
Endpoint5_5Input,
|
||||||
Endpoint5_5Output,
|
Endpoint5_5Output,
|
||||||
@@ -76,6 +76,10 @@ import type {
|
|||||||
Endpoint5_28Output,
|
Endpoint5_28Output,
|
||||||
Endpoint5_29Input,
|
Endpoint5_29Input,
|
||||||
Endpoint5_29Output,
|
Endpoint5_29Output,
|
||||||
|
Endpoint5_30Input,
|
||||||
|
Endpoint5_30Output,
|
||||||
|
Endpoint5_31Input,
|
||||||
|
Endpoint5_31Output,
|
||||||
Endpoint6_0Input,
|
Endpoint6_0Input,
|
||||||
Endpoint6_0Output,
|
Endpoint6_0Output,
|
||||||
Endpoint7_0Input,
|
Endpoint7_0Input,
|
||||||
@@ -220,6 +224,8 @@ import type {
|
|||||||
Endpoint28_0Output,
|
Endpoint28_0Output,
|
||||||
Endpoint28_1Input,
|
Endpoint28_1Input,
|
||||||
Endpoint28_1Output,
|
Endpoint28_1Output,
|
||||||
|
Endpoint29_0Input,
|
||||||
|
Endpoint29_0Output,
|
||||||
} from "../api/api.js"
|
} from "../api/api.js"
|
||||||
import { ClientError } from "./client-error"
|
import { ClientError } from "./client-error"
|
||||||
|
|
||||||
@@ -315,9 +321,11 @@ const Endpoint5_1 = (raw: RawClient["server.session"]) => (input?: Endpoint5_1In
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_2 = (raw: RawClient["server.session"]) => () =>
|
const Endpoint5_2 = (raw: RawClient["server.session"]) => (input: Endpoint5_2Input) =>
|
||||||
preserveEffect<Endpoint5_2Output>()(
|
preserveEffect<Endpoint5_2Output>()(
|
||||||
raw["session.active"]({}).pipe(
|
raw["session.import"]({
|
||||||
|
payload: { info: input["info"], messages: input["messages"], location: input["location"] },
|
||||||
|
}).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
),
|
),
|
||||||
@@ -325,20 +333,23 @@ const Endpoint5_2 = (raw: RawClient["server.session"]) => () =>
|
|||||||
|
|
||||||
const Endpoint5_3 = (raw: RawClient["server.session"]) => (input: Endpoint5_3Input) =>
|
const Endpoint5_3 = (raw: RawClient["server.session"]) => (input: Endpoint5_3Input) =>
|
||||||
preserveEffect<Endpoint5_3Output>()(
|
preserveEffect<Endpoint5_3Output>()(
|
||||||
raw["session.get"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
raw["session.export"]({ params: { sessionID: input["sessionID"] }, query: { sanitize: input["sanitize"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_4 = (raw: RawClient["server.session"]) => (input: Endpoint5_4Input) =>
|
const Endpoint5_4 = (raw: RawClient["server.session"]) => () =>
|
||||||
preserveEffect<Endpoint5_4Output>()(
|
preserveEffect<Endpoint5_4Output>()(
|
||||||
raw["session.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
raw["session.active"]({}).pipe(
|
||||||
|
Effect.mapError(mapClientError),
|
||||||
|
Effect.map((value) => value.data),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Input) =>
|
const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Input) =>
|
||||||
preserveEffect<Endpoint5_5Output>()(
|
preserveEffect<Endpoint5_5Output>()(
|
||||||
raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { boundary: input["boundary"] } }).pipe(
|
raw["session.get"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
),
|
),
|
||||||
@@ -346,35 +357,48 @@ const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Inp
|
|||||||
|
|
||||||
const Endpoint5_6 = (raw: RawClient["server.session"]) => (input: Endpoint5_6Input) =>
|
const Endpoint5_6 = (raw: RawClient["server.session"]) => (input: Endpoint5_6Input) =>
|
||||||
preserveEffect<Endpoint5_6Output>()(
|
preserveEffect<Endpoint5_6Output>()(
|
||||||
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
|
raw["session.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
Effect.mapError(mapClientError),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_7 = (raw: RawClient["server.session"]) => (input: Endpoint5_7Input) =>
|
const Endpoint5_7 = (raw: RawClient["server.session"]) => (input: Endpoint5_7Input) =>
|
||||||
preserveEffect<Endpoint5_7Output>()(
|
preserveEffect<Endpoint5_7Output>()(
|
||||||
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
|
raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { boundary: input["boundary"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
|
Effect.map((value) => value.data),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Input) =>
|
const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Input) =>
|
||||||
preserveEffect<Endpoint5_8Output>()(
|
preserveEffect<Endpoint5_8Output>()(
|
||||||
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
|
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) =>
|
const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) =>
|
||||||
preserveEffect<Endpoint5_9Output>()(
|
preserveEffect<Endpoint5_9Output>()(
|
||||||
|
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
|
||||||
|
Effect.mapError(mapClientError),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
|
||||||
|
preserveEffect<Endpoint5_10Output>()(
|
||||||
|
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
|
||||||
|
Effect.mapError(mapClientError),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) =>
|
||||||
|
preserveEffect<Endpoint5_11Output>()(
|
||||||
raw["session.move"]({
|
raw["session.move"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
payload: { directory: input["directory"], workspaceID: input["workspaceID"] },
|
payload: { directory: input["directory"], workspaceID: input["workspaceID"] },
|
||||||
}).pipe(Effect.mapError(mapClientError)),
|
}).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
|
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
|
||||||
preserveEffect<Endpoint5_10Output>()(
|
preserveEffect<Endpoint5_12Output>()(
|
||||||
raw["session.prompt"]({
|
raw["session.prompt"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
payload: {
|
payload: {
|
||||||
@@ -392,8 +416,8 @@ const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10I
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) =>
|
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
|
||||||
preserveEffect<Endpoint5_11Output>()(
|
preserveEffect<Endpoint5_13Output>()(
|
||||||
raw["session.command"]({
|
raw["session.command"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
payload: {
|
payload: {
|
||||||
@@ -413,16 +437,16 @@ const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11I
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
|
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
|
||||||
preserveEffect<Endpoint5_12Output>()(
|
preserveEffect<Endpoint5_14Output>()(
|
||||||
raw["session.skill"]({
|
raw["session.skill"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
|
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
|
||||||
}).pipe(Effect.mapError(mapClientError)),
|
}).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
|
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
|
||||||
preserveEffect<Endpoint5_13Output>()(
|
preserveEffect<Endpoint5_15Output>()(
|
||||||
raw["session.synthetic"]({
|
raw["session.synthetic"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
payload: {
|
payload: {
|
||||||
@@ -439,29 +463,29 @@ const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13I
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
|
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
|
||||||
preserveEffect<Endpoint5_14Output>()(
|
preserveEffect<Endpoint5_16Output>()(
|
||||||
raw["session.shell"]({
|
raw["session.shell"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
payload: { id: input["id"], command: input["command"] },
|
payload: { id: input["id"], command: input["command"] },
|
||||||
}).pipe(Effect.mapError(mapClientError)),
|
}).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
|
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
|
||||||
preserveEffect<Endpoint5_15Output>()(
|
preserveEffect<Endpoint5_17Output>()(
|
||||||
raw["session.compact"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"] } }).pipe(
|
raw["session.compact"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
|
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
|
||||||
preserveEffect<Endpoint5_16Output>()(
|
preserveEffect<Endpoint5_18Output>()(
|
||||||
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
|
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
|
||||||
preserveEffect<Endpoint5_17Output>()(
|
preserveEffect<Endpoint5_19Output>()(
|
||||||
raw["session.revert.stage"]({
|
raw["session.revert.stage"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
payload: { messageID: input["messageID"], files: input["files"] },
|
payload: { messageID: input["messageID"], files: input["files"] },
|
||||||
@@ -471,35 +495,19 @@ const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17I
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
|
|
||||||
preserveEffect<Endpoint5_18Output>()(
|
|
||||||
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
|
||||||
)
|
|
||||||
|
|
||||||
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
|
|
||||||
preserveEffect<Endpoint5_19Output>()(
|
|
||||||
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
|
||||||
)
|
|
||||||
|
|
||||||
const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
|
const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
|
||||||
preserveEffect<Endpoint5_20Output>()(
|
preserveEffect<Endpoint5_20Output>()(
|
||||||
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
Effect.mapError(mapClientError),
|
|
||||||
Effect.map((value) => value.data),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) =>
|
const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) =>
|
||||||
preserveEffect<Endpoint5_21Output>()(
|
preserveEffect<Endpoint5_21Output>()(
|
||||||
raw["session.pending.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
Effect.mapError(mapClientError),
|
|
||||||
Effect.map((value) => value.data),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
|
const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
|
||||||
preserveEffect<Endpoint5_22Output>()(
|
preserveEffect<Endpoint5_22Output>()(
|
||||||
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
),
|
),
|
||||||
@@ -507,29 +515,45 @@ const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22I
|
|||||||
|
|
||||||
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
|
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
|
||||||
preserveEffect<Endpoint5_23Output>()(
|
preserveEffect<Endpoint5_23Output>()(
|
||||||
|
raw["session.pending.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||||
|
Effect.mapError(mapClientError),
|
||||||
|
Effect.map((value) => value.data),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
|
||||||
|
preserveEffect<Endpoint5_24Output>()(
|
||||||
|
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||||
|
Effect.mapError(mapClientError),
|
||||||
|
Effect.map((value) => value.data),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
|
||||||
|
preserveEffect<Endpoint5_25Output>()(
|
||||||
raw["session.instructions.entry.put"]({
|
raw["session.instructions.entry.put"]({
|
||||||
params: { sessionID: input["sessionID"], key: input["key"] },
|
params: { sessionID: input["sessionID"], key: input["key"] },
|
||||||
payload: { value: input["value"] },
|
payload: { value: input["value"] },
|
||||||
}).pipe(Effect.mapError(mapClientError)),
|
}).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
|
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
|
||||||
preserveEffect<Endpoint5_24Output>()(
|
preserveEffect<Endpoint5_26Output>()(
|
||||||
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
|
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
|
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
|
||||||
preserveEffect<Endpoint5_25Output>()(
|
preserveEffect<Endpoint5_27Output>()(
|
||||||
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
|
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
|
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
|
||||||
preserveStream<Endpoint5_26Output>()(
|
preserveStream<Endpoint5_28Output>()(
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
raw["session.log"]({
|
raw["session.log"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
@@ -541,18 +565,18 @@ const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26I
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
|
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
|
||||||
preserveEffect<Endpoint5_27Output>()(
|
preserveEffect<Endpoint5_29Output>()(
|
||||||
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
|
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
|
||||||
preserveEffect<Endpoint5_28Output>()(
|
preserveEffect<Endpoint5_30Output>()(
|
||||||
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
|
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
|
||||||
preserveEffect<Endpoint5_29Output>()(
|
preserveEffect<Endpoint5_31Output>()(
|
||||||
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
@@ -562,30 +586,32 @@ const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29I
|
|||||||
const adaptGroup5 = (raw: RawClient["server.session"]) => ({
|
const adaptGroup5 = (raw: RawClient["server.session"]) => ({
|
||||||
list: Endpoint5_0(raw),
|
list: Endpoint5_0(raw),
|
||||||
create: Endpoint5_1(raw),
|
create: Endpoint5_1(raw),
|
||||||
active: Endpoint5_2(raw),
|
import: Endpoint5_2(raw),
|
||||||
get: Endpoint5_3(raw),
|
export: Endpoint5_3(raw),
|
||||||
remove: Endpoint5_4(raw),
|
active: Endpoint5_4(raw),
|
||||||
fork: Endpoint5_5(raw),
|
get: Endpoint5_5(raw),
|
||||||
switchAgent: Endpoint5_6(raw),
|
remove: Endpoint5_6(raw),
|
||||||
switchModel: Endpoint5_7(raw),
|
fork: Endpoint5_7(raw),
|
||||||
rename: Endpoint5_8(raw),
|
switchAgent: Endpoint5_8(raw),
|
||||||
move: Endpoint5_9(raw),
|
switchModel: Endpoint5_9(raw),
|
||||||
prompt: Endpoint5_10(raw),
|
rename: Endpoint5_10(raw),
|
||||||
command: Endpoint5_11(raw),
|
move: Endpoint5_11(raw),
|
||||||
skill: Endpoint5_12(raw),
|
prompt: Endpoint5_12(raw),
|
||||||
synthetic: Endpoint5_13(raw),
|
command: Endpoint5_13(raw),
|
||||||
shell: Endpoint5_14(raw),
|
skill: Endpoint5_14(raw),
|
||||||
compact: Endpoint5_15(raw),
|
synthetic: Endpoint5_15(raw),
|
||||||
wait: Endpoint5_16(raw),
|
shell: Endpoint5_16(raw),
|
||||||
revert: { stage: Endpoint5_17(raw), clear: Endpoint5_18(raw), commit: Endpoint5_19(raw) },
|
compact: Endpoint5_17(raw),
|
||||||
context: Endpoint5_20(raw),
|
wait: Endpoint5_18(raw),
|
||||||
pending: { list: Endpoint5_21(raw) },
|
revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) },
|
||||||
instructions: { entry: { list: Endpoint5_22(raw), put: Endpoint5_23(raw), remove: Endpoint5_24(raw) } },
|
context: Endpoint5_22(raw),
|
||||||
generate: Endpoint5_25(raw),
|
pending: { list: Endpoint5_23(raw) },
|
||||||
log: Endpoint5_26(raw),
|
instructions: { entry: { list: Endpoint5_24(raw), put: Endpoint5_25(raw), remove: Endpoint5_26(raw) } },
|
||||||
interrupt: Endpoint5_27(raw),
|
generate: Endpoint5_27(raw),
|
||||||
background: Endpoint5_28(raw),
|
log: Endpoint5_28(raw),
|
||||||
message: Endpoint5_29(raw),
|
interrupt: Endpoint5_29(raw),
|
||||||
|
background: Endpoint5_30(raw),
|
||||||
|
message: Endpoint5_31(raw),
|
||||||
})
|
})
|
||||||
|
|
||||||
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
|
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
|
||||||
@@ -1241,6 +1267,13 @@ const adaptGroup28 = (raw: RawClient["server.websearch"]) => ({
|
|||||||
query: Endpoint28_1(raw),
|
query: Endpoint28_1(raw),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const Endpoint29_0 = (raw: RawClient["server.config"]) => (input?: Endpoint29_0Input) =>
|
||||||
|
preserveEffect<Endpoint29_0Output>()(
|
||||||
|
raw["config.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const adaptGroup29 = (raw: RawClient["server.config"]) => ({ get: Endpoint29_0(raw) })
|
||||||
|
|
||||||
const adaptClient = (raw: RawClient) => ({
|
const adaptClient = (raw: RawClient) => ({
|
||||||
health: adaptGroup0(raw["server.health"]),
|
health: adaptGroup0(raw["server.health"]),
|
||||||
server: adaptGroup1(raw["server.server"]),
|
server: adaptGroup1(raw["server.server"]),
|
||||||
@@ -1271,6 +1304,7 @@ const adaptClient = (raw: RawClient) => ({
|
|||||||
debug: adaptGroup26(raw["server.debug"]),
|
debug: adaptGroup26(raw["server.debug"]),
|
||||||
migration: adaptGroup27(raw["server.migration"]),
|
migration: adaptGroup27(raw["server.migration"]),
|
||||||
websearch: adaptGroup28(raw["server.websearch"]),
|
websearch: adaptGroup28(raw["server.websearch"]),
|
||||||
|
config: adaptGroup29(raw["server.config"]),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type {
|
|||||||
AppApi,
|
AppApi,
|
||||||
CatalogApi,
|
CatalogApi,
|
||||||
CommandApi,
|
CommandApi,
|
||||||
|
ConfigApi,
|
||||||
EventApi,
|
EventApi,
|
||||||
IntegrationApi,
|
IntegrationApi,
|
||||||
ModelApi,
|
ModelApi,
|
||||||
@@ -20,6 +21,7 @@ export type {
|
|||||||
} from "./api.js"
|
} from "./api.js"
|
||||||
export { Agent } from "@opencode-ai/schema/agent"
|
export { Agent } from "@opencode-ai/schema/agent"
|
||||||
export { Command } from "@opencode-ai/schema/command"
|
export { Command } from "@opencode-ai/schema/command"
|
||||||
|
export { Config } from "@opencode-ai/schema/config"
|
||||||
export { Credential } from "@opencode-ai/schema/credential"
|
export { Credential } from "@opencode-ai/schema/credential"
|
||||||
export { Event } from "@opencode-ai/schema/event"
|
export { Event } from "@opencode-ai/schema/event"
|
||||||
export { EventLog } from "@opencode-ai/schema/event-log"
|
export { EventLog } from "@opencode-ai/schema/event-log"
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
|
|||||||
/** Ensure a healthy, compatible local service is running. */
|
/** Ensure a healthy, compatible local service is running. */
|
||||||
export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOptions = {}) {
|
export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOptions = {}) {
|
||||||
const contenders = new Set<Contender>()
|
const contenders = new Set<Contender>()
|
||||||
|
let timeouts: { readonly info: Info; readonly count: number } | undefined
|
||||||
let announced = false
|
let announced = false
|
||||||
let lastSpawn = 0
|
let lastSpawn = 0
|
||||||
let spawnDelay = 5_000
|
let spawnDelay = 5_000
|
||||||
@@ -82,6 +83,18 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
|||||||
const registration = yield* registered(options.file, true)
|
const registration = yield* registered(options.file, true)
|
||||||
const info = registration.info
|
const info = registration.info
|
||||||
const service = registration.service
|
const service = registration.service
|
||||||
|
if (registration.timedOut && info !== undefined) {
|
||||||
|
timeouts = {
|
||||||
|
info,
|
||||||
|
count: timeouts !== undefined && same(timeouts.info, info) ? timeouts.count + 1 : 1,
|
||||||
|
}
|
||||||
|
if (timeouts.count >= 3) {
|
||||||
|
yield* announce("missing")
|
||||||
|
yield* evict(info, options)
|
||||||
|
timeouts = undefined
|
||||||
|
lastSpawn = Date.now() - spawnDelay
|
||||||
|
}
|
||||||
|
} else timeouts = undefined
|
||||||
if (service !== undefined) {
|
if (service !== undefined) {
|
||||||
spawnDelay = 5_000
|
spawnDelay = 5_000
|
||||||
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
|
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
|
||||||
@@ -182,6 +195,10 @@ type LocalService = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const probe = Effect.fnUntraced(function* (info: Info, allowLegacy = false) {
|
const probe = Effect.fnUntraced(function* (info: Info, allowLegacy = false) {
|
||||||
|
return (yield* probeResult(info, allowLegacy)).service
|
||||||
|
})
|
||||||
|
|
||||||
|
const probeResult = Effect.fnUntraced(function* (info: Info, allowLegacy = false) {
|
||||||
const endpoint = {
|
const endpoint = {
|
||||||
url: info.url,
|
url: info.url,
|
||||||
auth:
|
auth:
|
||||||
@@ -189,39 +206,53 @@ const probe = Effect.fnUntraced(function* (info: Info, allowLegacy = false) {
|
|||||||
? undefined
|
? undefined
|
||||||
: { type: "basic" as const, username: "opencode", password: info.password },
|
: { type: "basic" as const, username: "opencode", password: info.password },
|
||||||
} satisfies Endpoint
|
} satisfies Endpoint
|
||||||
const response = yield* Effect.tryPromise(() =>
|
const signal = AbortSignal.timeout(2_000)
|
||||||
|
const result = yield* Effect.promise(() =>
|
||||||
fetch(new URL("/api/health", info.url), {
|
fetch(new URL("/api/health", info.url), {
|
||||||
headers: headers(endpoint),
|
headers: headers(endpoint),
|
||||||
signal: AbortSignal.timeout(2_000),
|
signal,
|
||||||
}),
|
})
|
||||||
).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
.then(async (response) => ({ response, body: (await response.json()) as unknown }))
|
||||||
if (response === undefined) return undefined
|
.then(
|
||||||
const body = yield* Effect.tryPromise(() => response.json()).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
(value) => ({ value }),
|
||||||
|
(cause: unknown) => ({ cause }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
|
||||||
|
const response = result.value.response
|
||||||
|
const body = result.value.body
|
||||||
const health = decodeHealth(body)
|
const health = decodeHealth(body)
|
||||||
if (Option.isSome(health)) {
|
if (Option.isSome(health)) {
|
||||||
if (health.value.pid !== info.pid) return undefined
|
if (health.value.pid !== info.pid) return { service: undefined, timedOut: false }
|
||||||
if (info.version !== undefined && health.value.version !== info.version) return undefined
|
if (info.version !== undefined && health.value.version !== info.version)
|
||||||
|
return { service: undefined, timedOut: false }
|
||||||
return {
|
return {
|
||||||
|
service: {
|
||||||
info,
|
info,
|
||||||
endpoint,
|
endpoint,
|
||||||
version: health.value.version,
|
version: health.value.version,
|
||||||
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
|
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
|
||||||
legacy: false,
|
legacy: false,
|
||||||
} satisfies LocalService
|
} satisfies LocalService,
|
||||||
|
timedOut: false,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!allowLegacy ||
|
!allowLegacy ||
|
||||||
Option.isNone(decodeLegacyHealth(body)) ||
|
Option.isNone(decodeLegacyHealth(body)) ||
|
||||||
(typeof body === "object" && body !== null && ("version" in body || "pid" in body))
|
(typeof body === "object" && body !== null && ("version" in body || "pid" in body))
|
||||||
)
|
)
|
||||||
return undefined
|
return { service: undefined, timedOut: false }
|
||||||
return { info, endpoint, state: "ready", legacy: true } satisfies LocalService
|
return {
|
||||||
|
service: { info, endpoint, state: "ready", legacy: true } satisfies LocalService,
|
||||||
|
timedOut: false,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const registered = Effect.fnUntraced(function* (file?: string, allowLegacy = false) {
|
const registered = Effect.fnUntraced(function* (file?: string, allowLegacy = false) {
|
||||||
const info = yield* read(file)
|
const info = yield* read(file)
|
||||||
if (info === undefined) return { info: undefined, service: undefined }
|
if (info === undefined) return { info: undefined, service: undefined, timedOut: false }
|
||||||
return { info, service: yield* probe(info, allowLegacy) }
|
return { info, ...(yield* probeResult(info, allowLegacy)) }
|
||||||
})
|
})
|
||||||
|
|
||||||
// Health-checked lookup without the version gate: lifecycle operations must be
|
// Health-checked lookup without the version gate: lifecycle operations must be
|
||||||
@@ -249,6 +280,19 @@ function same(left: Info, right: Info) {
|
|||||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const evict = Effect.fnUntraced(function* (info: Info, options: { readonly file?: string }) {
|
||||||
|
const current = yield* read(options.file)
|
||||||
|
if (current === undefined || !same(current, info)) return
|
||||||
|
yield* signal(info.pid, "SIGTERM")
|
||||||
|
const done = yield* stopped(info.pid).pipe(Effect.retry(poll), Effect.option)
|
||||||
|
if (Option.isSome(done)) return
|
||||||
|
|
||||||
|
const latest = yield* read(options.file)
|
||||||
|
if (latest === undefined || !same(latest, info)) return
|
||||||
|
yield* signal(info.pid, "SIGKILL")
|
||||||
|
yield* stopped(info.pid).pipe(Effect.retry(poll))
|
||||||
|
})
|
||||||
|
|
||||||
const kill = Effect.fnUntraced(function* (service: LocalService, options: { readonly file?: string }) {
|
const kill = Effect.fnUntraced(function* (service: LocalService, options: { readonly file?: string }) {
|
||||||
const requested = yield* requestStop(service)
|
const requested = yield* requestStop(service)
|
||||||
if (requested === "rejected") return
|
if (requested === "rejected") return
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ type Client = ReturnType<typeof import("./generated/client.js").make>
|
|||||||
|
|
||||||
export type AgentApi = Client["agent"]
|
export type AgentApi = Client["agent"]
|
||||||
export type CommandApi = Client["command"]
|
export type CommandApi = Client["command"]
|
||||||
|
export type ConfigApi = Client["config"]
|
||||||
export type EventApi = Client["event"]
|
export type EventApi = Client["event"]
|
||||||
export type IntegrationApi = Client["integration"]
|
export type IntegrationApi = Client["integration"]
|
||||||
export type ModelApi = Client["model"]
|
export type ModelApi = Client["model"]
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import type {
|
|||||||
SessionListOutput,
|
SessionListOutput,
|
||||||
SessionCreateInput,
|
SessionCreateInput,
|
||||||
SessionCreateOutput,
|
SessionCreateOutput,
|
||||||
|
SessionImportInput,
|
||||||
|
SessionImportOutput,
|
||||||
|
SessionExportInput,
|
||||||
|
SessionExportOutput,
|
||||||
SessionActiveOutput,
|
SessionActiveOutput,
|
||||||
SessionGetInput,
|
SessionGetInput,
|
||||||
SessionGetOutput,
|
SessionGetOutput,
|
||||||
@@ -216,6 +220,8 @@ import type {
|
|||||||
WebsearchProvidersOutput,
|
WebsearchProvidersOutput,
|
||||||
WebsearchQueryInput,
|
WebsearchQueryInput,
|
||||||
WebsearchQueryOutput,
|
WebsearchQueryOutput,
|
||||||
|
ConfigGetInput,
|
||||||
|
ConfigGetOutput,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
import { ClientError } from "./client-error"
|
import { ClientError } from "./client-error"
|
||||||
|
|
||||||
@@ -476,6 +482,30 @@ export function make(options: ClientOptions) {
|
|||||||
},
|
},
|
||||||
requestOptions,
|
requestOptions,
|
||||||
).then((value) => value.data),
|
).then((value) => value.data),
|
||||||
|
import: (input: SessionImportInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<{ readonly data: SessionImportOutput }>(
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: `/api/session/import`,
|
||||||
|
body: { info: input["info"], messages: input["messages"], location: input["location"] },
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [409, 401, 400],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
).then((value) => value.data),
|
||||||
|
export: (input: SessionExportInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<{ readonly data: SessionExportOutput }>(
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/export`,
|
||||||
|
query: { sanitize: input["sanitize"] },
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [404, 500, 401, 400],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
).then((value) => value.data),
|
||||||
active: (requestOptions?: RequestOptions) =>
|
active: (requestOptions?: RequestOptions) =>
|
||||||
request<{ readonly data: SessionActiveOutput }>(
|
request<{ readonly data: SessionActiveOutput }>(
|
||||||
{
|
{
|
||||||
@@ -1811,6 +1841,20 @@ export function make(options: ClientOptions) {
|
|||||||
requestOptions,
|
requestOptions,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
config: {
|
||||||
|
get: (input?: ConfigGetInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<ConfigGetOutput>(
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/config`,
|
||||||
|
query: { location: input?.["location"] },
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [401, 400],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ export type {
|
|||||||
AgentApi,
|
AgentApi,
|
||||||
CatalogApi,
|
CatalogApi,
|
||||||
CommandApi,
|
CommandApi,
|
||||||
|
ConfigApi,
|
||||||
EventApi,
|
EventApi,
|
||||||
IntegrationApi,
|
IntegrationApi,
|
||||||
ModelApi,
|
ModelApi,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ async function discoverLocal(options: DiscoverOptions) {
|
|||||||
export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||||
const deadline = Date.now() + 120_000
|
const deadline = Date.now() + 120_000
|
||||||
const contenders = new Set<Contender>()
|
const contenders = new Set<Contender>()
|
||||||
|
let timeouts: { readonly info: Info; readonly count: number } | undefined
|
||||||
let announced = false
|
let announced = false
|
||||||
let lastSpawn = 0
|
let lastSpawn = 0
|
||||||
let spawnDelay = 5_000
|
let spawnDelay = 5_000
|
||||||
@@ -62,6 +63,19 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
|||||||
while (true) {
|
while (true) {
|
||||||
if (Date.now() >= deadline) throw new Error("Timed out waiting for the background service to start")
|
if (Date.now() >= deadline) throw new Error("Timed out waiting for the background service to start")
|
||||||
const registration = await registered(options.file, true)
|
const registration = await registered(options.file, true)
|
||||||
|
if (registration.timedOut && registration.info !== undefined) {
|
||||||
|
timeouts = {
|
||||||
|
info: registration.info,
|
||||||
|
count:
|
||||||
|
timeouts !== undefined && same(timeouts.info, registration.info) ? timeouts.count + 1 : 1,
|
||||||
|
}
|
||||||
|
if (timeouts.count >= 3) {
|
||||||
|
announce("missing")
|
||||||
|
await evict(registration.info, options)
|
||||||
|
timeouts = undefined
|
||||||
|
lastSpawn = Date.now() - spawnDelay
|
||||||
|
}
|
||||||
|
} else timeouts = undefined
|
||||||
|
|
||||||
if (registration.service !== undefined) {
|
if (registration.service !== undefined) {
|
||||||
spawnDelay = 5_000
|
spawnDelay = 5_000
|
||||||
@@ -145,6 +159,10 @@ type LocalService = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function probe(info: Info, allowLegacy = false): Promise<LocalService | undefined> {
|
async function probe(info: Info, allowLegacy = false): Promise<LocalService | undefined> {
|
||||||
|
return (await probeResult(info, allowLegacy)).service
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeResult(info: Info, allowLegacy = false) {
|
||||||
const endpoint = {
|
const endpoint = {
|
||||||
url: info.url,
|
url: info.url,
|
||||||
auth:
|
auth:
|
||||||
@@ -152,30 +170,48 @@ async function probe(info: Info, allowLegacy = false): Promise<LocalService | un
|
|||||||
? undefined
|
? undefined
|
||||||
: { type: "basic" as const, username: "opencode", password: info.password },
|
: { type: "basic" as const, username: "opencode", password: info.password },
|
||||||
} satisfies Endpoint
|
} satisfies Endpoint
|
||||||
const response = await fetch(new URL("/api/health", info.url), {
|
const signal = AbortSignal.timeout(2_000)
|
||||||
|
const result = await fetch(new URL("/api/health", info.url), {
|
||||||
headers: headers(endpoint),
|
headers: headers(endpoint),
|
||||||
signal: AbortSignal.timeout(2_000),
|
signal,
|
||||||
}).catch(() => undefined)
|
})
|
||||||
const body = (await response?.json().catch(() => undefined)) as ServiceHealth | { readonly healthy: true } | undefined
|
.then(async (response) => ({
|
||||||
|
response,
|
||||||
|
body: (await response.json()) as ServiceHealth | { readonly healthy: true },
|
||||||
|
}))
|
||||||
|
.then(
|
||||||
|
(value) => ({ value }),
|
||||||
|
(cause: unknown) => ({ cause }),
|
||||||
|
)
|
||||||
|
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
|
||||||
|
const response = result.value.response
|
||||||
|
const body = result.value.body
|
||||||
if (body !== undefined && "version" in body && "pid" in body) {
|
if (body !== undefined && "version" in body && "pid" in body) {
|
||||||
if (body.pid !== info.pid) return undefined
|
if (body.pid !== info.pid) return { service: undefined, timedOut: false }
|
||||||
if (info.version !== undefined && body.version !== info.version) return undefined
|
if (info.version !== undefined && body.version !== info.version)
|
||||||
|
return { service: undefined, timedOut: false }
|
||||||
return {
|
return {
|
||||||
|
service: {
|
||||||
info,
|
info,
|
||||||
endpoint,
|
endpoint,
|
||||||
version: body.version,
|
version: body.version,
|
||||||
state: response?.ok ? "ready" : response?.status === 500 ? "failed" : "waiting",
|
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
|
||||||
legacy: false,
|
legacy: false,
|
||||||
|
} satisfies LocalService,
|
||||||
|
timedOut: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!allowLegacy || body?.healthy !== true) return undefined
|
if (!allowLegacy || body?.healthy !== true) return { service: undefined, timedOut: false }
|
||||||
return { info, endpoint, state: "ready", legacy: true }
|
return {
|
||||||
|
service: { info, endpoint, state: "ready", legacy: true } satisfies LocalService,
|
||||||
|
timedOut: false,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function registered(file?: string, allowLegacy = false) {
|
async function registered(file?: string, allowLegacy = false) {
|
||||||
const info = await read(file)
|
const info = await read(file)
|
||||||
if (info === undefined) return { info: undefined, service: undefined }
|
if (info === undefined) return { info: undefined, service: undefined, timedOut: false }
|
||||||
return { info, service: await probe(info, allowLegacy) }
|
return { info, ...(await probeResult(info, allowLegacy)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function find(options: { readonly file?: string }) {
|
async function find(options: { readonly file?: string }) {
|
||||||
@@ -209,6 +245,18 @@ function same(left: Info, right: Info) {
|
|||||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function evict(info: Info, options: { readonly file?: string }) {
|
||||||
|
const current = await read(options.file)
|
||||||
|
if (current === undefined || !same(current, info)) return
|
||||||
|
signal(info.pid, "SIGTERM")
|
||||||
|
if (await waitUntilStopped(info.pid)) return
|
||||||
|
|
||||||
|
const latest = await read(options.file)
|
||||||
|
if (latest === undefined || !same(latest, info)) return
|
||||||
|
signal(info.pid, "SIGKILL")
|
||||||
|
if (!(await waitUntilStopped(info.pid))) throw new Error(`Server process ${info.pid} is still running`)
|
||||||
|
}
|
||||||
|
|
||||||
async function kill(service: LocalService, options: { readonly file?: string }) {
|
async function kill(service: LocalService, options: { readonly file?: string }) {
|
||||||
const requested = await requestStop(service)
|
const requested = await requestStop(service)
|
||||||
if (requested === "rejected") return
|
if (requested === "rejected") return
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { Agent } from "@opencode-ai/schema/agent"
|
import { Agent } from "@opencode-ai/schema/agent"
|
||||||
|
import { Config } from "@opencode-ai/schema/config"
|
||||||
import { Model } from "@opencode-ai/schema/model"
|
import { Model } from "@opencode-ai/schema/model"
|
||||||
import { Prompt } from "@opencode-ai/schema/prompt"
|
import { Prompt } from "@opencode-ai/schema/prompt"
|
||||||
import { Session } from "@opencode-ai/schema/session"
|
import { Session } from "@opencode-ai/schema/session"
|
||||||
@@ -10,6 +11,7 @@ const Client = await import("../src/effect")
|
|||||||
|
|
||||||
test("effect entrypoint exposes canonical Schema contracts", () => {
|
test("effect entrypoint exposes canonical Schema contracts", () => {
|
||||||
expect(Client.Agent).toBe(Agent)
|
expect(Client.Agent).toBe(Agent)
|
||||||
|
expect(Client.Config).toBe(Config)
|
||||||
expect(Client.Model).toBe(Model)
|
expect(Client.Model).toBe(Model)
|
||||||
expect(Client.Session).toBe(Session)
|
expect(Client.Session).toBe(Session)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ const server = Bun.serve({
|
|||||||
}
|
}
|
||||||
if (pathname !== "/api/health") return new Response(null, { status: 404 })
|
if (pathname !== "/api/health") return new Response(null, { status: 404 })
|
||||||
requests += 1
|
requests += 1
|
||||||
|
if (mode === "hanging") {
|
||||||
|
await appendFile(registration + ".requests", process.pid + "\n")
|
||||||
|
return new Promise<Response>(() => {})
|
||||||
|
}
|
||||||
if (mode === "modern" && requests === 1) {
|
if (mode === "modern" && requests === 1) {
|
||||||
await writeFile(registration + ".first-request", "")
|
await writeFile(registration + ".first-request", "")
|
||||||
while (!(await Bun.file(registration + ".release").exists())) await Bun.sleep(5)
|
while (!(await Bun.file(registration + ".release").exists())) await Bun.sleep(5)
|
||||||
|
|||||||
@@ -70,6 +70,32 @@ test("reports a failed registered service", async () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("evicts an unresponsive registered service before starting its replacement", async () => {
|
||||||
|
const directory = await temp()
|
||||||
|
const registration = join(directory, "service.json")
|
||||||
|
const existing = Bun.spawn([process.execPath, fixture, registration, "hanging"], {
|
||||||
|
stdout: "ignore",
|
||||||
|
stderr: "inherit",
|
||||||
|
})
|
||||||
|
processes.push(existing)
|
||||||
|
await waitForFile(registration)
|
||||||
|
const original = await Bun.file(registration).json()
|
||||||
|
|
||||||
|
const endpoint = await Service.ensure({
|
||||||
|
file: registration,
|
||||||
|
version: "test",
|
||||||
|
command: [process.execPath, fixture, registration, "delayed", "10"],
|
||||||
|
})
|
||||||
|
const replacement = await Bun.file(registration).json()
|
||||||
|
|
||||||
|
expect((await Bun.file(registration + ".requests").text()).trim().split("\n")).toHaveLength(3)
|
||||||
|
expect(await existing.exited).toBe(0)
|
||||||
|
expect(replacement.pid).not.toBe(original.pid)
|
||||||
|
expect(endpoint.url).toBe(replacement.url)
|
||||||
|
process.kill(replacement.pid, "SIGTERM")
|
||||||
|
await waitForExit(replacement.pid)
|
||||||
|
}, 20_000)
|
||||||
|
|
||||||
test("requests graceful stop of the exact service instance", async () => {
|
test("requests graceful stop of the exact service instance", async () => {
|
||||||
const registration = await setup("graceful")
|
const registration = await setup("graceful")
|
||||||
const info = await Bun.file(registration).json()
|
const info = await Bun.file(registration).json()
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ test("exposes every standard HTTP API group", () => {
|
|||||||
"vcs",
|
"vcs",
|
||||||
"debug",
|
"debug",
|
||||||
"websearch",
|
"websearch",
|
||||||
|
"config",
|
||||||
])
|
])
|
||||||
expect(Object.keys(client.debug)).toEqual(["location"])
|
expect(Object.keys(client.debug)).toEqual(["location"])
|
||||||
expect(Object.keys(client.debug.location)).toEqual(["list", "evict"])
|
expect(Object.keys(client.debug.location)).toEqual(["list", "evict"])
|
||||||
@@ -50,6 +51,34 @@ test("exposes every standard HTTP API group", () => {
|
|||||||
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
|
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("config.get returns ordered config entries for a location", async () => {
|
||||||
|
let request: Request | undefined
|
||||||
|
const entries = [
|
||||||
|
{
|
||||||
|
type: "document" as const,
|
||||||
|
path: "/tmp/project/opencode.json",
|
||||||
|
info: {
|
||||||
|
permissions: [
|
||||||
|
{ action: "shell", resource: "*", effect: "ask" as const },
|
||||||
|
{ action: "shell", resource: "git status", effect: "allow" as const },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: "file" as const, path: "/tmp/project/opencode.json" },
|
||||||
|
]
|
||||||
|
const client = OpenCode.make({
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
fetch: async (input) => {
|
||||||
|
request = input instanceof Request ? input : new Request(input)
|
||||||
|
return Response.json(entries)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await client.config.get({ location: { directory: "/tmp/project" } })).toEqual(entries)
|
||||||
|
expect(request?.method).toBe("GET")
|
||||||
|
expect(request?.url).toBe("http://localhost:3000/api/config?location%5Bdirectory%5D=%2Ftmp%2Fproject")
|
||||||
|
})
|
||||||
|
|
||||||
test("websearch.query uses the public HTTP contract", async () => {
|
test("websearch.query uses the public HTTP contract", async () => {
|
||||||
let request: Request | undefined
|
let request: Request | undefined
|
||||||
const client = OpenCode.make({
|
const client = OpenCode.make({
|
||||||
|
|||||||
@@ -70,6 +70,30 @@ test("reports a failed registered service without spawning", async () => {
|
|||||||
expect(process.exitCode).toBe(null)
|
expect(process.exitCode).toBe(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("evicts an unresponsive registered service before starting its replacement", async () => {
|
||||||
|
const directory = await temp()
|
||||||
|
const registration = join(directory, "service.json")
|
||||||
|
const existing = spawn(registration, "hanging")
|
||||||
|
await waitForFile(registration)
|
||||||
|
const original = await Bun.file(registration).json()
|
||||||
|
|
||||||
|
const endpoint = await run(
|
||||||
|
Service.ensure({
|
||||||
|
file: registration,
|
||||||
|
version: "test",
|
||||||
|
command: [process.execPath, fixture, registration, "delayed", "10"],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const replacement = await Bun.file(registration).json()
|
||||||
|
|
||||||
|
expect((await Bun.file(registration + ".requests").text()).trim().split("\n")).toHaveLength(3)
|
||||||
|
expect(await existing.exited).toBe(0)
|
||||||
|
expect(replacement.pid).not.toBe(original.pid)
|
||||||
|
expect(endpoint.url).toBe(replacement.url)
|
||||||
|
expect(await health(endpoint.url)).toEqual({ healthy: true, version: "test", pid: replacement.pid })
|
||||||
|
process.kill(replacement.pid, "SIGTERM")
|
||||||
|
}, 20_000)
|
||||||
|
|
||||||
test("requests graceful stop of the exact service instance", async () => {
|
test("requests graceful stop of the exact service instance", async () => {
|
||||||
const directory = await temp()
|
const directory = await temp()
|
||||||
const registration = join(directory, "service.json")
|
const registration = join(directory, "service.json")
|
||||||
|
|||||||
+56
-164
@@ -5,8 +5,16 @@ import path from "path"
|
|||||||
import { isDeepStrictEqual } from "node:util"
|
import { isDeepStrictEqual } from "node:util"
|
||||||
import { type ParseError, parse } from "jsonc-parser"
|
import { type ParseError, parse } from "jsonc-parser"
|
||||||
import { Context, Effect, Layer, Option, PubSub, Ref, Schema, Semaphore, Stream } from "effect"
|
import { Context, Effect, Layer, Option, PubSub, Ref, Schema, Semaphore, Stream } from "effect"
|
||||||
import { Permission } from "@opencode-ai/schema/permission"
|
import {
|
||||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
AgentsDirectory,
|
||||||
|
ClaudeDirectory,
|
||||||
|
Directory,
|
||||||
|
Document,
|
||||||
|
File,
|
||||||
|
Info,
|
||||||
|
type Entry,
|
||||||
|
Event,
|
||||||
|
} from "@opencode-ai/schema/config"
|
||||||
import { Integration } from "@opencode-ai/schema/integration"
|
import { Integration } from "@opencode-ai/schema/integration"
|
||||||
import { Credential } from "./credential"
|
import { Credential } from "./credential"
|
||||||
import { Bus } from "./bus"
|
import { Bus } from "./bus"
|
||||||
@@ -15,141 +23,10 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
|||||||
import { Global } from "@opencode-ai/util/global"
|
import { Global } from "@opencode-ai/util/global"
|
||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
import { AbsolutePath } from "./schema"
|
import { AbsolutePath } from "./schema"
|
||||||
import { ConfigAgent } from "./config/agent"
|
|
||||||
import { ConfigMedia } from "./config/media"
|
|
||||||
import { ConfigCompaction } from "./config/compaction"
|
|
||||||
import { ConfigCommand } from "./config/command"
|
|
||||||
import { ConfigExperimental } from "./config/experimental"
|
|
||||||
import { ConfigFormatter } from "./config/formatter"
|
|
||||||
import { ConfigLSP } from "./config/lsp"
|
|
||||||
import { ConfigMCP } from "./config/mcp"
|
|
||||||
import { ConfigModel } from "./config/model"
|
|
||||||
import { ConfigPlugin } from "./config/plugin"
|
|
||||||
import { ConfigProvider } from "./config/provider"
|
|
||||||
import { ConfigReference } from "./config/reference"
|
|
||||||
import { ConfigWebSearch } from "./config/websearch"
|
|
||||||
import { ConfigToolOutput } from "./config/tool-output"
|
|
||||||
import { ConfigVariable } from "./config/variable"
|
import { ConfigVariable } from "./config/variable"
|
||||||
import { ConfigWatcher } from "./config/watcher"
|
import { ConfigNormalize } from "./config/normalize"
|
||||||
import { ConfigWarming } from "./config/warming"
|
|
||||||
import { ConfigV1 } from "./v1/config/config"
|
|
||||||
import { ConfigMigrateV1 } from "./v1/config/migrate"
|
|
||||||
import { WellKnown } from "./wellknown"
|
import { WellKnown } from "./wellknown"
|
||||||
|
|
||||||
export class Info extends Schema.Class<Info>("Config.Info")({
|
|
||||||
$schema: Schema.optional(Schema.String).annotate({
|
|
||||||
description: "JSON schema reference for configuration validation",
|
|
||||||
}),
|
|
||||||
shell: Schema.String.pipe(Schema.optional).annotate({
|
|
||||||
description: "Default shell to use for terminal and shell tool execution",
|
|
||||||
}),
|
|
||||||
model: ConfigModel.Selection.pipe(Schema.optional).annotate({
|
|
||||||
description: "Default model to use when no session or agent model is selected",
|
|
||||||
}),
|
|
||||||
default_agent: Schema.String.pipe(Schema.optional).annotate({
|
|
||||||
description: "Default primary agent to use when no session agent is selected",
|
|
||||||
}),
|
|
||||||
autoupdate: Schema.Union([Schema.Boolean, Schema.Literal("notify")])
|
|
||||||
.pipe(Schema.optional)
|
|
||||||
.annotate({
|
|
||||||
description: "Automatically update or notify when a new version is available",
|
|
||||||
}),
|
|
||||||
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(Schema.optional).annotate({
|
|
||||||
description: "Control whether sessions may be shared manually, automatically, or not at all",
|
|
||||||
}),
|
|
||||||
enterprise: Schema.Struct({
|
|
||||||
url: Schema.String.pipe(Schema.optional),
|
|
||||||
})
|
|
||||||
.pipe(Schema.optional)
|
|
||||||
.annotate({
|
|
||||||
description: "Enterprise sharing service configuration",
|
|
||||||
}),
|
|
||||||
username: Schema.String.pipe(Schema.optional).annotate({
|
|
||||||
description: "Username displayed in conversations and used for telemetry identity",
|
|
||||||
}),
|
|
||||||
permissions: Permission.Ruleset.pipe(Schema.optional).annotate({
|
|
||||||
description: "Ordered tool permission rules applied to agent tool use",
|
|
||||||
}),
|
|
||||||
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(Schema.optional).annotate({
|
|
||||||
description: "Named built-in agent overrides and custom agent definitions",
|
|
||||||
}),
|
|
||||||
snapshots: Schema.Boolean.pipe(Schema.optional).annotate({
|
|
||||||
description: "Enable snapshots used for undo and revert behavior",
|
|
||||||
}),
|
|
||||||
watcher: ConfigWatcher.Info.pipe(Schema.optional).annotate({
|
|
||||||
description: "Filesystem watcher configuration",
|
|
||||||
}),
|
|
||||||
formatter: ConfigFormatter.Info.pipe(Schema.optional).annotate({
|
|
||||||
description: "Enable built-in formatters or configure formatter overrides",
|
|
||||||
}),
|
|
||||||
lsp: ConfigLSP.Info.pipe(Schema.optional).annotate({
|
|
||||||
description: "Enable built-in language servers or configure server overrides",
|
|
||||||
}),
|
|
||||||
media: ConfigMedia.Info.pipe(Schema.optional).annotate({
|
|
||||||
description: "Media processing configuration",
|
|
||||||
}),
|
|
||||||
tool_output: ConfigToolOutput.Info.pipe(Schema.optional).annotate({
|
|
||||||
description: "Tool output truncation thresholds",
|
|
||||||
}),
|
|
||||||
mcp: ConfigMCP.Info.pipe(Schema.optional).annotate({
|
|
||||||
description: "MCP server configuration",
|
|
||||||
}),
|
|
||||||
compaction: ConfigCompaction.Info.pipe(Schema.optional).annotate({
|
|
||||||
description: "Conversation compaction behavior",
|
|
||||||
}),
|
|
||||||
skills: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
|
|
||||||
description: "Additional paths or URLs to discover skills from",
|
|
||||||
}),
|
|
||||||
commands: Schema.Record(Schema.String, ConfigCommand.Info).pipe(Schema.optional).annotate({
|
|
||||||
description: "Named slash command definitions",
|
|
||||||
}),
|
|
||||||
instructions: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
|
|
||||||
description: "Additional paths or URLs supplying ambient instructions",
|
|
||||||
}),
|
|
||||||
references: ConfigReference.Info.pipe(Schema.optional).annotate({
|
|
||||||
description: "Named local directories or Git repositories available as external context",
|
|
||||||
}),
|
|
||||||
websearch: ConfigWebSearch.Info.pipe(Schema.optional).annotate({
|
|
||||||
description: "Web search provider selection",
|
|
||||||
}),
|
|
||||||
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
|
|
||||||
description: "Ordered plugin enablement directives and external package declarations",
|
|
||||||
}),
|
|
||||||
warming: ConfigWarming.Warming.pipe(Schema.optional).annotate({
|
|
||||||
description: "Keep recently active sessions warm with transient model requests (default: false)",
|
|
||||||
}),
|
|
||||||
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
|
|
||||||
experimental: ConfigExperimental.Info.pipe(Schema.optional),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class Document extends Schema.Class<Document>("Config.Document")({
|
|
||||||
type: Schema.Literal("document"),
|
|
||||||
path: Schema.String.pipe(Schema.optional),
|
|
||||||
info: Info,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class Directory extends Schema.Class<Directory>("Config.Directory")({
|
|
||||||
type: Schema.Literal("directory"),
|
|
||||||
path: AbsolutePath,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class File extends Schema.Class<File>("Config.File")({
|
|
||||||
type: Schema.Literal("file"),
|
|
||||||
path: AbsolutePath,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class AgentsDirectory extends Schema.Class<AgentsDirectory>("Config.AgentsDirectory")({
|
|
||||||
type: Schema.Literal("agents"),
|
|
||||||
path: AbsolutePath,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class ClaudeDirectory extends Schema.Class<ClaudeDirectory>("Config.ClaudeDirectory")({
|
|
||||||
type: Schema.Literal("claude"),
|
|
||||||
path: AbsolutePath,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export type Entry = Document | Directory | File | AgentsDirectory | ClaudeDirectory
|
|
||||||
|
|
||||||
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
|
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
|
||||||
return entries
|
return entries
|
||||||
.filter((entry): entry is Document => entry.type === "document")
|
.filter((entry): entry is Document => entry.type === "document")
|
||||||
@@ -215,24 +92,43 @@ export const layer = (options?: Options) => Layer.effect(
|
|||||||
const reloadLock = Semaphore.makeUnsafe(1)
|
const reloadLock = Semaphore.makeUnsafe(1)
|
||||||
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||||
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
|
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
|
||||||
const decodeV1Info = Schema.decodeUnknownOption(ConfigV1.Info, decodeOptions)
|
const parseInfo = Effect.fn("Config.parseInfo")(function* (text: string, source: string) {
|
||||||
|
|
||||||
const parseInfo = (text: string) => {
|
|
||||||
const errors: ParseError[] = []
|
const errors: ParseError[] = []
|
||||||
const input: unknown = parse(text, errors, { allowTrailingComma: true })
|
const input: unknown = parse(text, errors, { allowTrailingComma: true })
|
||||||
if (errors.length) return
|
if (errors.length) {
|
||||||
return Option.getOrUndefined(
|
yield* Effect.logWarning("configuration normalization diagnostic", {
|
||||||
ConfigMigrateV1.isV1(input)
|
source,
|
||||||
? decodeV1Info(input).pipe(Option.map(ConfigMigrateV1.migrate), Option.flatMap(decodeInfo))
|
path: "$",
|
||||||
: decodeInfo(input),
|
kind: "invalid",
|
||||||
)
|
action: "rejected malformed JSON or JSONC document",
|
||||||
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
const result = ConfigNormalize.normalize(input)
|
||||||
|
yield* Effect.forEach(result.diagnostics, (diagnostic) =>
|
||||||
|
Effect.logWarning("configuration normalization diagnostic", {
|
||||||
|
source,
|
||||||
|
path: diagnostic.path[0] === "$" ? "$" : `$.${diagnostic.path.join(".")}`,
|
||||||
|
kind: diagnostic.kind,
|
||||||
|
action: diagnostic.message,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (result.type === "rejected") return
|
||||||
|
const info = Option.getOrUndefined(decodeInfo(result.encoded))
|
||||||
|
if (info) return info
|
||||||
|
yield* Effect.logWarning("configuration normalization diagnostic", {
|
||||||
|
source,
|
||||||
|
path: "$",
|
||||||
|
kind: "invalid",
|
||||||
|
action: "rejected canonical configuration after final validation",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const loadFile = Effect.fnUntraced(function* (filepath: string) {
|
const loadFile = Effect.fnUntraced(function* (filepath: string) {
|
||||||
const text = yield* fs.readFileStringSafe(filepath)
|
const text = yield* fs.readFileStringSafe(filepath)
|
||||||
if (!text) return
|
if (text === undefined) return
|
||||||
const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text })
|
const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text })
|
||||||
const info = parseInfo(substituted)
|
const info = yield* parseInfo(substituted, filepath)
|
||||||
if (!info) return
|
if (!info) return
|
||||||
return new Document({ type: "document", path: filepath, info })
|
return new Document({ type: "document", path: filepath, info })
|
||||||
})
|
})
|
||||||
@@ -263,7 +159,7 @@ export const layer = (options?: Options) => Layer.effect(
|
|||||||
text: JSON.stringify(config),
|
text: JSON.stringify(config),
|
||||||
env: variables,
|
env: variables,
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.map(parseInfo),
|
Effect.flatMap((text) => parseInfo(text, entry.origin)),
|
||||||
Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)),
|
Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)),
|
||||||
),
|
),
|
||||||
).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined)))
|
).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined)))
|
||||||
@@ -296,21 +192,17 @@ export const layer = (options?: Options) => Layer.effect(
|
|||||||
|
|
||||||
// We load certain files from a few other folders in the ecosystem
|
// We load certain files from a few other folders in the ecosystem
|
||||||
const claude = [
|
const claude = [
|
||||||
...((yield* fs.isDir(globalClaudeDirectory))
|
...new Set([
|
||||||
? [new ClaudeDirectory({ type: "claude", path: globalClaudeDirectory })]
|
...((yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []),
|
||||||
: []),
|
...discovered.filter((item) => path.basename(item) === ".claude"),
|
||||||
...discovered
|
]),
|
||||||
.filter((item) => path.basename(item) === ".claude")
|
].map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) }))
|
||||||
.map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) })),
|
|
||||||
]
|
|
||||||
const agents = [
|
const agents = [
|
||||||
...((yield* fs.isDir(globalAgentsDirectory))
|
...new Set([
|
||||||
? [new AgentsDirectory({ type: "agents", path: globalAgentsDirectory })]
|
...((yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []),
|
||||||
: []),
|
...discovered.filter((item) => path.basename(item) === ".agents"),
|
||||||
...discovered
|
]),
|
||||||
.filter((item) => path.basename(item) === ".agents")
|
].map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) }))
|
||||||
.map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) })),
|
|
||||||
]
|
|
||||||
|
|
||||||
const directories = [
|
const directories = [
|
||||||
globalDirectory,
|
globalDirectory,
|
||||||
@@ -344,14 +236,14 @@ export const layer = (options?: Options) => Layer.effect(
|
|||||||
Effect.orDie,
|
Effect.orDie,
|
||||||
)
|
)
|
||||||
: []
|
: []
|
||||||
const content = options?.content
|
const content = options?.content !== undefined
|
||||||
? yield* ConfigVariable.substitute({
|
? yield* ConfigVariable.substitute({
|
||||||
type: "virtual",
|
type: "virtual",
|
||||||
source: "OPENCODE_CONFIG_CONTENT",
|
source: "OPENCODE_CONFIG_CONTENT",
|
||||||
dir: location.directory,
|
dir: location.directory,
|
||||||
text: options.content,
|
text: options.content,
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.map(parseInfo),
|
Effect.flatMap((text) => parseInfo(text, "OPENCODE_CONFIG_CONTENT")),
|
||||||
Effect.map((info) => (info ? [new Document({ type: "document", info })] : [])),
|
Effect.map((info) => (info ? [new Document({ type: "document", info })] : [])),
|
||||||
Effect.orDie,
|
Effect.orDie,
|
||||||
)
|
)
|
||||||
@@ -359,13 +251,13 @@ export const layer = (options?: Options) => Layer.effect(
|
|||||||
|
|
||||||
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
|
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
|
||||||
return [
|
return [
|
||||||
|
...(yield* loadWellknown().pipe(Effect.orDie)),
|
||||||
...claude,
|
...claude,
|
||||||
...agents,
|
...agents,
|
||||||
...(supplementary[0] ?? []),
|
...(supplementary[0] ?? []),
|
||||||
...explicit,
|
...explicit,
|
||||||
...direct,
|
...direct,
|
||||||
...supplementary.slice(1).flat(),
|
...supplementary.slice(1).flat(),
|
||||||
...(yield* loadWellknown().pipe(Effect.orDie)),
|
|
||||||
...content,
|
...content,
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
@@ -408,7 +300,7 @@ export const layer = (options?: Options) => Layer.effect(
|
|||||||
if (isDeepStrictEqual(configs, next)) return
|
if (isDeepStrictEqual(configs, next)) return
|
||||||
configs = next
|
configs = next
|
||||||
yield* reconcile(next)
|
yield* reconcile(next)
|
||||||
yield* bus.publish(ConfigSchema.Event.Updated, {})
|
yield* bus.publish(Event.Updated, {})
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
export * as ConfigAgent from "./agent"
|
|
||||||
|
|
||||||
import { Schema } from "effect"
|
|
||||||
import { Permission } from "@opencode-ai/schema/permission"
|
|
||||||
import { ConfigProvider } from "./provider"
|
|
||||||
import { ConfigModel } from "./model"
|
|
||||||
import { PositiveInt } from "../schema"
|
|
||||||
|
|
||||||
export const Color = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))
|
|
||||||
|
|
||||||
export class Info extends Schema.Class<Info>("Config.Agent")({
|
|
||||||
model: ConfigModel.Selection.pipe(Schema.optional),
|
|
||||||
request: ConfigProvider.Request.pipe(Schema.optional),
|
|
||||||
system: Schema.String.pipe(Schema.optional),
|
|
||||||
description: Schema.String.pipe(Schema.optional),
|
|
||||||
mode: Schema.Literals(["subagent", "primary", "all"]).pipe(Schema.optional),
|
|
||||||
hidden: Schema.Boolean.pipe(Schema.optional),
|
|
||||||
color: Color.pipe(Schema.optional),
|
|
||||||
steps: PositiveInt.pipe(Schema.optional),
|
|
||||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
|
||||||
permissions: Permission.Ruleset.pipe(Schema.optional),
|
|
||||||
}) {}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
export * as ConfigCommand from "./command"
|
|
||||||
|
|
||||||
import { Schema } from "effect"
|
|
||||||
import { ConfigModel } from "./model"
|
|
||||||
|
|
||||||
export class Info extends Schema.Class<Info>("Config.Command")({
|
|
||||||
template: Schema.String,
|
|
||||||
description: Schema.String.pipe(Schema.optional),
|
|
||||||
agent: Schema.String.pipe(Schema.optional),
|
|
||||||
model: ConfigModel.Selection.pipe(Schema.optional),
|
|
||||||
subtask: Schema.Boolean.pipe(Schema.optional),
|
|
||||||
}) {}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
export * as ConfigCompaction from "./compaction"
|
|
||||||
|
|
||||||
import { Schema } from "effect"
|
|
||||||
import { NonNegativeInt } from "../schema"
|
|
||||||
|
|
||||||
export class Keep extends Schema.Class<Keep>("Config.Compaction.Keep")({
|
|
||||||
tokens: NonNegativeInt.pipe(Schema.optional),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class Info extends Schema.Class<Info>("Config.Compaction")({
|
|
||||||
auto: Schema.Boolean.pipe(Schema.optional),
|
|
||||||
keep: Keep.pipe(Schema.optional),
|
|
||||||
buffer: NonNegativeInt.pipe(Schema.optional),
|
|
||||||
}) {}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
export * as ConfigExperimental from "./experimental"
|
|
||||||
|
|
||||||
import { Schema } from "effect"
|
|
||||||
import { NonNegativeInt } from "../schema"
|
|
||||||
import { ConfigPolicy } from "./policy"
|
|
||||||
|
|
||||||
export class Info extends Schema.Class<Info>("ConfigExperimental.Info")({
|
|
||||||
subagent_depth: NonNegativeInt.pipe(Schema.optional).annotate({
|
|
||||||
description: "Maximum subagent nesting depth. Defaults to 1.",
|
|
||||||
}),
|
|
||||||
policies: ConfigPolicy.Info.pipe(Schema.Array, Schema.optional).annotate({
|
|
||||||
description: "Ordered policies controlling access to configured resources",
|
|
||||||
}),
|
|
||||||
}) {}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
export * as ConfigMedia from "./media"
|
|
||||||
|
|
||||||
import { Schema } from "effect"
|
|
||||||
import { PositiveInt } from "../schema"
|
|
||||||
|
|
||||||
export class Image extends Schema.Class<Image>("Config.Media.Image")({
|
|
||||||
auto_resize: Schema.Boolean.pipe(Schema.optional),
|
|
||||||
max_width: PositiveInt.pipe(Schema.optional),
|
|
||||||
max_height: PositiveInt.pipe(Schema.optional),
|
|
||||||
max_base64_bytes: PositiveInt.pipe(Schema.optional),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class Info extends Schema.Class<Info>("Config.Media")({
|
|
||||||
image: Image.pipe(Schema.optional),
|
|
||||||
}) {}
|
|
||||||
@@ -0,0 +1,813 @@
|
|||||||
|
export * as ConfigNormalize from "./normalize"
|
||||||
|
|
||||||
|
import { isDeepStrictEqual } from "node:util"
|
||||||
|
import { Option, Schema } from "effect"
|
||||||
|
import { Info } from "@opencode-ai/schema/config"
|
||||||
|
import { ConfigAgent } from "@opencode-ai/schema/config/agent"
|
||||||
|
import { ConfigCommand } from "@opencode-ai/schema/config/command"
|
||||||
|
import { ConfigCompaction } from "@opencode-ai/schema/config/compaction"
|
||||||
|
import { ConfigFormatter } from "@opencode-ai/schema/config/formatter"
|
||||||
|
import { ConfigLSP } from "@opencode-ai/schema/config/lsp"
|
||||||
|
import { ConfigMedia } from "@opencode-ai/schema/config/media"
|
||||||
|
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||||
|
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
|
||||||
|
import { ConfigPolicy } from "@opencode-ai/schema/config/policy"
|
||||||
|
import { ConfigProvider } from "@opencode-ai/schema/config/provider"
|
||||||
|
import { ConfigReference } from "@opencode-ai/schema/config/reference"
|
||||||
|
import { ConfigExperimental } from "@opencode-ai/schema/config/experimental"
|
||||||
|
import { Permission } from "@opencode-ai/schema/permission"
|
||||||
|
import { ConfigAgentV1 } from "../v1/config/agent"
|
||||||
|
import { ConfigAttachmentV1 } from "../v1/config/attachment"
|
||||||
|
import { ConfigCommandV1 } from "../v1/config/command"
|
||||||
|
import { ConfigMCPV1 } from "../v1/config/mcp"
|
||||||
|
import { ConfigPermissionV1 } from "../v1/config/permission"
|
||||||
|
import { ConfigPluginV1 } from "../v1/config/plugin"
|
||||||
|
import { ConfigProviderV1 } from "../v1/config/provider"
|
||||||
|
import { ConfigMigrateV1 } from "../v1/config/migrate"
|
||||||
|
import { PositiveInt } from "../schema"
|
||||||
|
|
||||||
|
export interface Diagnostic {
|
||||||
|
readonly kind: "conflict" | "invalid" | "unsupported"
|
||||||
|
readonly path: readonly string[]
|
||||||
|
readonly message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Result =
|
||||||
|
| {
|
||||||
|
readonly type: "normalized"
|
||||||
|
readonly encoded: Readonly<Record<string, unknown>>
|
||||||
|
readonly diagnostics: readonly Diagnostic[]
|
||||||
|
}
|
||||||
|
| { readonly type: "rejected"; readonly diagnostics: readonly Diagnostic[] }
|
||||||
|
|
||||||
|
const options = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||||
|
const unsupportedTopLevel = ["logLevel", "server", "subagent_depth", "layout"] as const
|
||||||
|
const unsupportedExperimental = [
|
||||||
|
"disable_paste_summary",
|
||||||
|
"batch_tool",
|
||||||
|
"openTelemetry",
|
||||||
|
"primary_tools",
|
||||||
|
"continue_loop_on_deny",
|
||||||
|
] as const
|
||||||
|
const unsupportedProvider = ["id", "whitelist", "blacklist"] as const
|
||||||
|
const unsupportedModel = ["release_date", "attachment", "reasoning", "temperature", "experimental"] as const
|
||||||
|
|
||||||
|
export function normalize(input: unknown): Result {
|
||||||
|
if (!isRecord(input))
|
||||||
|
return {
|
||||||
|
type: "rejected",
|
||||||
|
diagnostics: [
|
||||||
|
{ kind: "invalid", path: ["$"], message: "rejected configuration because its root is not an object" },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const diagnostics: Diagnostic[] = []
|
||||||
|
const encoded: Record<string, unknown> = {}
|
||||||
|
unsupportedTopLevel.forEach((key) => unsupportedIfPresent(input, key, [key], diagnostics))
|
||||||
|
|
||||||
|
const legacySnapshots = own(input, "snapshot")
|
||||||
|
? decodeEncoded(Schema.Boolean, input.snapshot, ["snapshot"], diagnostics)
|
||||||
|
: undefined
|
||||||
|
const legacyShare = own(input, "autoshare")
|
||||||
|
? decodeValue(Schema.Boolean, input.autoshare, ["autoshare"], diagnostics) === true
|
||||||
|
? "auto"
|
||||||
|
: undefined
|
||||||
|
: undefined
|
||||||
|
const legacyMedia = own(input, "attachment")
|
||||||
|
? decodeValue(ConfigAttachmentV1.Info, input.attachment, ["attachment"], diagnostics)
|
||||||
|
: undefined
|
||||||
|
if (legacyMedia !== undefined) {
|
||||||
|
const migrated = ConfigMigrateV1.migrate({ attachment: legacyMedia }).media
|
||||||
|
if (migrated !== undefined) encoded.media = canonical(ConfigMedia.Info, migrated)
|
||||||
|
}
|
||||||
|
if (legacySnapshots !== undefined) encoded.snapshots = legacySnapshots
|
||||||
|
if (legacyShare !== undefined) encoded.share = legacyShare
|
||||||
|
|
||||||
|
const legacyReferences = decodeEncodedMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics)
|
||||||
|
const nativeReferences = decodeEncodedMap(input.references, ConfigReference.Entry, ["references"], diagnostics)
|
||||||
|
mergeMap(
|
||||||
|
encoded,
|
||||||
|
"references",
|
||||||
|
legacyReferences,
|
||||||
|
nativeReferences,
|
||||||
|
isRecord(input.reference) || isRecord(input.references),
|
||||||
|
diagnostics,
|
||||||
|
)
|
||||||
|
|
||||||
|
const legacyCommands = decodeMap(input.command, ConfigCommandV1.Info, ["command"], diagnostics)
|
||||||
|
diagnoseSelectionMap(input.command, ["command"], diagnostics)
|
||||||
|
const migratedCommands = mapValues(legacyCommands, (value) => {
|
||||||
|
const migrated = ConfigMigrateV1.commands({ value })?.value
|
||||||
|
return migrated === undefined ? undefined : canonical(ConfigCommand.Info, migrated)
|
||||||
|
})
|
||||||
|
const nativeCommands = decodeEncodedMap(input.commands, ConfigCommand.Info, ["commands"], diagnostics)
|
||||||
|
mergeMap(
|
||||||
|
encoded,
|
||||||
|
"commands",
|
||||||
|
migratedCommands,
|
||||||
|
nativeCommands,
|
||||||
|
isRecord(input.command) || isRecord(input.commands),
|
||||||
|
diagnostics,
|
||||||
|
)
|
||||||
|
|
||||||
|
const legacyAgents = mapValues(decodeMap(input.agent, ConfigAgentV1.Info, ["agent"], diagnostics), (value) =>
|
||||||
|
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent(value)),
|
||||||
|
)
|
||||||
|
const legacySmallModel = own(input, "small_model")
|
||||||
|
? decodeValue(Schema.String, input.small_model, ["small_model"], diagnostics)
|
||||||
|
: undefined
|
||||||
|
const migratedSmallModel = legacySmallModel
|
||||||
|
? ConfigMigrateV1.migrate({ small_model: legacySmallModel }).agents?.title?.model
|
||||||
|
: undefined
|
||||||
|
if (legacySmallModel && !migratedSmallModel)
|
||||||
|
diagnostics.push({
|
||||||
|
kind: "unsupported",
|
||||||
|
path: ["small_model"],
|
||||||
|
message: "omitted unsupported legacy model reference",
|
||||||
|
})
|
||||||
|
if (migratedSmallModel)
|
||||||
|
legacyAgents.title = {
|
||||||
|
model: migratedSmallModel,
|
||||||
|
...legacyAgents.title,
|
||||||
|
}
|
||||||
|
const modeAgents = mapValues(decodeMap(input.mode, ConfigAgentV1.Info, ["mode"], diagnostics), (value) =>
|
||||||
|
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent({ ...value, mode: "primary" })),
|
||||||
|
)
|
||||||
|
const migratedAgents = mergeMaps(legacyAgents, modeAgents, ["agents"], diagnostics)
|
||||||
|
const nativeAgents = decodeEncodedMap(input.agents, ConfigAgent.Info, ["agents"], diagnostics)
|
||||||
|
diagnoseAgentUnsupported(input.agent, ["agent"], diagnostics)
|
||||||
|
diagnoseAgentUnsupported(input.mode, ["mode"], diagnostics)
|
||||||
|
mergeMap(
|
||||||
|
encoded,
|
||||||
|
"agents",
|
||||||
|
migratedAgents,
|
||||||
|
nativeAgents,
|
||||||
|
isRecord(input.agent) || isRecord(input.mode) || isRecord(input.agents),
|
||||||
|
diagnostics,
|
||||||
|
)
|
||||||
|
|
||||||
|
const legacyProviders = migrateProviders(input.provider, diagnostics)
|
||||||
|
const nativeProviders = decodeEncodedMap(input.providers, ConfigProvider.Info, ["providers"], diagnostics)
|
||||||
|
mergeMap(
|
||||||
|
encoded,
|
||||||
|
"providers",
|
||||||
|
legacyProviders,
|
||||||
|
nativeProviders,
|
||||||
|
isRecord(input.provider) || isRecord(input.providers),
|
||||||
|
diagnostics,
|
||||||
|
)
|
||||||
|
|
||||||
|
const toolRules = migrateTools(input.tools, diagnostics)
|
||||||
|
const permissionRules = migratePermissions(input.permission, diagnostics)
|
||||||
|
const nativePermissions = decodeEncodedList(input.permissions, Permission.Rule, ["permissions"], diagnostics)
|
||||||
|
const permissions = [...toolRules, ...permissionRules, ...nativePermissions]
|
||||||
|
if (permissions.length || Array.isArray(input.permissions)) encoded.permissions = permissions
|
||||||
|
|
||||||
|
const legacyPlugins = decodeList(input.plugin, ConfigPluginV1.Spec, ["plugin"], diagnostics).map((plugin) =>
|
||||||
|
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
|
||||||
|
)
|
||||||
|
const nativePlugins = decodeEncodedList(input.plugins, ConfigPlugin.Plugin, ["plugins"], diagnostics)
|
||||||
|
if (legacyPlugins.length || nativePlugins.length || Array.isArray(input.plugin) || Array.isArray(input.plugins))
|
||||||
|
encoded.plugins = [...legacyPlugins, ...nativePlugins]
|
||||||
|
|
||||||
|
normalizeSkills(input, encoded, diagnostics)
|
||||||
|
normalizeMcp(input, encoded, diagnostics)
|
||||||
|
normalizeCompaction(input, encoded, diagnostics)
|
||||||
|
normalizeExperimental(input, encoded, diagnostics)
|
||||||
|
normalizeWatcher(input, encoded, diagnostics)
|
||||||
|
normalizeFormatter(input, encoded, diagnostics)
|
||||||
|
normalizeLsp(input, encoded, diagnostics)
|
||||||
|
|
||||||
|
const nativeAtomic = {
|
||||||
|
$schema: Info.fields.$schema,
|
||||||
|
shell: Info.fields.shell,
|
||||||
|
model: Info.fields.model,
|
||||||
|
default_agent: Info.fields.default_agent,
|
||||||
|
autoupdate: Info.fields.autoupdate,
|
||||||
|
share: Info.fields.share,
|
||||||
|
enterprise: Info.fields.enterprise,
|
||||||
|
username: Info.fields.username,
|
||||||
|
snapshots: Info.fields.snapshots,
|
||||||
|
media: Info.fields.media,
|
||||||
|
tool_output: Info.fields.tool_output,
|
||||||
|
websearch: Info.fields.websearch,
|
||||||
|
warming: Info.fields.warming,
|
||||||
|
}
|
||||||
|
Object.entries(nativeAtomic).forEach(([key, schema]) => {
|
||||||
|
if (!own(input, key)) return
|
||||||
|
const value = decodeEncoded(schema, input[key], [key], diagnostics)
|
||||||
|
if (value === undefined) return
|
||||||
|
overlay(encoded, key, value, [key], diagnostics)
|
||||||
|
})
|
||||||
|
|
||||||
|
const instructions = decodeEncodedList(input.instructions, Schema.String, ["instructions"], diagnostics)
|
||||||
|
if (instructions.length || Array.isArray(input.instructions)) encoded.instructions = instructions
|
||||||
|
|
||||||
|
return { type: "normalized", encoded, diagnostics }
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSkills(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
|
||||||
|
if (!own(input, "skills")) return
|
||||||
|
if (Array.isArray(input.skills)) {
|
||||||
|
encoded.skills = decodeEncodedList(input.skills, Schema.String, ["skills"], diagnostics)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isRecord(input.skills)) {
|
||||||
|
invalid(["skills"], diagnostics)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
encoded.skills = [
|
||||||
|
...decodeEncodedList(input.skills.paths, Schema.String, ["skills", "paths"], diagnostics),
|
||||||
|
...decodeEncodedList(input.skills.urls, Schema.String, ["skills", "urls"], diagnostics),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMcp(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
|
||||||
|
const legacyServers: Record<string, unknown> = {}
|
||||||
|
const nativeServers: Record<string, unknown> = {}
|
||||||
|
const timeout: Record<string, unknown> = {}
|
||||||
|
if (isRecord(input.experimental) && own(input.experimental, "mcp_timeout")) {
|
||||||
|
const value = decodeEncoded(
|
||||||
|
PositiveInt,
|
||||||
|
input.experimental.mcp_timeout,
|
||||||
|
["experimental", "mcp_timeout"],
|
||||||
|
diagnostics,
|
||||||
|
)
|
||||||
|
if (value !== undefined) {
|
||||||
|
timeout.catalog = value
|
||||||
|
timeout.execution = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (own(input, "mcp")) {
|
||||||
|
if (!isRecord(input.mcp)) invalid(["mcp"], diagnostics)
|
||||||
|
if (isRecord(input.mcp)) {
|
||||||
|
Object.entries(input.mcp).forEach(([name, value]) => {
|
||||||
|
const path = ["mcp", name]
|
||||||
|
if (isEnabledOnlyMcp(value)) {
|
||||||
|
diagnostics.push({ kind: "unsupported", path, message: "omitted enabled-only legacy MCP entry" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (name === "servers" && !isDirectLegacyMcp(value)) {
|
||||||
|
Object.entries(decodeEncodedMap(value, ConfigMCP.Server, path, diagnostics)).forEach(([key, server]) =>
|
||||||
|
setOwn(nativeServers, key, server),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (name === "timeout" && !isDirectLegacyMcp(value)) {
|
||||||
|
normalizeMcpTimeout(value, timeout, path, diagnostics)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const server = decodeValue(ConfigMCPV1.Info, value, path, diagnostics)
|
||||||
|
if (server !== undefined)
|
||||||
|
setOwn(legacyServers, name, canonical(ConfigMCP.Server, ConfigMigrateV1.migrateMcp(server)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const servers = mergeMaps(legacyServers, nativeServers, ["mcp", "servers"], diagnostics)
|
||||||
|
if (!Object.keys(servers).length && !Object.keys(timeout).length) {
|
||||||
|
if (isRecord(input.mcp) && !Object.keys(input.mcp).length) encoded.mcp = {}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
encoded.mcp = {
|
||||||
|
...(Object.keys(timeout).length ? { timeout } : {}),
|
||||||
|
...(Object.keys(servers).length ? { servers } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMcpTimeout(
|
||||||
|
value: unknown,
|
||||||
|
timeout: Record<string, unknown>,
|
||||||
|
path: string[],
|
||||||
|
diagnostics: Diagnostic[],
|
||||||
|
) {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
invalid(path, diagnostics)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const recognized = ["startup", "catalog", "execution"].filter((key) => own(value, key))
|
||||||
|
if (Object.keys(value).length && !recognized.length) {
|
||||||
|
invalid(path, diagnostics)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recognized.forEach((key) => {
|
||||||
|
const leaf = decodeEncoded(
|
||||||
|
ConfigMCP.Timeout.fields[key as keyof typeof ConfigMCP.Timeout.fields],
|
||||||
|
value[key],
|
||||||
|
[...path, key],
|
||||||
|
diagnostics,
|
||||||
|
)
|
||||||
|
if (leaf === undefined) return
|
||||||
|
overlay(timeout, key, leaf, [...path, key], diagnostics)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCompaction(
|
||||||
|
input: Record<string, unknown>,
|
||||||
|
encoded: Record<string, unknown>,
|
||||||
|
diagnostics: Diagnostic[],
|
||||||
|
) {
|
||||||
|
if (!own(input, "compaction")) return
|
||||||
|
if (!isRecord(input.compaction)) {
|
||||||
|
invalid(["compaction"], diagnostics)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
unsupportedIfPresent(input.compaction, "tail_turns", ["compaction", "tail_turns"], diagnostics)
|
||||||
|
unsupportedIfPresent(input.compaction, "prune", ["compaction", "prune"], diagnostics)
|
||||||
|
const result: Record<string, unknown> = {}
|
||||||
|
if (own(input.compaction, "auto")) {
|
||||||
|
const value = decodeEncoded(
|
||||||
|
ConfigCompaction.Info.fields.auto,
|
||||||
|
input.compaction.auto,
|
||||||
|
["compaction", "auto"],
|
||||||
|
diagnostics,
|
||||||
|
)
|
||||||
|
if (value !== undefined) result.auto = value
|
||||||
|
}
|
||||||
|
const legacyTokens = own(input.compaction, "preserve_recent_tokens")
|
||||||
|
? decodeEncoded(
|
||||||
|
ConfigCompaction.Keep.fields.tokens,
|
||||||
|
input.compaction.preserve_recent_tokens,
|
||||||
|
["compaction", "preserve_recent_tokens"],
|
||||||
|
diagnostics,
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
const nativeKeep = isRecord(input.compaction.keep) ? input.compaction.keep : undefined
|
||||||
|
if (own(input.compaction, "keep") && !nativeKeep) invalid(["compaction", "keep"], diagnostics)
|
||||||
|
const nativeTokens =
|
||||||
|
nativeKeep && own(nativeKeep, "tokens")
|
||||||
|
? decodeEncoded(
|
||||||
|
ConfigCompaction.Keep.fields.tokens,
|
||||||
|
nativeKeep.tokens,
|
||||||
|
["compaction", "keep", "tokens"],
|
||||||
|
diagnostics,
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
const tokens = prefer(legacyTokens, nativeTokens, ["compaction", "keep", "tokens"], diagnostics)
|
||||||
|
if (tokens !== undefined) result.keep = { tokens }
|
||||||
|
const legacyBuffer = own(input.compaction, "reserved")
|
||||||
|
? decodeEncoded(
|
||||||
|
ConfigCompaction.Info.fields.buffer,
|
||||||
|
input.compaction.reserved,
|
||||||
|
["compaction", "reserved"],
|
||||||
|
diagnostics,
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
const nativeBuffer = own(input.compaction, "buffer")
|
||||||
|
? decodeEncoded(ConfigCompaction.Info.fields.buffer, input.compaction.buffer, ["compaction", "buffer"], diagnostics)
|
||||||
|
: undefined
|
||||||
|
const buffer = prefer(legacyBuffer, nativeBuffer, ["compaction", "buffer"], diagnostics)
|
||||||
|
if (buffer !== undefined) result.buffer = buffer
|
||||||
|
if (Object.keys(result).length || !Object.keys(input.compaction).length) encoded.compaction = result
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExperimental(
|
||||||
|
input: Record<string, unknown>,
|
||||||
|
encoded: Record<string, unknown>,
|
||||||
|
diagnostics: Diagnostic[],
|
||||||
|
) {
|
||||||
|
const result: Record<string, unknown> = {}
|
||||||
|
const generated: unknown[] = []
|
||||||
|
const enabled = decodeProviderList(input, "enabled_providers", diagnostics)
|
||||||
|
if (enabled.present && (!enabled.nonEmpty || enabled.values.length)) {
|
||||||
|
generated.push({ action: "provider.use", resource: "*", effect: "deny" })
|
||||||
|
generated.push(
|
||||||
|
...enabled.values.map((resource) => ({
|
||||||
|
action: "provider.use",
|
||||||
|
resource: ConfigMigrateV1.providerID(resource),
|
||||||
|
effect: "allow",
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const disabled = decodeProviderList(input, "disabled_providers", diagnostics)
|
||||||
|
generated.push(
|
||||||
|
...disabled.values.map((resource) => ({
|
||||||
|
action: "provider.use",
|
||||||
|
resource: ConfigMigrateV1.providerID(resource),
|
||||||
|
effect: "deny",
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
const native: unknown[] = []
|
||||||
|
if (own(input, "experimental")) {
|
||||||
|
if (!isRecord(input.experimental)) invalid(["experimental"], diagnostics)
|
||||||
|
if (isRecord(input.experimental)) {
|
||||||
|
const experimental = input.experimental
|
||||||
|
unsupportedExperimental.forEach((key) =>
|
||||||
|
unsupportedIfPresent(experimental, key, ["experimental", key], diagnostics),
|
||||||
|
)
|
||||||
|
if (own(experimental, "subagent_depth")) {
|
||||||
|
const value = decodeEncoded(
|
||||||
|
ConfigExperimental.Info.fields.subagent_depth,
|
||||||
|
experimental.subagent_depth,
|
||||||
|
["experimental", "subagent_depth"],
|
||||||
|
diagnostics,
|
||||||
|
)
|
||||||
|
if (value !== undefined) result.subagent_depth = value
|
||||||
|
}
|
||||||
|
native.push(
|
||||||
|
...decodeEncodedList(experimental.policies, ConfigPolicy.Info, ["experimental", "policies"], diagnostics),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (generated.length || native.length || (isRecord(input.experimental) && Array.isArray(input.experimental.policies)))
|
||||||
|
result.policies = [...generated, ...native]
|
||||||
|
if (Object.keys(result).length || (isRecord(input.experimental) && !Object.keys(input.experimental).length))
|
||||||
|
encoded.experimental = result
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWatcher(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
|
||||||
|
if (!own(input, "watcher")) return
|
||||||
|
if (!isRecord(input.watcher)) {
|
||||||
|
invalid(["watcher"], diagnostics)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const ignore = decodeEncodedList(input.watcher.ignore, Schema.String, ["watcher", "ignore"], diagnostics)
|
||||||
|
encoded.watcher = ignore.length || Array.isArray(input.watcher.ignore) ? { ignore } : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFormatter(
|
||||||
|
input: Record<string, unknown>,
|
||||||
|
encoded: Record<string, unknown>,
|
||||||
|
diagnostics: Diagnostic[],
|
||||||
|
) {
|
||||||
|
if (!own(input, "formatter")) return
|
||||||
|
if (typeof input.formatter === "boolean") {
|
||||||
|
const value = decodeEncoded(ConfigFormatter.Info, input.formatter, ["formatter"], diagnostics)
|
||||||
|
if (value !== undefined) encoded.formatter = value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const entries = decodeEncodedMap(input.formatter, ConfigFormatter.Entry, ["formatter"], diagnostics)
|
||||||
|
if (isRecord(input.formatter) && (!Object.keys(input.formatter).length || Object.keys(entries).length))
|
||||||
|
encoded.formatter = entries
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLsp(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
|
||||||
|
if (!own(input, "lsp")) return
|
||||||
|
if (typeof input.lsp === "boolean") {
|
||||||
|
const value = decodeEncoded(ConfigLSP.Info, input.lsp, ["lsp"], diagnostics)
|
||||||
|
if (value !== undefined) encoded.lsp = value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const entries = decodeEncodedMap(input.lsp, ConfigLSP.Entry, ["lsp"], diagnostics)
|
||||||
|
if (isRecord(input.lsp) && (!Object.keys(input.lsp).length || Object.keys(entries).length)) encoded.lsp = entries
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateTools(value: unknown, diagnostics: Diagnostic[]) {
|
||||||
|
if (value === undefined) return []
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
invalid(["tools"], diagnostics)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return Object.entries(value).flatMap(([action, raw]) => {
|
||||||
|
const enabled = decodeValue(Schema.Boolean, raw, ["tools", action], diagnostics)
|
||||||
|
if (enabled === undefined) return []
|
||||||
|
return [{ action: ConfigMigrateV1.normalizeAction(action), resource: "*", effect: enabled ? "allow" : "deny" }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function migratePermissions(value: unknown, diagnostics: Diagnostic[]) {
|
||||||
|
if (value === undefined) return []
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const effect = decodeValue(ConfigPermissionV1.Action, value, ["permission"], diagnostics)
|
||||||
|
return effect === undefined ? [] : [{ action: "*", resource: "*", effect }]
|
||||||
|
}
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
invalid(["permission"], diagnostics)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return Object.entries(value).flatMap(([action, raw]) => {
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
const effect = decodeValue(ConfigPermissionV1.Action, raw, ["permission", action], diagnostics)
|
||||||
|
return effect === undefined ? [] : [{ action: ConfigMigrateV1.normalizeAction(action), resource: "*", effect }]
|
||||||
|
}
|
||||||
|
if (!isRecord(raw)) {
|
||||||
|
invalid(["permission", action], diagnostics)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return Object.entries(raw).flatMap(([resource, effect], index) => {
|
||||||
|
const decoded = decodeValue(ConfigPermissionV1.Action, effect, ["permission", action, String(index)], diagnostics)
|
||||||
|
return decoded === undefined
|
||||||
|
? []
|
||||||
|
: [{ action: ConfigMigrateV1.normalizeAction(action), resource, effect: decoded }]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateProviders(value: unknown, diagnostics: Diagnostic[]) {
|
||||||
|
if (value === undefined) return {}
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
invalid(["provider"], diagnostics)
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
const candidates = Object.entries(value).flatMap(([name, raw]) => {
|
||||||
|
const path = ["provider", name]
|
||||||
|
diagnoseProviderUnsupported(raw, path, diagnostics)
|
||||||
|
if (invalidProviderOverlays(raw, path, diagnostics)) return []
|
||||||
|
const provider = decodeValue(ConfigProviderV1.Info, raw, path, diagnostics)
|
||||||
|
if (provider === undefined) return []
|
||||||
|
const destination = ConfigMigrateV1.providerID(name)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
destination,
|
||||||
|
provider: canonical(ConfigProvider.Info, ConfigMigrateV1.migrateProvider(name, provider)),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
const current = new Set(candidates.filter((item) => item.name === item.destination).map((item) => item.destination))
|
||||||
|
const result: Record<string, unknown> = {}
|
||||||
|
candidates.forEach((item) => {
|
||||||
|
if (item.name !== item.destination && current.has(item.destination)) return
|
||||||
|
setOwn(result, item.destination, item.provider)
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidProviderOverlays(value: unknown, path: string[], diagnostics: Diagnostic[]) {
|
||||||
|
if (!isRecord(value) || !isRecord(value.options)) return false
|
||||||
|
const headersInvalid =
|
||||||
|
own(value.options, "headers") &&
|
||||||
|
(!isPlainRecord(value.options.headers) ||
|
||||||
|
Object.values(value.options.headers).some((item) => typeof item !== "string"))
|
||||||
|
const bodyInvalid = own(value.options, "body") && !isPlainRecord(value.options.body)
|
||||||
|
if (headersInvalid) invalid([...path, "options", "headers"], diagnostics)
|
||||||
|
if (bodyInvalid) invalid([...path, "options", "body"], diagnostics)
|
||||||
|
return headersInvalid || bodyInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnoseProviderUnsupported(value: unknown, path: string[], diagnostics: Diagnostic[]) {
|
||||||
|
if (!isRecord(value)) return
|
||||||
|
unsupportedProvider.forEach((key) => unsupportedIfPresent(value, key, [...path, key], diagnostics))
|
||||||
|
if (!isRecord(value.models)) return
|
||||||
|
Object.entries(value.models).forEach(([name, model]) => {
|
||||||
|
if (!isRecord(model)) return
|
||||||
|
unsupportedModel.forEach((key) => unsupportedIfPresent(model, key, [...path, "models", name, key], diagnostics))
|
||||||
|
if (own(model, "status") && model.status !== "deprecated")
|
||||||
|
unsupportedIfPresent(model, "status", [...path, "models", name, "status"], diagnostics)
|
||||||
|
if (own(model, "interleaved") && typeof model.interleaved === "boolean")
|
||||||
|
unsupportedIfPresent(model, "interleaved", [...path, "models", name, "interleaved"], diagnostics)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnoseAgentUnsupported(value: unknown, path: string[], diagnostics: Diagnostic[]) {
|
||||||
|
if (!isRecord(value)) return
|
||||||
|
Object.entries(value).forEach(([name, agent]) => {
|
||||||
|
if (!isRecord(agent)) return
|
||||||
|
unsupportedIfPresent(agent, "name", [...path, name, "name"], diagnostics)
|
||||||
|
diagnoseSelection(agent, [...path, name], diagnostics)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnoseSelectionMap(value: unknown, path: string[], diagnostics: Diagnostic[]) {
|
||||||
|
if (!isRecord(value)) return
|
||||||
|
Object.entries(value).forEach(([name, entry]) => {
|
||||||
|
if (isRecord(entry)) diagnoseSelection(entry, [...path, name], diagnostics)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnoseSelection(value: Record<string, unknown>, path: string[], diagnostics: Diagnostic[]) {
|
||||||
|
const modelValid = typeof value.model === "string" && /^[^/#]+\/[^#]+$/.test(value.model)
|
||||||
|
if (own(value, "model") && typeof value.model === "string" && !modelValid)
|
||||||
|
diagnostics.push({
|
||||||
|
kind: "unsupported",
|
||||||
|
path: [...path, "model"],
|
||||||
|
message: "omitted unsupported legacy model reference",
|
||||||
|
})
|
||||||
|
if (
|
||||||
|
own(value, "variant") &&
|
||||||
|
typeof value.variant === "string" &&
|
||||||
|
(!modelValid || value.variant.length === 0 || value.variant.includes("#"))
|
||||||
|
)
|
||||||
|
diagnostics.push({
|
||||||
|
kind: "unsupported",
|
||||||
|
path: [...path, "variant"],
|
||||||
|
message: "omitted unsupported legacy model variant",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeProviderList(
|
||||||
|
input: Record<string, unknown>,
|
||||||
|
key: "enabled_providers" | "disabled_providers",
|
||||||
|
diagnostics: Diagnostic[],
|
||||||
|
) {
|
||||||
|
if (!own(input, key)) return { present: false, nonEmpty: false, values: [] as string[] }
|
||||||
|
if (!Array.isArray(input[key])) {
|
||||||
|
invalid([key], diagnostics)
|
||||||
|
return { present: true, nonEmpty: true, values: [] as string[] }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
present: true,
|
||||||
|
nonEmpty: input[key].length > 0,
|
||||||
|
values: decodeList(input[key], Schema.String, [key], diagnostics),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeEncodedMap<S extends Schema.Codec<unknown, unknown, never, never>>(
|
||||||
|
value: unknown,
|
||||||
|
schema: S,
|
||||||
|
path: string[],
|
||||||
|
diagnostics: Diagnostic[],
|
||||||
|
) {
|
||||||
|
if (value === undefined) return {}
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
invalid(path, diagnostics)
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value).flatMap(([name, raw]) => {
|
||||||
|
const decoded = decodeEncoded(schema, raw, [...path, name], diagnostics)
|
||||||
|
return decoded === undefined ? [] : [[name, decoded]]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeMap<S extends Schema.Codec<unknown, unknown, never, never>>(
|
||||||
|
value: unknown,
|
||||||
|
schema: S,
|
||||||
|
path: string[],
|
||||||
|
diagnostics: Diagnostic[],
|
||||||
|
) {
|
||||||
|
if (value === undefined) return {} as Record<string, S["Type"]>
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
invalid(path, diagnostics)
|
||||||
|
return {} as Record<string, S["Type"]>
|
||||||
|
}
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value).flatMap(([name, raw]) => {
|
||||||
|
const decoded = decodeValue(schema, raw, [...path, name], diagnostics)
|
||||||
|
return decoded === undefined ? [] : [[name, decoded]]
|
||||||
|
}),
|
||||||
|
) as Record<string, S["Type"]>
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeEncodedList<S extends Schema.Codec<unknown, unknown, never, never>>(
|
||||||
|
value: unknown,
|
||||||
|
schema: S,
|
||||||
|
path: string[],
|
||||||
|
diagnostics: Diagnostic[],
|
||||||
|
) {
|
||||||
|
if (value === undefined) return [] as S["Encoded"][]
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
invalid(path, diagnostics)
|
||||||
|
return [] as S["Encoded"][]
|
||||||
|
}
|
||||||
|
return value.flatMap((item, index) => {
|
||||||
|
const decoded = decodeEncoded(schema, item, [...path, String(index)], diagnostics)
|
||||||
|
return decoded === undefined ? [] : [decoded]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeList<S extends Schema.Codec<unknown, unknown, never, never>>(
|
||||||
|
value: unknown,
|
||||||
|
schema: S,
|
||||||
|
path: string[],
|
||||||
|
diagnostics: Diagnostic[],
|
||||||
|
) {
|
||||||
|
if (value === undefined) return [] as S["Type"][]
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
invalid(path, diagnostics)
|
||||||
|
return [] as S["Type"][]
|
||||||
|
}
|
||||||
|
return value.flatMap((item, index) => {
|
||||||
|
const decoded = decodeValue(schema, item, [...path, String(index)], diagnostics)
|
||||||
|
return decoded === undefined ? [] : [decoded]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeValue<S extends Schema.Codec<unknown, unknown, never, never>>(
|
||||||
|
schema: S,
|
||||||
|
value: unknown,
|
||||||
|
path: string[],
|
||||||
|
diagnostics: Diagnostic[],
|
||||||
|
) {
|
||||||
|
const decoded = Schema.decodeUnknownOption(schema, options)(value)
|
||||||
|
if (Option.isSome(decoded)) return decoded.value
|
||||||
|
invalid(path, diagnostics)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeEncoded<S extends Schema.Codec<unknown, unknown, never, never>>(
|
||||||
|
schema: S,
|
||||||
|
value: unknown,
|
||||||
|
path: string[],
|
||||||
|
diagnostics: Diagnostic[],
|
||||||
|
) {
|
||||||
|
const decoded = Schema.decodeUnknownOption(schema, options)(value)
|
||||||
|
if (Option.isNone(decoded)) {
|
||||||
|
invalid(path, diagnostics)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const encoded = Schema.encodeUnknownOption(schema, options)(decoded.value)
|
||||||
|
if (Option.isSome(encoded)) return plain(encoded.value)
|
||||||
|
invalid(path, diagnostics)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonical<S extends Schema.Codec<unknown, unknown, never, never>>(schema: S, value: unknown) {
|
||||||
|
return plain(
|
||||||
|
Option.getOrThrow(
|
||||||
|
Schema.decodeUnknownOption(
|
||||||
|
schema,
|
||||||
|
options,
|
||||||
|
)(plain(value)).pipe(Option.flatMap((decoded) => Schema.encodeUnknownOption(schema, options)(decoded))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function plain(value: unknown): unknown {
|
||||||
|
if (Array.isArray(value)) return value.map(plain)
|
||||||
|
if (!isRecord(value)) return value
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value).flatMap(([key, item]) => (item === undefined ? [] : [[key, plain(item)]])),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeMap(
|
||||||
|
target: Record<string, unknown>,
|
||||||
|
key: string,
|
||||||
|
legacy: Readonly<Record<string, unknown>>,
|
||||||
|
native: Readonly<Record<string, unknown>>,
|
||||||
|
present: boolean,
|
||||||
|
diagnostics: Diagnostic[],
|
||||||
|
) {
|
||||||
|
const merged = mergeMaps(legacy, native, [key], diagnostics)
|
||||||
|
if (present) target[key] = merged
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeMaps(
|
||||||
|
legacy: Readonly<Record<string, unknown>>,
|
||||||
|
native: Readonly<Record<string, unknown>>,
|
||||||
|
path: string[],
|
||||||
|
diagnostics: Diagnostic[],
|
||||||
|
) {
|
||||||
|
const result = Object.fromEntries(Object.entries(legacy))
|
||||||
|
Object.entries(native).forEach(([name, value]) => {
|
||||||
|
if (own(result, name) && !isDeepStrictEqual(result[name], value)) conflict([...path, name], diagnostics)
|
||||||
|
setOwn(result, name, value)
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapValues<A>(input: Readonly<Record<string, A>>, map: (value: A) => unknown) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(input).flatMap(([key, value]) => {
|
||||||
|
const mapped = map(value)
|
||||||
|
return mapped === undefined ? [] : [[key, mapped]]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlay(
|
||||||
|
target: Record<string, unknown>,
|
||||||
|
key: string,
|
||||||
|
value: unknown,
|
||||||
|
path: string[],
|
||||||
|
diagnostics: Diagnostic[],
|
||||||
|
) {
|
||||||
|
if (own(target, key) && !isDeepStrictEqual(target[key], value)) conflict(path, diagnostics)
|
||||||
|
target[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefer(legacy: unknown, native: unknown, path: string[], diagnostics: Diagnostic[]) {
|
||||||
|
if (native === undefined) return legacy
|
||||||
|
if (legacy !== undefined && !isDeepStrictEqual(legacy, native)) conflict(path, diagnostics)
|
||||||
|
return native
|
||||||
|
}
|
||||||
|
|
||||||
|
function unsupportedIfPresent(value: Record<string, unknown>, key: string, path: string[], diagnostics: Diagnostic[]) {
|
||||||
|
if (!own(value, key)) return
|
||||||
|
diagnostics.push({ kind: "unsupported", path, message: "omitted unsupported legacy setting" })
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalid(path: string[], diagnostics: Diagnostic[]) {
|
||||||
|
diagnostics.push({ kind: "invalid", path, message: "skipped malformed recognized value" })
|
||||||
|
}
|
||||||
|
|
||||||
|
function conflict(path: string[], diagnostics: Diagnostic[]) {
|
||||||
|
diagnostics.push({ kind: "conflict", path, message: "retained native value over legacy value" })
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDirectLegacyMcp(value: unknown) {
|
||||||
|
return isRecord(value) && (value.type === "local" || value.type === "remote")
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEnabledOnlyMcp(value: unknown) {
|
||||||
|
return isRecord(value) && !own(value, "type") && typeof value.enabled === "boolean"
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
if (!isRecord(value)) return false
|
||||||
|
const prototype = Object.getPrototypeOf(value)
|
||||||
|
return prototype === Object.prototype || prototype === null
|
||||||
|
}
|
||||||
|
|
||||||
|
function own(value: Record<string, unknown>, key: string) {
|
||||||
|
return Object.prototype.hasOwnProperty.call(value, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOwn(value: Record<string, unknown>, key: string, item: unknown) {
|
||||||
|
Object.defineProperty(value, key, { value: item, enumerable: true, configurable: true, writable: true })
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
export * as ConfigAgentPlugin from "./agent"
|
export * as ConfigAgentPlugin from "./agent"
|
||||||
|
|
||||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||||
|
import { Document, Info, type Entry } from "@opencode-ai/schema/config"
|
||||||
|
import { ConfigAgent } from "@opencode-ai/schema/config/agent"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Effect, Option, Schema, Stream } from "effect"
|
import { Effect, Option, Schema, Stream } from "effect"
|
||||||
import { Agent } from "../../agent"
|
import { Agent } from "../../agent"
|
||||||
import { Config } from "../../config"
|
import { Config } from "../../config"
|
||||||
import { ConfigAgent } from "../agent"
|
|
||||||
import { ConfigMarkdown } from "../markdown"
|
import { ConfigMarkdown } from "../markdown"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
import { ConfigAgentV1 } from "../../v1/config/agent"
|
import { ConfigAgentV1 } from "../../v1/config/agent"
|
||||||
@@ -24,7 +25,7 @@ const legacySources = [
|
|||||||
const sourceDirectories = ["agent", "agents", "mode", "modes"] as const
|
const sourceDirectories = ["agent", "agents", "mode", "modes"] as const
|
||||||
const decodeAgent = Schema.decodeUnknownOption(ConfigAgent.Info)
|
const decodeAgent = Schema.decodeUnknownOption(ConfigAgent.Info)
|
||||||
const decodeLegacyAgent = Schema.decodeUnknownOption(ConfigAgentV1.Info)
|
const decodeLegacyAgent = Schema.decodeUnknownOption(ConfigAgentV1.Info)
|
||||||
const decodeConfig = Schema.decodeUnknownOption(Config.Info)
|
const decodeConfig = Schema.decodeUnknownOption(Info)
|
||||||
type PathAction =
|
type PathAction =
|
||||||
| LocationMutation.ExternalDirectoryAuthorization["action"]
|
| LocationMutation.ExternalDirectoryAuthorization["action"]
|
||||||
| typeof ReadTool.name
|
| typeof ReadTool.name
|
||||||
@@ -63,13 +64,13 @@ export const Plugin = define({
|
|||||||
),
|
),
|
||||||
).pipe(
|
).pipe(
|
||||||
Effect.map((documents) =>
|
Effect.map((documents) =>
|
||||||
documents.filter((document): document is Config.Document => document !== undefined),
|
documents.filter((document): document is Document => document !== undefined),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}).pipe(Effect.map((documents) => documents.flat()))
|
}).pipe(Effect.map((documents) => documents.flat()))
|
||||||
})
|
})
|
||||||
const loaded = { documents: [] as Config.Document[] }
|
const loaded = { documents: [] as Document[] }
|
||||||
const reload = load().pipe(
|
const reload = load().pipe(
|
||||||
Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))),
|
Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))),
|
||||||
Effect.andThen(ctx.agent.reload()),
|
Effect.andThen(ctx.agent.reload()),
|
||||||
@@ -139,7 +140,7 @@ export const Plugin = define({
|
|||||||
|
|
||||||
// Matches anything at or under <root>/{agent,agents,mode,modes}. No file-suffix
|
// Matches anything at or under <root>/{agent,agents,mode,modes}. No file-suffix
|
||||||
// check: directory-level events such as renames carry no per-file paths.
|
// check: directory-level events such as renames carry no per-file paths.
|
||||||
function isAgentSource(entries: Config.Entry[], file: string) {
|
function isAgentSource(entries: Entry[], file: string) {
|
||||||
return entries.some(
|
return entries.some(
|
||||||
(entry) =>
|
(entry) =>
|
||||||
entry.type === "directory" &&
|
entry.type === "directory" &&
|
||||||
@@ -160,11 +161,14 @@ function isPathAction(action: string): action is PathAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function expandHome(resource: string, home: string) {
|
function expandHome(resource: string, home: string) {
|
||||||
if (resource.startsWith("~/")) return home + resource.slice(1)
|
|
||||||
if (resource === "~") return home
|
if (resource === "~") return home
|
||||||
if (resource === "$HOME") return home
|
if (resource === "$HOME") return home
|
||||||
if (resource.startsWith("$HOME/")) return home + resource.slice(5)
|
const relative = resource.startsWith("~/")
|
||||||
if (resource.startsWith("$HOME\\")) return home + resource.slice(5)
|
? resource.slice(2)
|
||||||
|
: resource.startsWith("$HOME/") || resource.startsWith("$HOME\\")
|
||||||
|
? resource.slice(6)
|
||||||
|
: undefined
|
||||||
|
if (relative !== undefined) return (path.posix.isAbsolute(home) ? path.posix : path.win32).join(home, relative)
|
||||||
return resource
|
return resource
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,5 +212,5 @@ function decode(file: { directory: string; filepath: string; primary: boolean },
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if (!info) return
|
if (!info) return
|
||||||
return new Config.Document({ type: "document", path: file.filepath, info })
|
return new Document({ type: "document", path: file.filepath, info })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
export * as ConfigCommandPlugin from "./command"
|
export * as ConfigCommandPlugin from "./command"
|
||||||
|
|
||||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||||
|
import { Info, type Entry } from "@opencode-ai/schema/config"
|
||||||
|
import { ConfigCommand } from "@opencode-ai/schema/config/command"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Effect, Option, Schema, Stream } from "effect"
|
import { Effect, Option, Schema, Stream } from "effect"
|
||||||
import { Command } from "../../command"
|
import { Command } from "../../command"
|
||||||
import { Config } from "../../config"
|
import { Config } from "../../config"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
import { ConfigCommand } from "../command"
|
|
||||||
import { ConfigMarkdown } from "../markdown"
|
import { ConfigMarkdown } from "../markdown"
|
||||||
|
|
||||||
const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info)
|
const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info)
|
||||||
@@ -27,7 +28,7 @@ export const Plugin = define({
|
|||||||
)
|
)
|
||||||
}).pipe(Effect.map((documents) => documents.flat()))
|
}).pipe(Effect.map((documents) => documents.flat()))
|
||||||
})
|
})
|
||||||
const loaded = { documents: [] as { commands: Config.Info["commands"] }[] }
|
const loaded = { documents: [] as { commands: Info["commands"] }[] }
|
||||||
const reload = load().pipe(
|
const reload = load().pipe(
|
||||||
Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))),
|
Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))),
|
||||||
Effect.andThen(ctx.command.reload()),
|
Effect.andThen(ctx.command.reload()),
|
||||||
@@ -75,7 +76,7 @@ const sourceDirectories = ["command", "commands"] as const
|
|||||||
|
|
||||||
// Matches anything at or under <root>/{command,commands}. No file-suffix check:
|
// Matches anything at or under <root>/{command,commands}. No file-suffix check:
|
||||||
// directory-level events such as renames carry no per-file paths.
|
// directory-level events such as renames carry no per-file paths.
|
||||||
function isCommandSource(entries: Config.Entry[], file: string) {
|
function isCommandSource(entries: Entry[], file: string) {
|
||||||
return entries.some(
|
return entries.some(
|
||||||
(entry) =>
|
(entry) =>
|
||||||
entry.type === "directory" &&
|
entry.type === "directory" &&
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export * as ConfigPolicyPlugin from "./policy"
|
export * as ConfigPolicyPlugin from "./policy"
|
||||||
|
|
||||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||||
|
import { Document } from "@opencode-ai/schema/config"
|
||||||
import { Effect, Stream } from "effect"
|
import { Effect, Stream } from "effect"
|
||||||
import { Config } from "../../config"
|
import { Config } from "../../config"
|
||||||
import { Wildcard } from "../../util/wildcard"
|
import { Wildcard } from "../../util/wildcard"
|
||||||
@@ -13,7 +14,7 @@ export const Plugin = define({
|
|||||||
yield* ctx.catalog.transform((catalog) => {
|
yield* ctx.catalog.transform((catalog) => {
|
||||||
// User-global policy takes priority over policy authored by a repository.
|
// User-global policy takes priority over policy authored by a repository.
|
||||||
const policies = loaded.entries
|
const policies = loaded.entries
|
||||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
.filter((entry): entry is Document => entry.type === "document")
|
||||||
.toReversed()
|
.toReversed()
|
||||||
.flatMap((entry) => entry.info.experimental?.policies ?? [])
|
.flatMap((entry) => entry.info.experimental?.policies ?? [])
|
||||||
for (const record of catalog.provider.list()) {
|
for (const record of catalog.provider.list()) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export * as ConfigProviderPlugin from "./provider"
|
export * as ConfigProviderPlugin from "./provider"
|
||||||
|
|
||||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||||
|
import { Document, type Entry } from "@opencode-ai/schema/config"
|
||||||
import { Money } from "@opencode-ai/schema/money"
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
import { Effect, Stream } from "effect"
|
import { Effect, Stream } from "effect"
|
||||||
import { Config } from "../../config"
|
import { Config } from "../../config"
|
||||||
@@ -107,8 +108,8 @@ export const Plugin = define({
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
function configuredProviders(entries: readonly Config.Entry[]) {
|
function configuredProviders(entries: readonly Entry[]) {
|
||||||
return entries
|
return entries
|
||||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
.filter((entry): entry is Document => entry.type === "document")
|
||||||
.flatMap((file) => Object.entries(file.info.providers ?? {}))
|
.flatMap((file) => Object.entries(file.info.providers ?? {}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
export * as ConfigReferencePlugin from "./reference"
|
export * as ConfigReferencePlugin from "./reference"
|
||||||
|
|
||||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||||
|
import { Document } from "@opencode-ai/schema/config"
|
||||||
|
import { ConfigReference } from "@opencode-ai/schema/config/reference"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Effect, Stream } from "effect"
|
import { Effect, Stream } from "effect"
|
||||||
import { Config } from "../../config"
|
import { Config } from "../../config"
|
||||||
import { ConfigReference } from "../reference"
|
|
||||||
import { Reference } from "../../reference"
|
import { Reference } from "../../reference"
|
||||||
import { AbsolutePath } from "../../schema"
|
import { AbsolutePath } from "../../schema"
|
||||||
import { Global } from "@opencode-ai/util/global"
|
import { Global } from "@opencode-ai/util/global"
|
||||||
@@ -19,7 +20,7 @@ export const Plugin = define({
|
|||||||
const loaded = { entries: yield* config.entries() }
|
const loaded = { entries: yield* config.entries() }
|
||||||
yield* ctx.reference.transform((draft) => {
|
yield* ctx.reference.transform((draft) => {
|
||||||
const entries = new Map<string, Reference.Source>()
|
const entries = new Map<string, Reference.Source>()
|
||||||
for (const doc of loaded.entries.filter((entry): entry is Config.Document => entry.type === "document")) {
|
for (const doc of loaded.entries.filter((entry): entry is Document => entry.type === "document")) {
|
||||||
const directory = doc.path ? path.dirname(doc.path) : location.directory
|
const directory = doc.path ? path.dirname(doc.path) : location.directory
|
||||||
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
|
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
|
||||||
if (!validAlias(name)) continue
|
if (!validAlias(name)) continue
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
export * as ConfigProvider from "./provider"
|
|
||||||
|
|
||||||
import { Schema } from "effect"
|
|
||||||
import { Money } from "@opencode-ai/schema/money"
|
|
||||||
import { Capabilities, Compatibility, Family, ID, VariantID } from "../model"
|
|
||||||
|
|
||||||
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
|
|
||||||
|
|
||||||
export const Overlays = {
|
|
||||||
settings: JsonRecord.pipe(Schema.optional),
|
|
||||||
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
|
||||||
body: JsonRecord.pipe(Schema.optional),
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Request extends Schema.Class<Request>("Config.Provider.Request")({
|
|
||||||
headers: Overlays.headers,
|
|
||||||
body: Overlays.body,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
class Cache extends Schema.Class<Cache>("Config.Model.Cost.Cache")({
|
|
||||||
read: Money.USDPerMillionTokens.pipe(Schema.optional),
|
|
||||||
write: Money.USDPerMillionTokens.pipe(Schema.optional),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
class Cost extends Schema.Class<Cost>("Config.Model.Cost")({
|
|
||||||
tier: Schema.Struct({
|
|
||||||
type: Schema.Literal("context"),
|
|
||||||
size: Schema.Int,
|
|
||||||
}).pipe(Schema.optional),
|
|
||||||
input: Money.USDPerMillionTokens,
|
|
||||||
output: Money.USDPerMillionTokens,
|
|
||||||
cache: Cache.pipe(Schema.optional),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
class Limit extends Schema.Class<Limit>("Config.Model.Limit")({
|
|
||||||
context: Schema.Int.pipe(Schema.optional),
|
|
||||||
input: Schema.Int.pipe(Schema.optional),
|
|
||||||
output: Schema.Int.pipe(Schema.optional),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
class Model extends Schema.Class<Model>("Config.Model")({
|
|
||||||
modelID: ID.pipe(Schema.optional),
|
|
||||||
family: Family.pipe(Schema.optional),
|
|
||||||
name: Schema.String.pipe(Schema.optional),
|
|
||||||
compatibility: Compatibility.pipe(Schema.optional),
|
|
||||||
package: Schema.String.pipe(Schema.optional),
|
|
||||||
...Overlays,
|
|
||||||
capabilities: Capabilities.pipe(Schema.optional),
|
|
||||||
variants: Schema.Struct({
|
|
||||||
id: VariantID,
|
|
||||||
...Overlays,
|
|
||||||
}).pipe(Schema.Array, Schema.optional),
|
|
||||||
cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(Schema.optional),
|
|
||||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
|
||||||
limit: Limit.pipe(Schema.optional),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class Info extends Schema.Class<Info>("Config.Provider")({
|
|
||||||
name: Schema.String.pipe(Schema.optional),
|
|
||||||
env: Schema.String.pipe(Schema.Array, Schema.optional),
|
|
||||||
package: Schema.String.pipe(Schema.optional),
|
|
||||||
...Overlays,
|
|
||||||
models: Schema.Record(Schema.String, Model).pipe(Schema.optional),
|
|
||||||
}) {}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export * as ConfigToolOutput from "./tool-output"
|
|
||||||
|
|
||||||
import { Schema } from "effect"
|
|
||||||
import { PositiveInt } from "../schema"
|
|
||||||
|
|
||||||
export class Info extends Schema.Class<Info>("Config.ToolOutput")({
|
|
||||||
max_lines: PositiveInt.pipe(Schema.optional),
|
|
||||||
max_bytes: PositiveInt.pipe(Schema.optional),
|
|
||||||
}) {}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export * as ConfigWatcher from "./watcher"
|
|
||||||
|
|
||||||
import { Schema } from "effect"
|
|
||||||
|
|
||||||
export class Info extends Schema.Class<Info>("Config.Watcher")({
|
|
||||||
ignore: Schema.String.pipe(Schema.Array, Schema.optional),
|
|
||||||
}) {}
|
|
||||||
@@ -7,7 +7,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
|||||||
import { Bom } from "@opencode-ai/util/bom"
|
import { Bom } from "@opencode-ai/util/bom"
|
||||||
|
|
||||||
export interface Target {
|
export interface Target {
|
||||||
readonly canonical: string
|
readonly absolute: string
|
||||||
readonly resource: string
|
readonly resource: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ export interface Interface {
|
|||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serialize file changes by canonical target. Conditional writes compare and
|
* Serialize file changes by absolute target. Conditional writes compare and
|
||||||
* write under the same process-local lock so cooperating OpenCode mutations do
|
* write under the same process-local lock so cooperating OpenCode mutations do
|
||||||
* not overwrite changes made from the same stale content.
|
* not overwrite changes made from the same stale content.
|
||||||
*/
|
*/
|
||||||
@@ -49,11 +49,11 @@ const layer = Layer.effect(
|
|||||||
const withTargetLock =
|
const withTargetLock =
|
||||||
(target: Target) =>
|
(target: Target) =>
|
||||||
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||||
locks.withLock(target.canonical)(Effect.uninterruptible(effect))
|
locks.withLock(target.absolute)(Effect.uninterruptible(effect))
|
||||||
|
|
||||||
const writeResult = (target: Target, existed: boolean): WriteResult => ({
|
const writeResult = (target: Target, existed: boolean): WriteResult => ({
|
||||||
operation: "write",
|
operation: "write",
|
||||||
target: target.canonical,
|
target: target.absolute,
|
||||||
resource: target.resource,
|
resource: target.resource,
|
||||||
existed,
|
existed,
|
||||||
})
|
})
|
||||||
@@ -61,8 +61,8 @@ const layer = Layer.effect(
|
|||||||
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
|
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
|
||||||
withTargetLock(input.target)(
|
withTargetLock(input.target)(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const existed = yield* fs.exists(input.target.canonical)
|
const existed = yield* fs.exists(input.target.absolute)
|
||||||
yield* fs.writeWithDirs(input.target.canonical, input.content)
|
yield* fs.writeWithDirs(input.target.absolute, input.content)
|
||||||
return writeResult(input.target, existed)
|
return writeResult(input.target, existed)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -73,10 +73,10 @@ const layer = Layer.effect(
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const next = Bom.split(input.content)
|
const next = Bom.split(input.content)
|
||||||
const current = yield* fs
|
const current = yield* fs
|
||||||
.readFile(input.target.canonical)
|
.readFile(input.target.absolute)
|
||||||
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
||||||
yield* fs.writeWithDirs(
|
yield* fs.writeWithDirs(
|
||||||
input.target.canonical,
|
input.target.absolute,
|
||||||
Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom),
|
Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom),
|
||||||
)
|
)
|
||||||
return writeResult(input.target, current !== undefined)
|
return writeResult(input.target, current !== undefined)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export * as LocationWatcher from "./location-watcher"
|
|||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { Context, Effect, Layer, Stream } from "effect"
|
import { Context, Effect, Layer, Stream } from "effect"
|
||||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||||
|
import { Document } from "@opencode-ai/schema/config"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Config } from "../config"
|
import { Config } from "../config"
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
@@ -41,7 +42,7 @@ const layer = Layer.effect(
|
|||||||
|
|
||||||
yield* Effect.gen(function* () {
|
yield* Effect.gen(function* () {
|
||||||
const config = (yield* configService.entries())
|
const config = (yield* configService.entries())
|
||||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
.filter((entry): entry is Document => entry.type === "document")
|
||||||
.flatMap((item) => item.info.watcher?.ignore ?? [])
|
.flatMap((item) => item.info.watcher?.ignore ?? [])
|
||||||
const home = Protected.isHome(location.directory)
|
const home = Protected.isHome(location.directory)
|
||||||
|
|
||||||
|
|||||||
@@ -23,14 +23,9 @@ export const ResolveInput = Schema.Struct({
|
|||||||
})
|
})
|
||||||
export type ResolveInput = typeof ResolveInput.Type
|
export type ResolveInput = typeof ResolveInput.Type
|
||||||
|
|
||||||
export class PathError extends Schema.TaggedErrorClass<PathError>()("LocationMutation.PathError", {
|
|
||||||
path: Schema.String,
|
|
||||||
reason: Schema.Literal("non_directory_ancestor"),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export interface ExternalDirectoryAuthorization {
|
export interface ExternalDirectoryAuthorization {
|
||||||
readonly action: "external_directory"
|
readonly action: "external_directory"
|
||||||
/** Canonical existing directory used as the external approval boundary. */
|
/** Lexical directory used as the external approval boundary. */
|
||||||
readonly directory: string
|
readonly directory: string
|
||||||
/** `external_directory` permission resource. */
|
/** `external_directory` permission resource. */
|
||||||
readonly resource: string
|
readonly resource: string
|
||||||
@@ -44,9 +39,9 @@ export const externalDirectoryPermission = (input: ExternalDirectoryAuthorizatio
|
|||||||
})
|
})
|
||||||
|
|
||||||
export interface Target {
|
export interface Target {
|
||||||
/** Canonical existing path, or missing path below a canonical directory. */
|
/** Absolute lexical path. */
|
||||||
readonly canonical: string
|
readonly absolute: string
|
||||||
/** Permission resource: Location-relative for internal paths, canonical for external paths. */
|
/** Permission resource: Location-relative for internal paths, absolute for external paths. */
|
||||||
readonly resource: string
|
readonly resource: string
|
||||||
readonly externalDirectory?: ExternalDirectoryAuthorization
|
readonly externalDirectory?: ExternalDirectoryAuthorization
|
||||||
}
|
}
|
||||||
@@ -57,25 +52,11 @@ export interface Interface {
|
|||||||
* from the Location. Paths outside it require separate `external_directory`
|
* from the Location. Paths outside it require separate `external_directory`
|
||||||
* approval. This does not approve the mutation.
|
* approval. This does not approve the mutation.
|
||||||
*/
|
*/
|
||||||
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, PathError | FSUtil.Error>
|
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, FSUtil.Error>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationMutation") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationMutation") {}
|
||||||
|
|
||||||
interface ResolvedPath {
|
|
||||||
readonly canonical: string
|
|
||||||
readonly type?:
|
|
||||||
| "File"
|
|
||||||
| "Directory"
|
|
||||||
| "SymbolicLink"
|
|
||||||
| "BlockDevice"
|
|
||||||
| "CharacterDevice"
|
|
||||||
| "FIFO"
|
|
||||||
| "Socket"
|
|
||||||
| "Unknown"
|
|
||||||
readonly directory: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const slash = (value: string) => value.replaceAll("\\", "/")
|
const slash = (value: string) => value.replaceAll("\\", "/")
|
||||||
|
|
||||||
const layer = Layer.effect(
|
const layer = Layer.effect(
|
||||||
@@ -84,65 +65,33 @@ const layer = Layer.effect(
|
|||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
|
|
||||||
function notFound<A>(effect: Effect.Effect<A, FSUtil.Error>) {
|
|
||||||
return effect.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvePath = Effect.fnUntraced(function* (absolute: string) {
|
|
||||||
const existing = yield* notFound(fs.realPath(absolute))
|
|
||||||
if (existing !== undefined) {
|
|
||||||
const info = yield* fs.stat(existing)
|
|
||||||
return {
|
|
||||||
canonical: existing,
|
|
||||||
type: info.type,
|
|
||||||
directory: info.type === "Directory" ? existing : path.dirname(existing),
|
|
||||||
} satisfies ResolvedPath
|
|
||||||
}
|
|
||||||
|
|
||||||
let anchor = path.dirname(absolute)
|
|
||||||
while (true) {
|
|
||||||
const canonical = yield* notFound(fs.realPath(anchor))
|
|
||||||
if (canonical !== undefined) {
|
|
||||||
const info = yield* fs.stat(canonical)
|
|
||||||
if (info.type !== "Directory") {
|
|
||||||
return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
canonical: path.resolve(canonical, path.relative(anchor, absolute)),
|
|
||||||
directory: canonical,
|
|
||||||
} satisfies ResolvedPath
|
|
||||||
}
|
|
||||||
const parent = path.dirname(anchor)
|
|
||||||
if (parent === anchor) return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
|
|
||||||
anchor = parent
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
|
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
|
||||||
const absolute = path.resolve(location.directory, input.path)
|
const absolute = path.resolve(location.directory, input.path)
|
||||||
// External access follows the requested path boundary. Symlinks reached through an
|
if (FSUtil.contains(location.directory, absolute)) {
|
||||||
// internal path intentionally retain internal permission semantics after canonicalization.
|
return {
|
||||||
const lexicallyInternal = FSUtil.contains(location.directory, absolute)
|
absolute,
|
||||||
|
resource: slash(path.relative(location.directory, absolute) || "."),
|
||||||
const resolved = yield* resolvePath(absolute)
|
} satisfies Target
|
||||||
const external = !lexicallyInternal
|
}
|
||||||
const resource = external ? slash(resolved.canonical) : slash(path.relative(location.directory, absolute) || ".")
|
const type =
|
||||||
const externalDirectory =
|
input.kind === "directory"
|
||||||
input.kind === "directory" && resolved.type === "Directory" ? resolved.canonical : resolved.directory
|
? "Directory"
|
||||||
|
: (yield* fs
|
||||||
|
.stat(absolute)
|
||||||
|
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))))?.type
|
||||||
|
const externalDirectory = type === "Directory" ? absolute : path.dirname(absolute)
|
||||||
const externalResource = slash(path.join(externalDirectory, "*"))
|
const externalResource = slash(path.join(externalDirectory, "*"))
|
||||||
return {
|
return {
|
||||||
canonical: resolved.canonical,
|
absolute,
|
||||||
resource,
|
resource: slash(absolute),
|
||||||
externalDirectory: external
|
externalDirectory: {
|
||||||
? {
|
|
||||||
action: "external_directory",
|
action: "external_directory",
|
||||||
directory: externalDirectory,
|
directory: externalDirectory,
|
||||||
resource: externalResource,
|
resource: externalResource,
|
||||||
save: slash(
|
save: slash(
|
||||||
path.join((yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory, "*"),
|
path.join((yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory, "*"),
|
||||||
),
|
),
|
||||||
}
|
},
|
||||||
: undefined,
|
|
||||||
} satisfies Target
|
} satisfies Target
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import { SessionGenerateNode } from "./session/generate-node"
|
|||||||
import { McpTool } from "./tool/mcp"
|
import { McpTool } from "./tool/mcp"
|
||||||
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
|
import { ToolOutput } from "./tool-output"
|
||||||
import { Vcs } from "./vcs"
|
import { Vcs } from "./vcs"
|
||||||
|
|
||||||
export { LocationServiceMap } from "./location-service-map"
|
export { LocationServiceMap } from "./location-service-map"
|
||||||
@@ -78,6 +79,7 @@ const locationServiceNodes = [
|
|||||||
MCP.node,
|
MCP.node,
|
||||||
Permission.node,
|
Permission.node,
|
||||||
Tool.node,
|
Tool.node,
|
||||||
|
ToolOutput.node,
|
||||||
Image.node,
|
Image.node,
|
||||||
SkillInstructions.node,
|
SkillInstructions.node,
|
||||||
ReferenceInstructions.node,
|
ReferenceInstructions.node,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
ToolSchema,
|
ToolSchema,
|
||||||
} from "@modelcontextprotocol/sdk/types.js"
|
} from "@modelcontextprotocol/sdk/types.js"
|
||||||
import { Cause, Effect, Exit, Schema } from "effect"
|
import { Cause, Effect, Exit, Schema } from "effect"
|
||||||
import { ConfigMCP } from "../config/mcp"
|
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||||
|
|
||||||
const DEFAULT_STARTUP_TIMEOUT = 30_000
|
const DEFAULT_STARTUP_TIMEOUT = 30_000
|
||||||
const DEFAULT_CATALOG_TIMEOUT = 30_000
|
const DEFAULT_CATALOG_TIMEOUT = 30_000
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ export * as MCP from "./index"
|
|||||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||||
import { Command } from "@opencode-ai/schema/command"
|
import { Command } from "@opencode-ai/schema/command"
|
||||||
|
import { Document } from "@opencode-ai/schema/config"
|
||||||
|
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||||
import { createHash } from "node:crypto"
|
import { createHash } from "node:crypto"
|
||||||
import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Schema, Scope, Stream } from "effect"
|
import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Schema, Scope, Stream } from "effect"
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { Config } from "../config"
|
import { Config } from "../config"
|
||||||
import { ConfigMCP } from "../config/mcp"
|
|
||||||
import { Credential } from "../credential"
|
import { Credential } from "../credential"
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
import { Form } from "../form"
|
import { Form } from "../form"
|
||||||
@@ -178,7 +179,7 @@ export const layer = (options?: Options) => Layer.effect(
|
|||||||
const fork = yield* FiberSet.makeRuntime<never, void, never>()
|
const fork = yield* FiberSet.makeRuntime<never, void, never>()
|
||||||
yield* Effect.addFinalizer((exit) => Scope.close(root, exit))
|
yield* Effect.addFinalizer((exit) => Scope.close(root, exit))
|
||||||
|
|
||||||
const documents = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document")
|
const documents = (yield* config.entries()).filter((entry): entry is Document => entry.type === "document")
|
||||||
// Global MCP timeout defaults, later config files overriding earlier ones.
|
// Global MCP timeout defaults, later config files overriding earlier ones.
|
||||||
const timeout = Object.assign(
|
const timeout = Object.assign(
|
||||||
{},
|
{},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprot
|
|||||||
import { createServer } from "node:http"
|
import { createServer } from "node:http"
|
||||||
import { Deferred, Effect } from "effect"
|
import { Deferred, Effect } from "effect"
|
||||||
import { Credential } from "@opencode-ai/schema/credential"
|
import { Credential } from "@opencode-ai/schema/credential"
|
||||||
import { ConfigMCP } from "../config/mcp"
|
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||||
import { OauthCallbackPage } from "../oauth/page"
|
import { OauthCallbackPage } from "../oauth/page"
|
||||||
import type { Integration } from "../integration"
|
import type { Integration } from "../integration"
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ export * as PluginPromise from "./promise"
|
|||||||
|
|
||||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||||
import type { Context, Plugin } from "@opencode-ai/plugin/promise/plugin"
|
import type { Context, Plugin } from "@opencode-ai/plugin/promise/plugin"
|
||||||
import type { SessionHooks, SessionHttp, SessionHttpMiddleware } from "@opencode-ai/plugin/promise/session"
|
|
||||||
import type { Info } from "@opencode-ai/plugin/promise/tool"
|
import type { Info } from "@opencode-ai/plugin/promise/tool"
|
||||||
import { Agent } from "@opencode-ai/schema/agent"
|
import { Agent } from "@opencode-ai/schema/agent"
|
||||||
import { Integration } from "@opencode-ai/schema/integration"
|
import { Integration } from "@opencode-ai/schema/integration"
|
||||||
@@ -58,62 +57,6 @@ export function fromPromise(plugin: Plugin) {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
function sessionHook<Name extends keyof SessionHooks>(
|
|
||||||
name: Name,
|
|
||||||
callback: (event: SessionHooks[Name]) => Promise<void> | void,
|
|
||||||
): Promise<Registration>
|
|
||||||
function sessionHook(
|
|
||||||
...registration: {
|
|
||||||
[Name in keyof SessionHooks]: [
|
|
||||||
name: Name,
|
|
||||||
callback: (event: SessionHooks[Name]) => Promise<void> | void,
|
|
||||||
]
|
|
||||||
}[keyof SessionHooks]
|
|
||||||
) {
|
|
||||||
if (registration[0] !== "http")
|
|
||||||
return register(
|
|
||||||
host.session.hook(registration[0], (event) =>
|
|
||||||
Effect.promise(() => Promise.resolve(registration[1](event))),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return register(
|
|
||||||
host.session.hook("http", (event) => {
|
|
||||||
const middlewares: SessionHttpMiddleware[] = []
|
|
||||||
const output: SessionHttp = {
|
|
||||||
...event,
|
|
||||||
use: (item) => {
|
|
||||||
middlewares.push(item)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return Effect.promise(() => Promise.resolve(registration[1](output))).pipe(
|
|
||||||
Effect.flatMap(() =>
|
|
||||||
Effect.forEach(
|
|
||||||
middlewares,
|
|
||||||
(item) =>
|
|
||||||
event.use((input, next) =>
|
|
||||||
Effect.tryPromise({
|
|
||||||
try: (signal) => {
|
|
||||||
const inputSignal = AbortSignal.any([signal, input.signal])
|
|
||||||
return Promise.resolve(
|
|
||||||
item(new Request(input, { signal: inputSignal }), (request) => {
|
|
||||||
const requestSignal = AbortSignal.any([signal, request.signal])
|
|
||||||
return Effect.runPromiseWith(
|
|
||||||
context,
|
|
||||||
)(next(new Request(request, { signal: requestSignal })), { signal: requestSignal })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
{ discard: true },
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const context2: Context = {
|
const context2: Context = {
|
||||||
app: host.app,
|
app: host.app,
|
||||||
options: host.options,
|
options: host.options,
|
||||||
@@ -322,7 +265,8 @@ export function fromPromise(plugin: Plugin) {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
hook: sessionHook,
|
hook: (name, callback) =>
|
||||||
|
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||||
create: (input) =>
|
create: (input) =>
|
||||||
run(
|
run(
|
||||||
host.session.create(
|
host.session.create(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
|
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
|
||||||
|
import { shouldUseResponsesApi } from "@opencode-ai/ai/providers/github-copilot"
|
||||||
import { Effect, Option, Schema, Semaphore, Stream } from "effect"
|
import { Effect, Option, Schema, Semaphore, Stream } from "effect"
|
||||||
import { Catalog } from "../../catalog"
|
import { Catalog } from "../../catalog"
|
||||||
import { Credential } from "../../credential"
|
import { Credential } from "../../credential"
|
||||||
@@ -140,14 +141,6 @@ const oauth = (app: App.Info) => ({
|
|||||||
}),
|
}),
|
||||||
}) satisfies IntegrationOAuthMethodRegistration
|
}) satisfies IntegrationOAuthMethodRegistration
|
||||||
|
|
||||||
function shouldUseResponses(modelID: string) {
|
|
||||||
// Copilot supports Responses for GPT-5 class models, except mini variants
|
|
||||||
// which still need the chat-completions endpoint.
|
|
||||||
const match = /^gpt-(\d+)/.exec(modelID)
|
|
||||||
if (!match) return false
|
|
||||||
return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GithubCopilotPlugin = define({
|
export const GithubCopilotPlugin = define({
|
||||||
id: "opencode.provider.github-copilot",
|
id: "opencode.provider.github-copilot",
|
||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
@@ -269,7 +262,7 @@ export const GithubCopilotPlugin = define({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const id = evt.model.modelID ?? evt.model.id
|
const id = evt.model.modelID ?? evt.model.id
|
||||||
evt.language = shouldUseResponses(id) ? evt.sdk.responses(id) : evt.sdk.chat(id)
|
evt.language = shouldUseResponsesApi(id) ? evt.sdk.responses(id) : evt.sdk.chat(id)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -221,18 +221,18 @@ export const OpenAIPlugin = define({
|
|||||||
}
|
}
|
||||||
draft.cost = []
|
draft.cost = []
|
||||||
// Match Codex CLI so context consumption and subscription usage stay consistent between clients.
|
// Match Codex CLI so context consumption and subscription usage stay consistent between clients.
|
||||||
draft.limit = { ...draft.limit, context: 272_000, input: 272_000 }
|
draft.limit = { ...draft.limit, context: 400_000, input: 272_000 }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
yield* ctx.session.hook("http", (evt) =>
|
yield* ctx.session.hook("http.request", (evt) =>
|
||||||
evt.use((request, next) => {
|
Effect.sync(() => {
|
||||||
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return next(request)
|
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
|
||||||
const url = new URL(request.url)
|
const url = new URL(evt.request.url)
|
||||||
request.headers.set("originator", "opencode")
|
evt.request.headers.set("originator", "opencode")
|
||||||
request.headers.set("session-id", evt.sessionID)
|
evt.request.headers.set("session-id", evt.sessionID)
|
||||||
if (url.origin !== "https://api.openai.com") return next(request)
|
if (url.origin !== "https://api.openai.com") return
|
||||||
return next(new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, request))
|
evt.request = new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, evt.request)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
export * as PluginSupervisor from "./supervisor"
|
export * as PluginSupervisor from "./supervisor"
|
||||||
|
|
||||||
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
|
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
|
||||||
import { Event } from "@opencode-ai/schema/config"
|
import { Directory, Document, Event, type Entry } from "@opencode-ai/schema/config"
|
||||||
|
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
|
||||||
import { Context, Deferred, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
import { Context, Deferred, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { fileURLToPath, pathToFileURL } from "url"
|
import { fileURLToPath, pathToFileURL } from "url"
|
||||||
@@ -9,7 +10,6 @@ import { Agent } from "../agent"
|
|||||||
import { Catalog } from "../catalog"
|
import { Catalog } from "../catalog"
|
||||||
import { Command } from "../command"
|
import { Command } from "../command"
|
||||||
import { Config } from "../config"
|
import { Config } from "../config"
|
||||||
import { ConfigPlugin } from "../config/plugin"
|
|
||||||
import { Credential } from "../credential"
|
import { Credential } from "../credential"
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||||
@@ -83,15 +83,15 @@ function parse(input: ConfigPlugin.Plugin): Operation {
|
|||||||
return { type: "remove", target: input.slice(1) }
|
return { type: "remove", target: input.slice(1) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const scan = Effect.fn("PluginSupervisor.scan")(function* (entries: readonly Config.Entry[]) {
|
const scan = Effect.fn("PluginSupervisor.scan")(function* (entries: readonly Entry[]) {
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
const discovered = yield* Effect.forEach(
|
const discovered = yield* Effect.forEach(
|
||||||
entries.filter((entry): entry is Config.Directory => entry.type === "directory"),
|
entries.filter((entry): entry is Directory => entry.type === "directory"),
|
||||||
(entry) => discoverDirectory(fs, entry.path),
|
(entry) => discoverDirectory(fs, entry.path),
|
||||||
).pipe(Effect.map((items) => items.flat()))
|
).pipe(Effect.map((items) => items.flat()))
|
||||||
const configured = entries
|
const configured = entries
|
||||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
.filter((entry): entry is Document => entry.type === "document")
|
||||||
.flatMap((entry) =>
|
.flatMap((entry) =>
|
||||||
(entry.info.plugins ?? []).map(parse).map((operation) => {
|
(entry.info.plugins ?? []).map(parse).map((operation) => {
|
||||||
if (operation.type === "remove") return operation
|
if (operation.type === "remove") return operation
|
||||||
@@ -208,7 +208,7 @@ function discoverDirectory(fs: FSUtil.Interface, directory: string) {
|
|||||||
|
|
||||||
const sourceDirectories = ["plugin", "plugins"] as const
|
const sourceDirectories = ["plugin", "plugins"] as const
|
||||||
|
|
||||||
function isPluginSource(entries: readonly Config.Entry[], file: string) {
|
function isPluginSource(entries: readonly Entry[], file: string) {
|
||||||
return entries.some(
|
return entries.some(
|
||||||
(entry) =>
|
(entry) =>
|
||||||
entry.type === "directory" &&
|
entry.type === "directory" &&
|
||||||
@@ -243,7 +243,7 @@ const layer = Layer.effect(
|
|||||||
const configuredChanges = yield* PubSub.unbounded<void>()
|
const configuredChanges = yield* PubSub.unbounded<void>()
|
||||||
const watched = new Set<string>()
|
const watched = new Set<string>()
|
||||||
const watchConfiguredSources = Effect.fn("PluginSupervisor.watchConfiguredSources")(function* (
|
const watchConfiguredSources = Effect.fn("PluginSupervisor.watchConfiguredSources")(function* (
|
||||||
entries: readonly Config.Entry[],
|
entries: readonly Entry[],
|
||||||
operations: readonly Operation[],
|
operations: readonly Operation[],
|
||||||
) {
|
) {
|
||||||
for (const operation of operations) {
|
for (const operation of operations) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export * as SessionCompaction from "./compaction"
|
|||||||
|
|
||||||
import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest, type LanguageModel } from "@opencode-ai/ai"
|
import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest, type LanguageModel } from "@opencode-ai/ai"
|
||||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||||
|
import { Document, type Entry } from "@opencode-ai/schema/config"
|
||||||
import { Context, Effect, Layer, Stream } from "effect"
|
import { Context, Effect, Layer, Stream } from "effect"
|
||||||
import { Config } from "../config"
|
import { Config } from "../config"
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
@@ -19,7 +20,7 @@ import type { Info } from "../model"
|
|||||||
import { SessionUsage } from "./usage"
|
import { SessionUsage } from "./usage"
|
||||||
|
|
||||||
const DEFAULT_BUFFER = 20_000
|
const DEFAULT_BUFFER = 20_000
|
||||||
const DEFAULT_KEEP_TOKENS = 8_000
|
const DEFAULT_KEEP_TOKENS = 15_000
|
||||||
const OUTPUT_TOKEN_MAX = 32_000
|
const OUTPUT_TOKEN_MAX = 32_000
|
||||||
const TOOL_OUTPUT_MAX_CHARS = 2_000
|
const TOOL_OUTPUT_MAX_CHARS = 2_000
|
||||||
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
|
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
|
||||||
@@ -148,9 +149,9 @@ const serialize = (message: SessionMessage.Info) => {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = (documents: readonly Config.Entry[]) => {
|
const settings = (documents: readonly Entry[]) => {
|
||||||
const configured = documents
|
const configured = documents
|
||||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
.filter((entry): entry is Document => entry.type === "document")
|
||||||
.flatMap((entry) => (entry.info.compaction ? [entry.info.compaction] : []))
|
.flatMap((entry) => (entry.info.compaction ? [entry.info.compaction] : []))
|
||||||
return {
|
return {
|
||||||
auto: configured.findLast((value) => value.auto !== undefined)?.auto ?? true,
|
auto: configured.findLast((value) => value.auto !== undefined)?.auto ?? true,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user