mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-18 02:03:25 +00:00
Merge remote-tracking branch 'upstream/dev' into refactor/tanstack-virtual-session
# Conflicts: # bun.lock # package.json
This commit is contained in:
commit
6ad0e88657
186 changed files with 6919 additions and 3275 deletions
|
|
@ -52,6 +52,9 @@ _Avoid_: Request body, wire options
|
|||
**Generation Controls**:
|
||||
Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog.
|
||||
|
||||
**PTY Environment**:
|
||||
The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory.
|
||||
|
||||
## Relationships
|
||||
|
||||
- A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
|
||||
|
|
@ -99,6 +102,8 @@ Provider-neutral sampling and output controls, partitioned from provider semanti
|
|||
- A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history.
|
||||
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
|
||||
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
|
||||
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
|
||||
- A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter.
|
||||
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
|
||||
- When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply.
|
||||
- Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.
|
||||
|
|
|
|||
55
bun.lock
55
bun.lock
|
|
@ -29,7 +29,7 @@
|
|||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
|
|
@ -85,7 +85,7 @@
|
|||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.7",
|
||||
"bin": {
|
||||
"lildax": "./bin/lildax.cjs",
|
||||
},
|
||||
|
|
@ -110,7 +110,7 @@
|
|||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
|
|
@ -146,7 +146,7 @@
|
|||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
|
|
@ -173,7 +173,7 @@
|
|||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
|
|
@ -195,7 +195,7 @@
|
|||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
|
@ -219,7 +219,7 @@
|
|||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode-ai/console-support",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode-ai/console-core": "workspace:*",
|
||||
|
|
@ -239,7 +239,7 @@
|
|||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.7",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
|
|
@ -330,7 +330,7 @@
|
|||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"effect": "catalog:",
|
||||
|
|
@ -384,7 +384,7 @@
|
|||
},
|
||||
"packages/effect-drizzle-sqlite": {
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
|
|
@ -398,7 +398,7 @@
|
|||
},
|
||||
"packages/effect-sqlite-node": {
|
||||
"name": "@opencode-ai/effect-sqlite-node",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
},
|
||||
|
|
@ -410,7 +410,7 @@
|
|||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
|
|
@ -441,7 +441,7 @@
|
|||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
|
|
@ -457,7 +457,7 @@
|
|||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "4.0.0-beta.74",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.74",
|
||||
|
|
@ -476,7 +476,7 @@
|
|||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
|
|
@ -494,7 +494,7 @@
|
|||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.7",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
|
|
@ -622,7 +622,7 @@
|
|||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
|
|
@ -660,7 +660,7 @@
|
|||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
|
|
@ -675,7 +675,7 @@
|
|||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode-ai/server",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"drizzle-orm": "catalog:",
|
||||
|
|
@ -689,7 +689,7 @@
|
|||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
|
|
@ -702,7 +702,7 @@
|
|||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode-ai/stats-app",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@opencode-ai/stats-core": "workspace:*",
|
||||
|
|
@ -735,7 +735,7 @@
|
|||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode-ai/stats-core",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
|
|
@ -754,7 +754,7 @@
|
|||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode-ai/stats-server",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
|
|
@ -794,7 +794,7 @@
|
|||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode-ai/tui",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
|
|
@ -821,7 +821,7 @@
|
|||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
|
|
@ -870,7 +870,7 @@
|
|||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
|
|
@ -918,8 +918,9 @@
|
|||
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
|
||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
},
|
||||
"overrides": {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-u3CBUflGrE1unVRTvvLKaDkvx5ZHXyRLd/SJOUUsRdc=",
|
||||
"aarch64-linux": "sha256-hUu70IlOXXQ9apQe3IPOdQGf3ZSVCpvRuAWSKxipk/k=",
|
||||
"aarch64-darwin": "sha256-N6Mqmo94q0rsskLcXoUGWCMJjQ9wCuuo0Lby3j6+Dps=",
|
||||
"x86_64-darwin": "sha256-5691VKK2zWqAwe94CCjZuRjXEaswedevFtHxensVLfg="
|
||||
"x86_64-linux": "sha256-Y+8gz+NfFSb4dysjho4CCaQpx012vvajnptfd3cdh/k=",
|
||||
"aarch64-linux": "sha256-dfkEJ57XvxCM4huM5Ij7tSy+JUPKYmBMZDGZKZ5xSXA=",
|
||||
"aarch64-darwin": "sha256-CJ8dvzGzJp5sgaNJYHwxRc1xK9bWA8zyTmDFi4wTI3o=",
|
||||
"x86_64-darwin": "sha256-7/kwFObujL4z4544Q0D2LN/SxT8sHnJyVrzmBXbJajA="
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,6 +150,7 @@
|
|||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch"
|
||||
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.7",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
|
|
|||
|
|
@ -69,8 +69,8 @@ const SessionRoute = Object.assign(
|
|||
createEffect(() => {
|
||||
if (!settings.general.newLayoutDesigns()) return
|
||||
if (params.id || search.draftId) return
|
||||
if (!tabs.ready() || !sdk.directory) return
|
||||
tabs.newDraft({ server: server.key, directory: sdk.directory }, search.prompt)
|
||||
if (!tabs.ready() || !sdk().directory) return
|
||||
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
|
||||
})
|
||||
|
||||
return (
|
||||
|
|
@ -82,6 +82,45 @@ const SessionRoute = Object.assign(
|
|||
{ preload: Session.preload },
|
||||
)
|
||||
|
||||
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
||||
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
|
||||
// Notification/Models + the visual Layout) for that server.
|
||||
function SelectedServerLayout(props: ParentProps) {
|
||||
return (
|
||||
<ServerKey>
|
||||
<ServerSDKProvider>
|
||||
<ServerSyncProvider>
|
||||
<ServerScopedShell>{props.children}</ServerScopedShell>
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</ServerKey>
|
||||
)
|
||||
}
|
||||
|
||||
// Wraps /new-session. It resolves the draft's target server and provides the
|
||||
// server-scoped shell for that server — without ServerKey, so the page never depends
|
||||
// on the globally "selected" server.
|
||||
function DraftServerLayout(props: ParentProps) {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const conn = createMemo(() => {
|
||||
const id = search.draftId
|
||||
if (!id) return undefined
|
||||
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === id)
|
||||
if (!draft) return undefined
|
||||
return server.list.find((c) => ServerConnection.key(c) === draft.server)
|
||||
})
|
||||
|
||||
return (
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>
|
||||
<ServerScopedShell>{props.children}</ServerScopedShell>
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function DraftRoute() {
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const tabs = useTabs()
|
||||
|
|
@ -95,19 +134,15 @@ function DraftRoute() {
|
|||
}
|
||||
|
||||
function ResolvedDraftRoute(props: { draftID: string }) {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const draft = createMemo(() =>
|
||||
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === props.draftID),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const current = draft()
|
||||
if (current && current.server !== server.key) server.setActive(current.server)
|
||||
})
|
||||
|
||||
// Key on the directory so retargeting the draft's project re-instantiates the
|
||||
// SDK/data providers for the new directory while keeping the same draft id.
|
||||
// directory-scoped providers while keeping the same draft id. The draft's target
|
||||
// server is provided by DraftServerLayout, so changing only the server updates the
|
||||
// SDK/sync hooks without remounting the composer.
|
||||
const directory = () => draft()?.directory
|
||||
|
||||
return (
|
||||
|
|
@ -171,27 +206,36 @@ function BodyDesignClass() {
|
|||
return null
|
||||
}
|
||||
|
||||
function AppShellProviders(props: ParentProps) {
|
||||
// Server-agnostic providers shared across every route. These live in the shared
|
||||
// shell (router root) so they stay mounted regardless of the active server/route.
|
||||
function SharedProviders(props: ParentProps) {
|
||||
return (
|
||||
<SettingsProvider>
|
||||
<BodyDesignClass />
|
||||
<PermissionProvider>
|
||||
<LayoutProvider>
|
||||
<NotificationProvider>
|
||||
<ModelsProvider>
|
||||
<CommandProvider>
|
||||
<HighlightsProvider>
|
||||
<Layout>{props.children}</Layout>
|
||||
</HighlightsProvider>
|
||||
</CommandProvider>
|
||||
</ModelsProvider>
|
||||
</NotificationProvider>
|
||||
</LayoutProvider>
|
||||
</PermissionProvider>
|
||||
<CommandProvider>
|
||||
<HighlightsProvider>{props.children}</HighlightsProvider>
|
||||
</CommandProvider>
|
||||
</SettingsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Server-scoped providers plus the visual Layout (tabs/sidebar). These live inside
|
||||
// each per-route server layout so they resolve to that route's server (selected vs
|
||||
// draft). The Layout remounts when crossing between those groups.
|
||||
function ServerScopedShell(props: ParentProps) {
|
||||
return (
|
||||
<PermissionProvider>
|
||||
<LayoutProvider>
|
||||
<NotificationProvider>
|
||||
<ModelsProvider>
|
||||
<Layout>{props.children}</Layout>
|
||||
</ModelsProvider>
|
||||
</NotificationProvider>
|
||||
</LayoutProvider>
|
||||
</PermissionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionProviders(props: ParentProps) {
|
||||
return (
|
||||
<TerminalProvider>
|
||||
|
|
@ -216,17 +260,6 @@ function DraftProviders(props: ParentProps) {
|
|||
)
|
||||
}
|
||||
|
||||
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
|
||||
return (
|
||||
<AppShellProviders>
|
||||
{/*<Suspense fallback={<Loading />}>*/}
|
||||
{props.appChildren}
|
||||
{props.children}
|
||||
{/*</Suspense>*/}
|
||||
</AppShellProviders>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
||||
return (
|
||||
<MetaProvider>
|
||||
|
|
@ -385,6 +418,20 @@ export function AppInterface(props: {
|
|||
router?: Component<BaseRouterProps>
|
||||
disableHealthCheck?: boolean
|
||||
}) {
|
||||
// The shared shell holds only server-agnostic providers (QueryClient + Settings/
|
||||
// Command/Highlights) and stays mounted across every route. The server-scoped
|
||||
// providers and the visual Layout live in the per-route layouts below, so they
|
||||
// resolve to that route's server (selected for most routes, the draft's server for
|
||||
// /new-session). appChildren is server-agnostic, so it renders here once.
|
||||
const ServerShell = (shellProps: ParentProps) => (
|
||||
<QueryProvider>
|
||||
<SharedProviders>
|
||||
{props.children}
|
||||
{shellProps.children}
|
||||
</SharedProviders>
|
||||
</QueryProvider>
|
||||
)
|
||||
|
||||
return (
|
||||
<ServerProvider
|
||||
defaultServer={props.defaultServer}
|
||||
|
|
@ -397,23 +444,19 @@ export function AppInterface(props: {
|
|||
component={props.router ?? Router}
|
||||
root={(routerProps) => (
|
||||
<TabsProvider>
|
||||
<ServerKey>
|
||||
<QueryProvider>
|
||||
<ServerSDKProvider>
|
||||
<ServerSyncProvider>
|
||||
<RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</QueryProvider>
|
||||
</ServerKey>
|
||||
<ServerShell>{routerProps.children}</ServerShell>
|
||||
</TabsProvider>
|
||||
)}
|
||||
>
|
||||
<Route path="/" component={HomeRoute} />
|
||||
<Route path="/new-session" component={DraftRoute} />
|
||||
<Route path="/:dir" component={DirectoryLayout}>
|
||||
<Route path="/" component={() => <Navigate href="session" />} />
|
||||
<Route path="/session/:id?" component={SessionRoute} />
|
||||
<Route component={SelectedServerLayout}>
|
||||
<Route path="/" component={HomeRoute} />
|
||||
<Route path="/:dir" component={DirectoryLayout}>
|
||||
<Route path="/" component={() => <Navigate href="session" />} />
|
||||
<Route path="/session/:id?" component={SessionRoute} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route component={DraftServerLayout}>
|
||||
<Route path="/new-session" component={DraftRoute} />
|
||||
</Route>
|
||||
</Dynamic>
|
||||
</ConnectionGate>
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||
})
|
||||
|
||||
const provider = createMemo(
|
||||
() => providers.all().get(props.provider) ?? serverSync.data.provider.all.get(props.provider)!,
|
||||
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
|
||||
)
|
||||
const fallback = createMemo<ProviderAuthMethod[]>(() => [
|
||||
{
|
||||
|
|
@ -52,16 +52,16 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||
const [auth] = createResource(
|
||||
() => props.provider,
|
||||
async () => {
|
||||
const cached = serverSync.data.provider_auth[props.provider]
|
||||
const cached = serverSync().data.provider_auth[props.provider]
|
||||
if (cached) return cached
|
||||
const res = await serverSDK.client.provider.auth()
|
||||
const res = await serverSDK().client.provider.auth()
|
||||
if (!alive.value) return fallback()
|
||||
serverSync.set("provider_auth", res.data ?? {})
|
||||
serverSync().set("provider_auth", res.data ?? {})
|
||||
return res.data?.[props.provider] ?? fallback()
|
||||
},
|
||||
)
|
||||
const loading = createMemo(() => auth.loading && !serverSync.data.provider_auth[props.provider])
|
||||
const methods = createMemo(() => auth.latest ?? serverSync.data.provider_auth[props.provider] ?? fallback())
|
||||
const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
|
||||
const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
|
||||
const [store, setStore] = createStore({
|
||||
methodIndex: undefined as undefined | number,
|
||||
authorization: undefined as undefined | ProviderAuthAuthorization,
|
||||
|
|
@ -158,8 +158,8 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||
}
|
||||
dispatch({ type: "auth.pending" })
|
||||
const start = Date.now()
|
||||
await serverSDK.client.provider.oauth
|
||||
.authorize(
|
||||
await serverSDK()
|
||||
.client.provider.oauth.authorize(
|
||||
{
|
||||
providerID: props.provider,
|
||||
method: index,
|
||||
|
|
@ -331,7 +331,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||
})
|
||||
|
||||
async function complete() {
|
||||
await serverSDK.client.global.dispose()
|
||||
await serverSDK().client.global.dispose()
|
||||
dialog.close()
|
||||
showToast({
|
||||
variant: "success",
|
||||
|
|
@ -409,7 +409,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||
}
|
||||
|
||||
setFormStore("error", undefined)
|
||||
await serverSDK.client.auth.set({
|
||||
await serverSDK().client.auth.set({
|
||||
providerID: props.provider,
|
||||
auth: {
|
||||
type: "api",
|
||||
|
|
@ -480,8 +480,8 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||
}
|
||||
|
||||
setFormStore("error", undefined)
|
||||
const result = await serverSDK.client.provider.oauth
|
||||
.callback({
|
||||
const result = await serverSDK()
|
||||
.client.provider.oauth.callback({
|
||||
providerID: props.provider,
|
||||
method: store.methodIndex,
|
||||
code,
|
||||
|
|
@ -533,8 +533,8 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||
|
||||
onMount(() => {
|
||||
void (async () => {
|
||||
const result = await serverSDK.client.provider.oauth
|
||||
.callback({
|
||||
const result = await serverSDK()
|
||||
.client.provider.oauth.callback({
|
||||
providerID: props.provider,
|
||||
method: store.methodIndex,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -105,8 +105,8 @@ export function DialogCustomProvider(props: Props) {
|
|||
const output = validateCustomProvider({
|
||||
form,
|
||||
t: language.t,
|
||||
disabledProviders: serverSync.data.config.disabled_providers ?? [],
|
||||
existingProviderIDs: new Set(serverSync.data.provider.all.keys()),
|
||||
disabledProviders: serverSync().data.config.disabled_providers ?? [],
|
||||
existingProviderIDs: new Set(serverSync().data.provider.all.keys()),
|
||||
})
|
||||
batch(() => {
|
||||
setForm("err", output.err)
|
||||
|
|
@ -118,11 +118,11 @@ export function DialogCustomProvider(props: Props) {
|
|||
|
||||
const saveMutation = useMutation(() => ({
|
||||
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
||||
const disabledProviders = serverSync.data.config.disabled_providers ?? []
|
||||
const disabledProviders = serverSync().data.config.disabled_providers ?? []
|
||||
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
||||
|
||||
if (result.key) {
|
||||
await serverSDK.client.auth.set({
|
||||
await serverSDK().client.auth.set({
|
||||
providerID: result.providerID,
|
||||
auth: {
|
||||
type: "api",
|
||||
|
|
@ -131,7 +131,7 @@ export function DialogCustomProvider(props: Props) {
|
|||
})
|
||||
}
|
||||
|
||||
await serverSync.updateConfig({
|
||||
await serverSync().updateConfig({
|
||||
provider: { [result.providerID]: result.config },
|
||||
disabled_providers: nextDisabled,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -35,13 +35,13 @@ export const DialogFork: Component = () => {
|
|||
const sessionID = params.id
|
||||
if (!sessionID) return []
|
||||
|
||||
const msgs = sync.data.message[sessionID] ?? []
|
||||
const msgs = sync().data.message[sessionID] ?? []
|
||||
const result: ForkableMessage[] = []
|
||||
|
||||
for (const message of msgs) {
|
||||
if (message.role !== "user") continue
|
||||
|
||||
const parts = sync.data.part[message.id] ?? []
|
||||
const parts = sync().data.part[message.id] ?? []
|
||||
const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored)
|
||||
if (!textPart) continue
|
||||
|
||||
|
|
@ -61,15 +61,15 @@ export const DialogFork: Component = () => {
|
|||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
|
||||
const parts = sync.data.part[item.id] ?? []
|
||||
const parts = sync().data.part[item.id] ?? []
|
||||
const restored = extractPromptFromParts(parts, {
|
||||
directory: sdk.directory,
|
||||
directory: sdk().directory,
|
||||
attachmentName: language.t("common.attachment"),
|
||||
})
|
||||
const dir = base64Encode(sdk.directory)
|
||||
const dir = base64Encode(sdk().directory)
|
||||
|
||||
sdk.client.session
|
||||
.fork({ sessionID, messageID: item.id })
|
||||
sdk()
|
||||
.client.session.fork({ sessionID, messageID: item.id })
|
||||
.then((forked) => {
|
||||
if (!forked.data) {
|
||||
showToast({ title: language.t("common.requestFailed") })
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
|||
import { useNavigate } from "@solidjs/router"
|
||||
import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useFile } from "@/context/file"
|
||||
|
|
@ -175,7 +175,7 @@ function createFileEntries(props: {
|
|||
function createSessionEntries(props: {
|
||||
workspaces: () => string[]
|
||||
label: (directory: string) => string
|
||||
serverSDK: ReturnType<typeof useServerSDK>
|
||||
serverSDK: ServerSDK
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const state: {
|
||||
|
|
@ -292,21 +292,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||
if (directory && !dirs.includes(directory)) return [...dirs, directory]
|
||||
return dirs
|
||||
})
|
||||
const homedir = createMemo(() => serverSync.data.path.home)
|
||||
const homedir = createMemo(() => serverSync().data.path.home)
|
||||
const label = (directory: string) => {
|
||||
const current = project()
|
||||
const kind =
|
||||
current && directory === current.worktree
|
||||
? language.t("workspace.type.local")
|
||||
: language.t("workspace.type.sandbox")
|
||||
const [store] = serverSync.child(directory, { bootstrap: false })
|
||||
const [store] = serverSync().child(directory, { bootstrap: false })
|
||||
const home = homedir()
|
||||
const path = home ? directory.replace(home, "~") : directory
|
||||
const name = store.vcs?.branch ?? getFilename(directory)
|
||||
return `${kind} : ${name || path}`
|
||||
}
|
||||
|
||||
const { sessions } = createSessionEntries({ workspaces, label, serverSDK, language })
|
||||
const { sessions } = createSessionEntries({ workspaces, label, serverSDK: serverSDK(), language })
|
||||
|
||||
const items = async (text: string) => {
|
||||
const query = text.trim()
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export const DialogSelectMcp: Component = () => {
|
|||
const language = useLanguage()
|
||||
|
||||
const items = createMemo(() =>
|
||||
Object.entries(sync.data.mcp ?? {})
|
||||
Object.entries(sync().data.mcp ?? {})
|
||||
.map(([name, status]) => ({ name, status: status.status }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
)
|
||||
|
|
@ -48,7 +48,7 @@ export const DialogSelectMcp: Component = () => {
|
|||
}}
|
||||
>
|
||||
{(i) => {
|
||||
const mcpStatus = () => sync.data.mcp[i.name]
|
||||
const mcpStatus = () => sync().data.mcp[i.name]
|
||||
const status = () => mcpStatus()?.status
|
||||
const statusLabel = () => {
|
||||
const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined
|
||||
|
|
|
|||
117
packages/app/src/components/prompt-input.stories.tsx
Normal file
117
packages/app/src/components/prompt-input.stories.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
// @ts-nocheck
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createPromptState } from "@/context/prompt"
|
||||
import { createPromptInputHistory, PromptInput } from "./prompt-input"
|
||||
|
||||
function PromptInputExample() {
|
||||
const state = createPromptState()
|
||||
const history = createPromptInputHistory()
|
||||
const [controls, setControls] = createStore({
|
||||
agent: "build",
|
||||
variant: undefined as string | undefined,
|
||||
comments: 0,
|
||||
tabs: [] as string[],
|
||||
activeTab: undefined as string | undefined,
|
||||
reviewOpen: false,
|
||||
})
|
||||
const model = {
|
||||
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
|
||||
variant: {
|
||||
list: () => ["fast", "thinking"],
|
||||
current: () => controls.variant,
|
||||
set: (variant?: string) => setControls("variant", variant),
|
||||
},
|
||||
}
|
||||
const submission = {
|
||||
abort() {},
|
||||
handleSubmit(event: Event) {
|
||||
event.preventDefault()
|
||||
state.reset()
|
||||
},
|
||||
}
|
||||
const inputControls = {
|
||||
agents: {
|
||||
available: [{ name: "review", hidden: false, mode: "subagent" }],
|
||||
options: ["build", "review", "plan"],
|
||||
get current() {
|
||||
return controls.agent
|
||||
},
|
||||
loading: false,
|
||||
visible: true,
|
||||
select: (agent?: string) => setControls("agent", agent ?? "build"),
|
||||
},
|
||||
model: {
|
||||
selection: model,
|
||||
paid: true,
|
||||
loading: false,
|
||||
},
|
||||
projects: {
|
||||
available: [{ name: "Story project", worktree: "/tmp/story", sandboxes: [] }],
|
||||
directory: "/tmp/story",
|
||||
select() {},
|
||||
add() {},
|
||||
},
|
||||
session: {
|
||||
id: "story-session",
|
||||
tabs: {
|
||||
active: () => controls.activeTab,
|
||||
all: () => controls.tabs,
|
||||
open: (tab: string) => setControls("tabs", (tabs) => (tabs.includes(tab) ? tabs : [...tabs, tab])),
|
||||
setActive: (tab: string) => setControls("activeTab", tab),
|
||||
},
|
||||
reviewPanel: {
|
||||
opened: () => controls.reviewOpen,
|
||||
open: () => setControls("reviewOpen", true),
|
||||
},
|
||||
},
|
||||
newLayoutDesigns: true,
|
||||
}
|
||||
const addReviewComment = () => {
|
||||
const comment = controls.comments + 1
|
||||
setControls("comments", comment)
|
||||
state.context.add({
|
||||
type: "file",
|
||||
path: "src/components/prompt-input.tsx",
|
||||
selection: {
|
||||
startLine: 84 + comment,
|
||||
startChar: 0,
|
||||
endLine: 84 + comment,
|
||||
endChar: 0,
|
||||
},
|
||||
comment: `Review comment ${comment}`,
|
||||
commentID: `review-comment-${comment}`,
|
||||
commentOrigin: "review",
|
||||
preview: "export const PromptInput = ...",
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-3">
|
||||
<PromptInput controls={inputControls} state={state} history={history} submission={submission} />
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-border-weak-base bg-background-base px-2.5 py-1.5 text-12-medium text-text-base hover:bg-background-stronger"
|
||||
onClick={addReviewComment}
|
||||
>
|
||||
Add review comment
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "App/PromptInput",
|
||||
id: "app-prompt-input",
|
||||
component: PromptInput,
|
||||
}
|
||||
|
||||
export const Basic = {
|
||||
render: () => (
|
||||
<div class="pt-10">
|
||||
<h1 class="mb-4">Prompt Input</h1>
|
||||
<PromptInputExample />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
|
@ -17,8 +17,8 @@ import {
|
|||
type JSX,
|
||||
} from "solid-js"
|
||||
import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { useLocal } from "@/context/local"
|
||||
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
|
||||
import {
|
||||
ContentPart,
|
||||
|
|
@ -31,10 +31,7 @@ import {
|
|||
FileAttachmentPart,
|
||||
} from "@/context/prompt"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useNavigate, useSearchParams } from "@solidjs/router"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
|
|
@ -46,14 +43,11 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
|||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
|
||||
import { createPromptAttachments } from "./prompt-input/attachments"
|
||||
|
|
@ -73,18 +67,104 @@ import { PromptContextItems } from "./prompt-input/context-items"
|
|||
import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
||||
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
|
||||
import { promptPlaceholder } from "./prompt-input/placeholder"
|
||||
import { useDirectoryPicker } from "./directory-picker"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||
import { useQueries } from "@tanstack/solid-query"
|
||||
import { useQueryOptions } from "@/context/server-sync"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { displayName } from "@/pages/layout/helpers"
|
||||
|
||||
interface PromptInputProps {
|
||||
export type PromptInputState = ReturnType<typeof usePrompt>
|
||||
|
||||
export type PromptInputHistory = {
|
||||
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
|
||||
add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
|
||||
}
|
||||
|
||||
export type PromptInputSubmission = {
|
||||
abort: () => Promise<void> | void
|
||||
handleSubmit: (event: Event) => Promise<void> | void
|
||||
}
|
||||
|
||||
export type PromptInputControls = {
|
||||
agents: {
|
||||
available: { name: string; hidden?: boolean; mode: string }[]
|
||||
options: string[]
|
||||
current: string
|
||||
loading: boolean
|
||||
visible: boolean
|
||||
select: (name: string | undefined) => void
|
||||
}
|
||||
model: {
|
||||
selection: ReturnType<typeof useLocal>["model"]
|
||||
paid: boolean
|
||||
loading: boolean
|
||||
}
|
||||
projects: {
|
||||
available: { name?: string; worktree: string; sandboxes?: string[] }[]
|
||||
directory: string
|
||||
select: (worktree: string) => void
|
||||
add: (title: string) => void
|
||||
}
|
||||
session: {
|
||||
id?: string
|
||||
tabs: {
|
||||
active: () => string | undefined
|
||||
all: () => string[]
|
||||
open: (tab: string) => void | Promise<void>
|
||||
setActive: (tab: string) => void
|
||||
}
|
||||
reviewPanel: {
|
||||
opened: () => boolean
|
||||
open: () => void
|
||||
}
|
||||
}
|
||||
newLayoutDesigns: boolean
|
||||
}
|
||||
|
||||
export function createPromptInputHistory(): PromptInputHistory {
|
||||
const [normal, setNormal] = createStore<PromptHistoryState>({ entries: [] })
|
||||
const [shell, setShell] = createStore<PromptHistoryState>({ entries: [] })
|
||||
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
|
||||
}
|
||||
|
||||
type PromptHistoryState = { entries: PromptHistoryStoredEntry[] }
|
||||
|
||||
function createPromptInputHistoryStore(
|
||||
normal: Store<PromptHistoryState>,
|
||||
setNormal: SetStoreFunction<PromptHistoryState>,
|
||||
shell: Store<PromptHistoryState>,
|
||||
setShell: SetStoreFunction<PromptHistoryState>,
|
||||
): PromptInputHistory {
|
||||
return {
|
||||
entries: (mode) => (mode === "shell" ? shell.entries : normal.entries),
|
||||
add(prompt, mode, comments) {
|
||||
const current = mode === "shell" ? shell : normal
|
||||
const setCurrent = mode === "shell" ? setShell : setNormal
|
||||
const next = prependHistoryEntry(current.entries, prompt, comments)
|
||||
if (next === current.entries) return
|
||||
setCurrent("entries", next)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createPersistedPromptInputHistory() {
|
||||
const [normal, setNormal] = persisted(
|
||||
Persist.global("prompt-history", ["prompt-history.v1"]),
|
||||
createStore<PromptHistoryState>({ entries: [] }),
|
||||
)
|
||||
const [shell, setShell] = persisted(
|
||||
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
|
||||
createStore<PromptHistoryState>({ entries: [] }),
|
||||
)
|
||||
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
|
||||
}
|
||||
|
||||
export interface PromptInputProps {
|
||||
class?: string
|
||||
variant?: "dock" | "new-session"
|
||||
state?: PromptInputState
|
||||
history?: PromptInputHistory
|
||||
submission?: PromptInputSubmission
|
||||
controls: PromptInputControls
|
||||
ref?: (el: HTMLDivElement) => void
|
||||
newSessionWorktree?: string
|
||||
onNewSessionWorktreeReset?: () => void
|
||||
|
|
@ -126,27 +206,18 @@ const EXAMPLES = [
|
|||
|
||||
export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const sdk = useSDK()
|
||||
const navigate = useNavigate()
|
||||
const queryOptions = useQueryOptions()
|
||||
|
||||
const sync = useSync()
|
||||
const local = useLocal()
|
||||
const files = useFile()
|
||||
const prompt = usePrompt()
|
||||
const prompt = props.state ?? usePrompt()
|
||||
const layout = useLayout()
|
||||
const server = useServer()
|
||||
const comments = useComments()
|
||||
const dialog = useDialog()
|
||||
const providers = useProviders()
|
||||
const command = useCommand()
|
||||
const permission = usePermission()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const settings = useSettings()
|
||||
const tabsStore = useTabs()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
const tabs = () => props.controls.session.tabs
|
||||
let editorRef!: HTMLDivElement
|
||||
let fileInputRef: HTMLInputElement | undefined
|
||||
let scrollRef!: HTMLDivElement
|
||||
|
|
@ -204,10 +275,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
}).activeFileTab
|
||||
|
||||
const commentInReview = (path: string) => {
|
||||
const sessionID = params.id
|
||||
const sessionID = props.controls.session.id
|
||||
if (!sessionID) return false
|
||||
|
||||
const diffs = sync.data.session_diff[sessionID]
|
||||
const diffs = sync().data.session_diff[sessionID]
|
||||
if (!diffs) return false
|
||||
return diffs.some((diff) => diff.file === path)
|
||||
}
|
||||
|
|
@ -237,14 +308,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
|
||||
const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path))
|
||||
if (wantsReview) {
|
||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||
if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
|
||||
layout.fileTree.setTab("changes")
|
||||
tabs().setActive("review")
|
||||
queueCommentFocus()
|
||||
return
|
||||
}
|
||||
|
||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||
if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
|
||||
layout.fileTree.setTab("all")
|
||||
const tab = files.tab(item.path)
|
||||
void tabs().open(tab)
|
||||
|
|
@ -269,8 +340,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
|
||||
return paths
|
||||
})
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const working = createMemo(() => sync.data.session_working(params.id ?? ""))
|
||||
const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined))
|
||||
const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? ""))
|
||||
const imageAttachments = createMemo(() =>
|
||||
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
||||
)
|
||||
|
|
@ -347,29 +418,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
})
|
||||
|
||||
const hasUserPrompt = createMemo(() => {
|
||||
const sessionID = params.id
|
||||
const sessionID = props.controls.session.id
|
||||
if (!sessionID) return false
|
||||
const messages = sync.data.message[sessionID]
|
||||
const messages = sync().data.message[sessionID]
|
||||
if (!messages) return false
|
||||
return messages.some((m) => m.role === "user")
|
||||
})
|
||||
|
||||
const [history, setHistory] = persisted(
|
||||
Persist.global("prompt-history", ["prompt-history.v1"]),
|
||||
createStore<{
|
||||
entries: PromptHistoryStoredEntry[]
|
||||
}>({
|
||||
entries: [],
|
||||
}),
|
||||
)
|
||||
const [shellHistory, setShellHistory] = persisted(
|
||||
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
|
||||
createStore<{
|
||||
entries: PromptHistoryStoredEntry[]
|
||||
}>({
|
||||
entries: [],
|
||||
}),
|
||||
)
|
||||
const history = props.history ?? createPersistedPromptInputHistory()
|
||||
|
||||
const suggest = createMemo(() => !hasUserPrompt())
|
||||
|
||||
|
|
@ -474,7 +530,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
const pick = () => {
|
||||
pickAttachmentFiles({
|
||||
picker: platform.openAttachmentPickerDialog,
|
||||
directory: () => sdk.directory,
|
||||
directory: () => sdk().directory,
|
||||
fallback: () => fileInputRef?.click(),
|
||||
onFile: addAttachment,
|
||||
onError: (error) =>
|
||||
|
|
@ -573,8 +629,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
}
|
||||
|
||||
createEffect(() => {
|
||||
params.id
|
||||
if (params.id) return
|
||||
props.controls.session.id
|
||||
if (props.controls.session.id) return
|
||||
if (!suggest()) return
|
||||
const interval = setInterval(() => {
|
||||
setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length)
|
||||
|
|
@ -603,11 +659,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
}
|
||||
|
||||
const agentList = createMemo(() =>
|
||||
sync.data.agent
|
||||
props.controls.agents.available
|
||||
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
||||
.map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
|
||||
)
|
||||
const agentNames = createMemo(() => local.agent.list().map((agent) => agent.name))
|
||||
|
||||
const handleAtSelect = (option: AtOption | undefined) => {
|
||||
if (!option) return
|
||||
|
|
@ -673,7 +728,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
type: "builtin" as const,
|
||||
}))
|
||||
|
||||
const custom = sync.data.command.map((cmd) => ({
|
||||
const custom = sync().data.command.map((cmd) => ({
|
||||
id: `custom.${cmd.name}`,
|
||||
trigger: cmd.name,
|
||||
title: cmd.name,
|
||||
|
|
@ -1033,11 +1088,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
}
|
||||
|
||||
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
|
||||
const currentHistory = mode === "shell" ? shellHistory : history
|
||||
const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
|
||||
const next = prependHistoryEntry(currentHistory.entries, prompt, mode === "shell" ? [] : historyComments())
|
||||
if (next === currentHistory.entries) return
|
||||
setCurrentHistory("entries", next)
|
||||
history.add(prompt, mode, mode === "shell" ? [] : historyComments())
|
||||
}
|
||||
|
||||
createEffect(
|
||||
|
|
@ -1082,7 +1133,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
const navigateHistory = (direction: "up" | "down") => {
|
||||
const result = navigatePromptHistory({
|
||||
direction,
|
||||
entries: store.mode === "shell" ? shellHistory.entries : history.entries,
|
||||
entries: history.entries(store.mode),
|
||||
historyIndex: store.historyIndex,
|
||||
currentPrompt: prompt.current(),
|
||||
currentComments: historyComments(),
|
||||
|
|
@ -1096,6 +1147,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
}
|
||||
|
||||
const { addAttachment, addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
|
||||
prompt,
|
||||
editor: () => editorRef,
|
||||
isDialogActive: () => !!dialog.active,
|
||||
setDraggingType: (type) => setStore("draggingType", type),
|
||||
|
|
@ -1122,38 +1174,41 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
/>
|
||||
)
|
||||
|
||||
const variants = createMemo(() => ["default", ...local.model.variant.list()])
|
||||
const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()])
|
||||
// Check provider variants directly: `variants` also includes the UI-only default option.
|
||||
const showVariantControl = createMemo(() => local.model.variant.list().length > 0)
|
||||
const showVariantControl = createMemo(() => props.controls.model.selection.variant.list().length > 0)
|
||||
const accepting = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
|
||||
return permission.isAutoAccepting(id, sdk.directory)
|
||||
const id = props.controls.session.id
|
||||
if (!id) return permission.isAutoAcceptingDirectory(sdk().directory)
|
||||
return permission.isAutoAccepting(id, sdk().directory)
|
||||
})
|
||||
|
||||
const { abort, handleSubmit } = createPromptSubmit({
|
||||
info,
|
||||
imageAttachments,
|
||||
commentCount,
|
||||
autoAccept: () => accepting(),
|
||||
mode: () => store.mode,
|
||||
working,
|
||||
editor: () => editorRef,
|
||||
queueScroll,
|
||||
promptLength,
|
||||
addToHistory,
|
||||
resetHistoryNavigation: () => {
|
||||
resetHistoryNavigation(true)
|
||||
},
|
||||
setMode: (mode) => setStore("mode", mode),
|
||||
setPopover: (popover) => setStore("popover", popover),
|
||||
newSessionWorktree: () => props.newSessionWorktree,
|
||||
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
|
||||
shouldQueue: props.shouldQueue,
|
||||
onQueue: props.onQueue,
|
||||
onAbort: props.onAbort,
|
||||
onSubmit: props.onSubmit,
|
||||
})
|
||||
const { abort, handleSubmit } =
|
||||
props.submission ??
|
||||
createPromptSubmit({
|
||||
prompt,
|
||||
info,
|
||||
imageAttachments,
|
||||
commentCount,
|
||||
autoAccept: () => accepting(),
|
||||
mode: () => store.mode,
|
||||
working,
|
||||
editor: () => editorRef,
|
||||
queueScroll,
|
||||
promptLength,
|
||||
addToHistory,
|
||||
resetHistoryNavigation: () => {
|
||||
resetHistoryNavigation(true)
|
||||
},
|
||||
setMode: (mode) => setStore("mode", mode),
|
||||
setPopover: (popover) => setStore("popover", popover),
|
||||
newSessionWorktree: () => props.newSessionWorktree,
|
||||
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
|
||||
shouldQueue: props.shouldQueue,
|
||||
onQueue: props.onQueue,
|
||||
onAbort: props.onAbort,
|
||||
onSubmit: props.onSubmit,
|
||||
})
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") {
|
||||
|
|
@ -1317,17 +1372,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
}
|
||||
}
|
||||
|
||||
const [agentsQuery, globalProvidersQuery, providersQuery] = useQueries(() => ({
|
||||
queries: [
|
||||
queryOptions.agents(pathKey(sdk.directory)),
|
||||
queryOptions.providers(null),
|
||||
queryOptions.providers(pathKey(sdk.directory)),
|
||||
],
|
||||
}))
|
||||
|
||||
const agentsLoading = () => agentsQuery.isLoading
|
||||
const agentsLoading = () => props.controls.agents.loading
|
||||
const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading())
|
||||
const providersLoading = () => agentsLoading() || providersQuery.isLoading || globalProvidersQuery.isLoading
|
||||
const providersLoading = () => props.controls.model.loading
|
||||
const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
|
||||
|
||||
const [promptReady] = createResource(
|
||||
|
|
@ -1342,23 +1389,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
|
||||
const modelControlState = createMemo<ComposerModelControlState>(() => ({
|
||||
loading: providersLoading(),
|
||||
paid: providers.paid().length > 0,
|
||||
paid: props.controls.model.paid,
|
||||
title: language.t("command.model.choose"),
|
||||
keybind: command.keybind("model.choose"),
|
||||
model: local.model,
|
||||
providerID: local.model.current()?.provider?.id,
|
||||
modelName: local.model.current()?.name ?? language.t("dialog.model.select.title"),
|
||||
model: props.controls.model.selection,
|
||||
providerID: props.controls.model.selection.current()?.provider?.id,
|
||||
modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"),
|
||||
style: control(),
|
||||
onClose: restoreFocus,
|
||||
onUnpaidClick: () => {
|
||||
void import("@/components/dialog-select-model-unpaid").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
|
||||
dialog.show(() => <x.DialogSelectModelUnpaid model={props.controls.model.selection} />)
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
const newSession = () => props.variant === "new-session"
|
||||
const projects = createMemo(() => layout.projects.list())
|
||||
const projects = createMemo(() => props.controls.projects.available)
|
||||
const projectForDirectory = (directory: string | undefined) => {
|
||||
if (!directory) return
|
||||
const key = pathKey(directory)
|
||||
|
|
@ -1366,13 +1413,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
(project) => pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
|
||||
)
|
||||
}
|
||||
const selectedProject = createMemo(() => projectForDirectory(sdk.directory))
|
||||
const selectedProject = createMemo(() => projectForDirectory(props.controls.projects.directory))
|
||||
const projectResults = createMemo(() => {
|
||||
const search = picker.projectSearch.trim().toLowerCase()
|
||||
if (!search) return projects()
|
||||
return projects().filter((project) => displayName(project).toLowerCase().includes(search))
|
||||
})
|
||||
const showAgentControl = createMemo(() => settings.visibility.customAgents() && agentNames().length > 0)
|
||||
const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0)
|
||||
const selectProject = (worktree: string) => {
|
||||
setPicker({
|
||||
projectOpen: false,
|
||||
|
|
@ -1382,33 +1429,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
restoreFocus()
|
||||
return
|
||||
}
|
||||
layout.projects.open(worktree)
|
||||
server.projects.touch(worktree)
|
||||
|
||||
// On the draft route, retarget the existing draft in place so we keep the same
|
||||
// draft id (and its tab/prompt) instead of spawning a new draft for the new directory.
|
||||
const draftID = search.draftId
|
||||
if (draftID) {
|
||||
tabsStore.updateDraft(draftID, { server: server.key, directory: worktree })
|
||||
restoreFocus()
|
||||
return
|
||||
}
|
||||
|
||||
navigate(`/${base64Encode(worktree)}/session`)
|
||||
props.controls.projects.select(worktree)
|
||||
restoreFocus()
|
||||
}
|
||||
const addProject = () => {
|
||||
const conn = server.current
|
||||
if (!conn) return
|
||||
const select = (result: string | string[] | null) => {
|
||||
const directory = Array.isArray(result) ? result[0] : result
|
||||
if (!directory) return
|
||||
selectProject(directory)
|
||||
}
|
||||
pickDirectory({
|
||||
server: conn,
|
||||
title: language.t("command.project.open"),
|
||||
onSelect: select,
|
||||
})
|
||||
props.controls.projects.add(language.t("command.project.open"))
|
||||
}
|
||||
|
||||
const projectPickerState = createMemo<ComposerPickerState>(() => ({
|
||||
|
|
@ -1449,11 +1474,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
const agentControlState = createMemo<ComposerAgentControlState>(() => ({
|
||||
title: language.t("command.agent.cycle"),
|
||||
keybind: command.keybind("agent.cycle"),
|
||||
options: agentNames(),
|
||||
current: local.agent.current()?.name ?? "",
|
||||
options: props.controls.agents.options,
|
||||
current: props.controls.agents.current,
|
||||
style: control(),
|
||||
onSelect: (value) => {
|
||||
local.agent.set(value)
|
||||
props.controls.agents.select(value)
|
||||
restoreFocus()
|
||||
},
|
||||
}))
|
||||
|
|
@ -1485,7 +1510,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||
/>
|
||||
<Switch>
|
||||
<Match when={settings.general.newLayoutDesigns()}>
|
||||
<Match when={props.controls.newLayoutDesigns}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<DockShellForm
|
||||
data-component={newSession() ? "session-new-composer" : "session-composer"}
|
||||
|
|
@ -1605,7 +1630,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
data-component="prompt-variant-control"
|
||||
classList={{
|
||||
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
|
||||
!local.model.variant.current() && !store.variantOpen,
|
||||
!props.controls.model.selection.variant.current() && !store.variantOpen,
|
||||
}}
|
||||
>
|
||||
<TooltipKeybind
|
||||
|
|
@ -1617,11 +1642,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
<Select
|
||||
size="normal"
|
||||
options={variants()}
|
||||
current={local.model.variant.current() ?? "default"}
|
||||
current={props.controls.model.selection.variant.current() ?? "default"}
|
||||
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
||||
onOpenChange={(open) => setStore("variantOpen", open)}
|
||||
onSelect={(value) => {
|
||||
local.model.variant.set(value === "default" ? undefined : value)
|
||||
props.controls.model.selection.variant.set(value === "default" ? undefined : value)
|
||||
restoreFocus()
|
||||
}}
|
||||
class="capitalize max-w-[160px] justify-start text-v2-text-text-faint"
|
||||
|
|
@ -1861,10 +1886,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
>
|
||||
<Select
|
||||
size="normal"
|
||||
options={agentNames()}
|
||||
current={local.agent.current()?.name ?? ""}
|
||||
options={props.controls.agents.options}
|
||||
current={props.controls.agents.current}
|
||||
onSelect={(value) => {
|
||||
local.agent.set(value)
|
||||
props.controls.agents.select(value)
|
||||
restoreFocus()
|
||||
}}
|
||||
class="capitalize max-w-[160px] text-text-base"
|
||||
|
|
@ -1883,7 +1908,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
||||
>
|
||||
<Show
|
||||
when={providers.paid().length > 0}
|
||||
when={props.controls.model.paid}
|
||||
fallback={
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
|
|
@ -1900,19 +1925,22 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
style={control()}
|
||||
onClick={() => {
|
||||
void import("@/components/dialog-select-model-unpaid").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
|
||||
dialog.show(() => (
|
||||
<x.DialogSelectModelUnpaid model={props.controls.model.selection} />
|
||||
))
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<Show when={props.controls.model.selection.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
id={local.model.current()?.provider?.id ?? ""}
|
||||
id={props.controls.model.selection.current()?.provider?.id ?? ""}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
</Show>
|
||||
<span class="truncate">
|
||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||
{props.controls.model.selection.current()?.name ??
|
||||
language.t("dialog.model.select.title")}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||
</Button>
|
||||
|
|
@ -1926,7 +1954,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
keybind={command.keybind("model.choose")}
|
||||
>
|
||||
<ModelSelectorPopover
|
||||
model={local.model}
|
||||
model={props.controls.model.selection}
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
|
|
@ -1937,15 +1965,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
}}
|
||||
onClose={restoreFocus}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<Show when={props.controls.model.selection.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
id={local.model.current()?.provider?.id ?? ""}
|
||||
id={props.controls.model.selection.current()?.provider?.id ?? ""}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
</Show>
|
||||
<span class="truncate">
|
||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||
{props.controls.model.selection.current()?.name ??
|
||||
language.t("dialog.model.select.title")}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||
</ModelSelectorPopover>
|
||||
|
|
@ -1966,10 +1995,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
<Select
|
||||
size="normal"
|
||||
options={variants()}
|
||||
current={local.model.variant.current() ?? "default"}
|
||||
current={props.controls.model.selection.variant.current() ?? "default"}
|
||||
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
||||
onSelect={(value) => {
|
||||
local.model.variant.set(value === "default" ? undefined : value)
|
||||
props.controls.model.selection.variant.set(value === "default" ? undefined : value)
|
||||
restoreFocus()
|
||||
}}
|
||||
class="capitalize max-w-[160px] text-text-base"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { onMount } from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
|
||||
import { type ContentPart, type ImageAttachmentPart, type usePrompt } from "@/context/prompt"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { uuid } from "@/utils/uuid"
|
||||
import { getCursorPosition } from "./editor-dom"
|
||||
|
|
@ -26,6 +26,7 @@ function dataUrl(file: File, mime: string) {
|
|||
}
|
||||
|
||||
type PromptAttachmentsInput = {
|
||||
prompt: ReturnType<typeof usePrompt>
|
||||
editor: () => HTMLDivElement | undefined
|
||||
isDialogActive: () => boolean
|
||||
setDraggingType: (type: "image" | "@mention" | null) => void
|
||||
|
|
@ -35,7 +36,7 @@ type PromptAttachmentsInput = {
|
|||
}
|
||||
|
||||
export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
const prompt = usePrompt()
|
||||
const prompt = input.prompt
|
||||
const language = useLanguage()
|
||||
|
||||
const warn = () => {
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
|||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
|
||||
<div class="flex items-center text-11-regular min-w-0 font-medium">
|
||||
<div class="flex items-center text-[12px] min-w-0 font-medium leading-5">
|
||||
<span class="text-text-strong whitespace-nowrap">{label}</span>
|
||||
<Show when={item.selection}>
|
||||
{(sel) => (
|
||||
|
|
|
|||
|
|
@ -26,6 +26,22 @@ let selected = "/repo/worktree-a"
|
|||
let variant: string | undefined
|
||||
|
||||
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
const prompt = {
|
||||
ready: () => Object.assign(() => true, { promise: Promise.resolve(true) }),
|
||||
current: () => promptValue,
|
||||
cursor: () => 0,
|
||||
dirty: () => true,
|
||||
reset: () => undefined,
|
||||
set: () => undefined,
|
||||
context: {
|
||||
add: () => undefined,
|
||||
remove: () => undefined,
|
||||
removeComment: () => undefined,
|
||||
updateComment: () => undefined,
|
||||
replaceComments: () => undefined,
|
||||
items: () => [],
|
||||
},
|
||||
}
|
||||
|
||||
const clientFor = (directory: string) => {
|
||||
createdClients.push(directory)
|
||||
|
|
@ -73,6 +89,7 @@ beforeAll(async () => {
|
|||
}))
|
||||
|
||||
mock.module("@opencode-ai/ui/toast", () => ({
|
||||
Toast: { Region: () => null },
|
||||
showToast: () => 0,
|
||||
}))
|
||||
|
||||
|
|
@ -116,16 +133,7 @@ beforeAll(async () => {
|
|||
}))
|
||||
|
||||
mock.module("@/context/prompt", () => ({
|
||||
usePrompt: () => ({
|
||||
current: () => promptValue,
|
||||
reset: () => undefined,
|
||||
set: () => undefined,
|
||||
context: {
|
||||
add: () => undefined,
|
||||
remove: () => undefined,
|
||||
items: () => [],
|
||||
},
|
||||
}),
|
||||
usePrompt: () => prompt,
|
||||
}))
|
||||
|
||||
mock.module("@/context/layout", () => ({
|
||||
|
|
@ -147,12 +155,12 @@ beforeAll(async () => {
|
|||
return clientFor(opts.directory)
|
||||
},
|
||||
}
|
||||
return sdk
|
||||
return () => sdk
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/context/sync", () => ({
|
||||
useSync: () => ({
|
||||
useSync: () => () => ({
|
||||
data: { command: [] },
|
||||
session: {
|
||||
optimistic: {
|
||||
|
|
@ -176,7 +184,7 @@ beforeAll(async () => {
|
|||
}))
|
||||
|
||||
mock.module("@/context/server-sync", () => ({
|
||||
useServerSync: () => ({
|
||||
useServerSync: () => () => ({
|
||||
child: (directory: string) => {
|
||||
syncedDirectories.push(directory)
|
||||
storedSessions[directory] ??= []
|
||||
|
|
@ -232,6 +240,7 @@ beforeEach(() => {
|
|||
describe("prompt submit worktree selection", () => {
|
||||
test("reads the latest worktree accessor value per submit", async () => {
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
|
|
@ -269,6 +278,7 @@ describe("prompt submit worktree selection", () => {
|
|||
|
||||
test("applies auto-accept to newly created sessions", async () => {
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
|
|
@ -299,6 +309,7 @@ describe("prompt submit worktree selection", () => {
|
|||
variant = "high"
|
||||
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => ({ id: "session-1" }),
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
|
|
@ -330,6 +341,7 @@ describe("prompt submit worktree selection", () => {
|
|||
|
||||
test("seeds new sessions before optimistic prompts are added", async () => {
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
|
|
|
|||
|
|
@ -7,14 +7,14 @@ import { batch, type Accessor } from "solid-js"
|
|||
import type { FileSelection } from "@/context/file"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
|
||||
import { useSDK, type DirectorySDK } from "@/context/sdk"
|
||||
import { useSync, type DirectorySync } from "@/context/sync"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||
import { buildRequestParts } from "./build-request-parts"
|
||||
|
|
@ -40,9 +40,9 @@ export type FollowupDraft = {
|
|||
}
|
||||
|
||||
type FollowupSendInput = {
|
||||
client: ReturnType<typeof useSDK>["client"]
|
||||
serverSync: ReturnType<typeof useServerSync>
|
||||
sync: ReturnType<typeof useSync>
|
||||
client: DirectorySDK["client"]
|
||||
serverSync: ServerSync
|
||||
sync: DirectorySync
|
||||
draft: FollowupDraft
|
||||
messageID?: string
|
||||
optimisticBusy?: boolean
|
||||
|
|
@ -174,6 +174,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
|||
}
|
||||
|
||||
type PromptSubmitInput = {
|
||||
prompt: ReturnType<typeof usePrompt>
|
||||
info: Accessor<{ id: string } | undefined>
|
||||
imageAttachments: Accessor<ImageAttachmentPart[]>
|
||||
commentCount: Accessor<number>
|
||||
|
|
@ -211,14 +212,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||
const serverSync = useServerSync()
|
||||
const local = useLocal()
|
||||
const permission = usePermission()
|
||||
const prompt = usePrompt()
|
||||
const prompt = input.prompt
|
||||
const layout = useLayout()
|
||||
const language = useLanguage()
|
||||
const params = useParams()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const pendingKey = (sessionID: string) => ScopedKey.from(sdk.scope, sessionID)
|
||||
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
|
||||
|
||||
const errorMessage = (err: unknown) => {
|
||||
if (err && typeof err === "object" && "data" in err) {
|
||||
|
|
@ -233,8 +234,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||
const sessionID = params.id
|
||||
if (!sessionID) return Promise.resolve()
|
||||
|
||||
serverSync.todo.set(sessionID, [])
|
||||
const [, setStore] = serverSync.child(sdk.directory)
|
||||
serverSync().todo.set(sessionID, [])
|
||||
const [, setStore] = serverSync().child(sdk().directory)
|
||||
setStore("todo", sessionID, [])
|
||||
|
||||
input.onAbort?.()
|
||||
|
|
@ -247,8 +248,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||
pending.delete(key)
|
||||
return Promise.resolve()
|
||||
}
|
||||
return sdk.client.session
|
||||
.abort({
|
||||
return sdk()
|
||||
.client.session.abort({
|
||||
sessionID,
|
||||
})
|
||||
.catch(() => {})
|
||||
|
|
@ -281,7 +282,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||
}
|
||||
|
||||
const seed = (dir: string, info: Session) => {
|
||||
const [, setStore] = serverSync.child(dir)
|
||||
const [, setStore] = serverSync().child(dir)
|
||||
setStore("session", (list: Session[]) => {
|
||||
const result = Binary.search(list, info.id, (item) => item.id)
|
||||
const next = [...list]
|
||||
|
|
@ -321,13 +322,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||
input.addToHistory(currentPrompt, mode)
|
||||
input.resetHistoryNavigation()
|
||||
|
||||
const projectDirectory = sdk.directory
|
||||
const projectDirectory = sdk().directory
|
||||
const isNewSession = !params.id
|
||||
const shouldAutoAccept = isNewSession && input.autoAccept()
|
||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
||||
|
||||
let sessionDirectory = projectDirectory
|
||||
let client = sdk.client
|
||||
let client = sdk().client
|
||||
|
||||
if (isNewSession) {
|
||||
if (worktreeSelection === "create") {
|
||||
|
|
@ -349,7 +350,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||
})
|
||||
return
|
||||
}
|
||||
WorktreeState.pending(sdk.scope, createdWorktree.directory)
|
||||
WorktreeState.pending(sdk().scope, createdWorktree.directory)
|
||||
sessionDirectory = createdWorktree.directory
|
||||
}
|
||||
|
||||
|
|
@ -358,11 +359,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||
}
|
||||
|
||||
if (sessionDirectory !== projectDirectory) {
|
||||
client = sdk.createClient({
|
||||
client = sdk().createClient({
|
||||
directory: sessionDirectory,
|
||||
throwOnError: true,
|
||||
})
|
||||
serverSync.child(sessionDirectory)
|
||||
serverSync().child(sessionDirectory)
|
||||
}
|
||||
|
||||
input.onNewSessionWorktreeReset?.()
|
||||
|
|
@ -470,7 +471,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||
if (text.startsWith("/")) {
|
||||
const [cmdName, ...args] = text.split(" ")
|
||||
const commandName = cmdName.slice(1)
|
||||
const customCommand = sync.data.command.find((c) => c.name === commandName)
|
||||
const customCommand = sync().data.command.find((c) => c.name === commandName)
|
||||
if (customCommand) {
|
||||
clearInput()
|
||||
client.session
|
||||
|
|
@ -504,7 +505,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||
const messageID = Identifier.ascending("message")
|
||||
|
||||
const removeOptimisticMessage = () => {
|
||||
sync.session.optimistic.remove({
|
||||
sync().session.optimistic.remove({
|
||||
directory: sessionDirectory,
|
||||
sessionID: session.id,
|
||||
messageID,
|
||||
|
|
@ -515,17 +516,17 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||
clearInput()
|
||||
|
||||
const waitForWorktree = async () => {
|
||||
const worktree = WorktreeState.get(sdk.scope, sessionDirectory)
|
||||
const worktree = WorktreeState.get(sdk().scope, sessionDirectory)
|
||||
if (!worktree || worktree.status !== "pending") return true
|
||||
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync.set("session_status", session.id, { type: "busy" })
|
||||
sync().set("session_status", session.id, { type: "busy" })
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const cleanup = () => {
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync.set("session_status", session.id, { type: "idle" })
|
||||
sync().set("session_status", session.id, { type: "idle" })
|
||||
}
|
||||
removeOptimisticMessage()
|
||||
restoreCommentItems(commentItems)
|
||||
|
|
@ -559,12 +560,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||
}, timeoutMs)
|
||||
})
|
||||
|
||||
const result = await Promise.race([WorktreeState.wait(sdk.scope, sessionDirectory), abortWait, timeout]).finally(
|
||||
() => {
|
||||
if (timer.id === undefined) return
|
||||
clearTimeout(timer.id)
|
||||
},
|
||||
)
|
||||
const result = await Promise.race([
|
||||
WorktreeState.wait(sdk().scope, sessionDirectory),
|
||||
abortWait,
|
||||
timeout,
|
||||
]).finally(() => {
|
||||
if (timer.id === undefined) return
|
||||
clearTimeout(timer.id)
|
||||
})
|
||||
pending.delete(pendingKey(session.id))
|
||||
if (controller.signal.aborted) return false
|
||||
if (result.status === "failed") throw new Error(result.message)
|
||||
|
|
@ -573,8 +576,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||
|
||||
void sendFollowupDraft({
|
||||
client,
|
||||
sync,
|
||||
serverSync,
|
||||
sync: sync(),
|
||||
serverSync: serverSync(),
|
||||
draft,
|
||||
messageID,
|
||||
optimisticBusy: sessionDirectory === projectDirectory,
|
||||
|
|
@ -582,7 +585,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||
}).catch((err) => {
|
||||
pending.delete(pendingKey(session.id))
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync.set("session_status", session.id, { type: "idle" })
|
||||
sync().set("session_status", session.id, { type: "idle" })
|
||||
}
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||
pathFromTab: file.pathFromTab,
|
||||
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
||||
})
|
||||
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
|
||||
|
||||
const usd = createMemo(
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -96,13 +96,13 @@ export function SessionContextTab() {
|
|||
const providers = useProviders()
|
||||
const { params, view } = useSessionLayout()
|
||||
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
|
||||
|
||||
const messages = createMemo(
|
||||
() => {
|
||||
const id = params.id
|
||||
if (!id) return emptyMessages
|
||||
return (sync.data.message[id] ?? []) as Message[]
|
||||
return (sync().data.message[id] ?? []) as Message[]
|
||||
},
|
||||
emptyMessages,
|
||||
{ equals: same },
|
||||
|
|
@ -180,7 +180,7 @@ export function SessionContextTab() {
|
|||
if (!c?.input) return []
|
||||
return estimateSessionContextBreakdown({
|
||||
messages: messages(),
|
||||
parts: sync.data.part as Record<string, Part[] | undefined>,
|
||||
parts: sync().data.part as Record<string, Part[] | undefined>,
|
||||
input: c.input,
|
||||
systemPrompt: systemPrompt(),
|
||||
})
|
||||
|
|
@ -221,7 +221,7 @@ export function SessionContextTab() {
|
|||
let scroll: HTMLDivElement | undefined
|
||||
let frame: number | undefined
|
||||
let pending: { x: number; y: number } | undefined
|
||||
const getParts = (id: string) => (sync.data.part[id] ?? []) as Part[]
|
||||
const getParts = (id: string) => (sync().data.part[id] ?? []) as Part[]
|
||||
|
||||
const restoreScroll = () => {
|
||||
const el = scroll
|
||||
|
|
|
|||
|
|
@ -229,7 +229,7 @@ export function SessionHeader() {
|
|||
)
|
||||
const opening = createMemo(() => openRequest.app !== undefined)
|
||||
const tint = createMemo(() =>
|
||||
messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent),
|
||||
messageAgentColor(params.id ? sync().data.message[params.id] : undefined, sync().data.agent),
|
||||
)
|
||||
const v2ActionsState = createMemo<SessionHeaderV2ActionsState>(() => ({
|
||||
statusVisible: status(),
|
||||
|
|
|
|||
|
|
@ -20,24 +20,24 @@ export function NewSessionView(props: NewSessionViewProps) {
|
|||
const sdk = useSDK()
|
||||
const language = useLanguage()
|
||||
|
||||
const sandboxes = createMemo(() => sync.project?.sandboxes ?? [])
|
||||
const sandboxes = createMemo(() => sync().project?.sandboxes ?? [])
|
||||
const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE])
|
||||
const current = createMemo(() => {
|
||||
const selection = props.worktree
|
||||
if (options().includes(selection)) return selection
|
||||
return MAIN_WORKTREE
|
||||
})
|
||||
const projectRoot = createMemo(() => sync.project?.worktree ?? sdk.directory)
|
||||
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
|
||||
const isWorktree = createMemo(() => {
|
||||
const project = sync.project
|
||||
const project = sync().project
|
||||
if (!project) return false
|
||||
return sdk.directory !== project.worktree
|
||||
return sdk().directory !== project.worktree
|
||||
})
|
||||
|
||||
const label = (value: string) => {
|
||||
if (value === MAIN_WORKTREE) {
|
||||
if (isWorktree()) return language.t("session.new.worktree.main")
|
||||
const branch = sync.data.vcs?.branch
|
||||
const branch = sync().data.vcs?.branch
|
||||
if (branch) return language.t("session.new.worktree.mainWithBranch", { branch })
|
||||
return language.t("session.new.worktree.main")
|
||||
}
|
||||
|
|
@ -69,7 +69,7 @@ export function NewSessionView(props: NewSessionViewProps) {
|
|||
{label(current())}
|
||||
</div>
|
||||
</div>
|
||||
<Show when={sync.project}>
|
||||
<Show when={sync().project}>
|
||||
{(project) => (
|
||||
<div class="flex items-start justify-center gap-3 min-h-5">
|
||||
<div class="text-12-medium text-text-weak leading-5 min-w-0 max-w-160 break-words text-center">
|
||||
|
|
|
|||
|
|
@ -127,8 +127,8 @@ export const SettingsGeneral: Component = () => {
|
|||
|
||||
const [shells] = createResource(
|
||||
() =>
|
||||
serverSdk.client.pty
|
||||
.shells()
|
||||
serverSdk()
|
||||
.client.pty.shells()
|
||||
.then((res) => res.data ?? [])
|
||||
.catch(() => [] as ShellOption[]),
|
||||
{ initialValue: [] as ShellOption[] },
|
||||
|
|
@ -151,11 +151,11 @@ export const SettingsGeneral: Component = () => {
|
|||
})
|
||||
|
||||
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
|
||||
const currentShell = createMemo(() => serverSync.data.config.shell ?? "")
|
||||
const currentShell = createMemo(() => serverSync().data.config.shell ?? "")
|
||||
|
||||
const shellOptions = createMemo<ShellSelectOption[]>(() => {
|
||||
const list = shells.latest
|
||||
const current = serverSync.data.config.shell
|
||||
const current = serverSync().data.config.shell
|
||||
|
||||
const nameCounts = new Map<string, number>()
|
||||
for (const s of list) {
|
||||
|
|
@ -290,7 +290,7 @@ export const SettingsGeneral: Component = () => {
|
|||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
if (option.value === currentShell()) return
|
||||
serverSync.updateConfig({ shell: option.value })
|
||||
serverSync().updateConfig({ shell: option.value })
|
||||
}}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ const SettingsProvidersContent: Component = () => {
|
|||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||
|
||||
const isConfigCustom = (providerID: string) => {
|
||||
const provider = serverSync.data.config.provider?.[providerID]
|
||||
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
|
||||
|
|
@ -91,11 +91,11 @@ const SettingsProvidersContent: Component = () => {
|
|||
}
|
||||
|
||||
const disableProvider = async (providerID: string, name: string) => {
|
||||
const before = serverSync.data.config.disabled_providers ?? []
|
||||
const before = serverSync().data.config.disabled_providers ?? []
|
||||
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||
serverSync.set("config", "disabled_providers", next)
|
||||
serverSync().set("config", "disabled_providers", next)
|
||||
|
||||
await serverSync
|
||||
await serverSync()
|
||||
.updateConfig({ disabled_providers: next })
|
||||
.then(() => {
|
||||
showToast({
|
||||
|
|
@ -106,7 +106,7 @@ const SettingsProvidersContent: Component = () => {
|
|||
})
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
serverSync.set("config", "disabled_providers", before)
|
||||
serverSync().set("config", "disabled_providers", before)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
})
|
||||
|
|
@ -114,14 +114,16 @@ const SettingsProvidersContent: Component = () => {
|
|||
|
||||
const disconnect = async (providerID: string, name: string) => {
|
||||
if (isConfigCustom(providerID)) {
|
||||
await serverSDK.client.auth.remove({ providerID }).catch(() => undefined)
|
||||
await serverSDK()
|
||||
.client.auth.remove({ providerID })
|
||||
.catch(() => undefined)
|
||||
await disableProvider(providerID, name)
|
||||
return
|
||||
}
|
||||
await serverSDK.client.auth
|
||||
.remove({ providerID })
|
||||
await serverSDK()
|
||||
.client.auth.remove({ providerID })
|
||||
.then(async () => {
|
||||
await serverSDK.client.global.dispose()
|
||||
await serverSDK().client.global.dispose()
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ function SettingsServerDataProviders(props: ParentProps<{ server: ServerConnecti
|
|||
|
||||
return (
|
||||
<QueryClientProvider client={serverCtx().queryClient}>
|
||||
<ServerSDKProvider server={props.server}>
|
||||
<ServerSDKProvider server={() => props.server}>
|
||||
<ServerSyncProvider>
|
||||
<ModelsProvider>{props.children}</ModelsProvider>
|
||||
</ServerSyncProvider>
|
||||
|
|
|
|||
|
|
@ -126,8 +126,8 @@ export const SettingsGeneralV2: Component = () => {
|
|||
|
||||
const [shells] = createResource(
|
||||
() =>
|
||||
serverSdk.client.pty
|
||||
.shells()
|
||||
serverSdk()
|
||||
.client.pty.shells()
|
||||
.then((res) => res.data ?? [])
|
||||
.catch(() => [] as ShellOption[]),
|
||||
{ initialValue: [] as ShellOption[] },
|
||||
|
|
@ -144,11 +144,11 @@ export const SettingsGeneralV2: Component = () => {
|
|||
})
|
||||
|
||||
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
|
||||
const currentShell = createMemo(() => serverSync.data.config.shell ?? "")
|
||||
const currentShell = createMemo(() => serverSync().data.config.shell ?? "")
|
||||
|
||||
const shellOptions = createMemo<ShellSelectOption[]>(() => {
|
||||
const list = shells.latest
|
||||
const current = serverSync.data.config.shell
|
||||
const current = serverSync().data.config.shell
|
||||
|
||||
const nameCounts = new Map<string, number>()
|
||||
for (const s of list) {
|
||||
|
|
@ -275,7 +275,7 @@ export const SettingsGeneralV2: Component = () => {
|
|||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
if (option.value === currentShell()) return
|
||||
serverSync.updateConfig({ shell: option.value })
|
||||
serverSync().updateConfig({ shell: option.value })
|
||||
}}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ export const SettingsProvidersV2: Component = () => {
|
|||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||
|
||||
const isConfigCustom = (providerID: string) => {
|
||||
const provider = serverSync.data.config.provider?.[providerID]
|
||||
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
|
||||
|
|
@ -85,11 +85,11 @@ export const SettingsProvidersV2: Component = () => {
|
|||
}
|
||||
|
||||
const disableProvider = async (providerID: string, name: string) => {
|
||||
const before = serverSync.data.config.disabled_providers ?? []
|
||||
const before = serverSync().data.config.disabled_providers ?? []
|
||||
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||
serverSync.set("config", "disabled_providers", next)
|
||||
serverSync().set("config", "disabled_providers", next)
|
||||
|
||||
await serverSync
|
||||
await serverSync()
|
||||
.updateConfig({ disabled_providers: next })
|
||||
.then(() => {
|
||||
showToast({
|
||||
|
|
@ -100,7 +100,7 @@ export const SettingsProvidersV2: Component = () => {
|
|||
})
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
serverSync.set("config", "disabled_providers", before)
|
||||
serverSync().set("config", "disabled_providers", before)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
})
|
||||
|
|
@ -108,14 +108,16 @@ export const SettingsProvidersV2: Component = () => {
|
|||
|
||||
const disconnect = async (providerID: string, name: string) => {
|
||||
if (isConfigCustom(providerID)) {
|
||||
await serverSdk.client.auth.remove({ providerID }).catch(() => undefined)
|
||||
await serverSdk()
|
||||
.client.auth.remove({ providerID })
|
||||
.catch(() => undefined)
|
||||
await disableProvider(providerID, name)
|
||||
return
|
||||
}
|
||||
await serverSdk.client.auth
|
||||
.remove({ providerID })
|
||||
await serverSdk()
|
||||
.client.auth.remove({ providerID })
|
||||
.then(async () => {
|
||||
await serverSdk.client.global.dispose()
|
||||
await serverSdk().client.global.dispose()
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
|
|
|
|||
|
|
@ -279,13 +279,13 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||
const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health))
|
||||
const toggleMcp = useMcpToggle()
|
||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
||||
const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||
const mcpStatus = (name: string) => sync().data.mcp?.[name]?.status
|
||||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||
const lspItems = createMemo(() => sync.data.lsp ?? [])
|
||||
const lspItems = createMemo(() => sync().data.lsp ?? [])
|
||||
const lspCount = createMemo(() => lspItems().length)
|
||||
const plugins = createMemo(() =>
|
||||
(sync.data.config.plugin ?? []).map((item) => (typeof item === "string" ? item : item[0])),
|
||||
(sync().data.config.plugin ?? []).map((item) => (typeof item === "string" ? item : item[0])),
|
||||
)
|
||||
const pluginCount = createMemo(() => plugins().length)
|
||||
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
||||
|
|
|
|||
|
|
@ -18,9 +18,9 @@ export function StatusPopover() {
|
|||
const global = useGlobal()
|
||||
const sync = useSync()
|
||||
const [shown, setShown] = createSignal(false)
|
||||
const ready = createMemo(() => global.servers.health[server.key]?.healthy === false || sync.data.mcp_ready)
|
||||
const ready = createMemo(() => global.servers.health[server.key]?.healthy === false || sync().data.mcp_ready)
|
||||
const mcpIssue = createMemo(() => {
|
||||
const mcp = Object.values(sync.data.mcp ?? {})
|
||||
const mcp = Object.values(sync().data.mcp ?? {})
|
||||
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
|
||||
const warn = mcp.some((item) => item.status === "needs_auth")
|
||||
if (failed) return "critical" as const
|
||||
|
|
@ -86,9 +86,9 @@ function DirectoryStatusPopover() {
|
|||
const sync = useSync()
|
||||
const [shown, setShown] = createSignal(false)
|
||||
const serverHealth = () => global.servers.health[server.key]?.healthy
|
||||
const ready = createMemo(() => serverHealth() === false || sync.data.mcp_ready)
|
||||
const ready = createMemo(() => serverHealth() === false || sync().data.mcp_ready)
|
||||
const mcpIssue = createMemo(() => {
|
||||
const mcp = Object.values(sync.data.mcp ?? {})
|
||||
const mcp = Object.values(sync().data.mcp ?? {})
|
||||
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
|
||||
const warn = mcp.some((item) => item.status === "needs_auth")
|
||||
if (failed) return "critical" as const
|
||||
|
|
|
|||
|
|
@ -161,9 +161,9 @@ export const Terminal = (props: TerminalProps) => {
|
|||
const theme = useTheme()
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const url = sdk.url
|
||||
const directory = sdk().directory
|
||||
const client = sdk().client
|
||||
const url = sdk().url
|
||||
const auth = server.current?.http
|
||||
const username = auth?.username ?? "opencode"
|
||||
const password = auth?.password ?? ""
|
||||
|
|
|
|||
|
|
@ -291,7 +291,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||
item.type === "session" && item.server === route.server && item.sessionId === route.sessionId,
|
||||
)
|
||||
if (main) return main
|
||||
const sync = serverSync.createDirSyncContext(route.dir)
|
||||
const sync = serverSync().createDirSyncContext(route.dir)
|
||||
const session = sync.session.get(route.sessionId)
|
||||
if (session?.parentID) {
|
||||
const parentID = session.parentID
|
||||
|
|
@ -312,7 +312,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||
if (tab) return
|
||||
|
||||
if (route.type === "session") {
|
||||
const sync = serverSync.createDirSyncContext(route.dir)
|
||||
const sync = serverSync().createDirSyncContext(route.dir)
|
||||
const session = sync.session.get(route.sessionId)
|
||||
if (!session) return
|
||||
const sessionId = session.parentID ?? session.id
|
||||
|
|
|
|||
|
|
@ -208,7 +208,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
|
|||
const decoded = decodeSessionKey(key)
|
||||
return createRoot((dispose) => ({
|
||||
value: createCommentSession(
|
||||
serverSDK.scope,
|
||||
serverSDK().scope,
|
||||
decoded.dir,
|
||||
decoded.id === WORKSPACE_KEY ? undefined : decoded.id,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
|
||||
import { diffs as list, message as clean } from "@/utils/diffs"
|
||||
import { createServerSdkContext, useServerSDK } from "./server-sdk"
|
||||
import { type createServerSdkContext } from "./server-sdk"
|
||||
import { type createServerSyncContextInner } from "./server-sync"
|
||||
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
|
|
@ -174,7 +174,7 @@ function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: Opti
|
|||
export const createDirSyncContext = (
|
||||
directory: string,
|
||||
serverSync: ReturnType<typeof createServerSyncContextInner>,
|
||||
serverSDK: ReturnType<typeof createServerSdkContext> = useServerSDK(),
|
||||
serverSDK: ReturnType<typeof createServerSdkContext>,
|
||||
) => {
|
||||
const client = serverSDK.createClient({ directory, throwOnError: true })
|
||||
|
||||
|
|
|
|||
|
|
@ -62,10 +62,10 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||
const language = useLanguage()
|
||||
const layout = useLayout()
|
||||
|
||||
const scope = createMemo(() => sdk.directory)
|
||||
const scope = createMemo(() => sdk().directory)
|
||||
const path = createPathHelpers(scope)
|
||||
const tabs = layout.tabs(() =>
|
||||
SessionStateKey.from(serverSDK.scope, SessionRouteKey.fromRoute(params.dir, params.id)),
|
||||
SessionStateKey.from(serverSDK().scope, SessionRouteKey.fromRoute(params.dir, params.id)),
|
||||
)
|
||||
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
|
|
@ -78,7 +78,10 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||
const tree = createFileTreeStore({
|
||||
scope,
|
||||
normalizeDir: path.normalizeDir,
|
||||
list: (dir) => sdk.client.file.list({ path: dir }).then((x) => x.data ?? []),
|
||||
list: (dir) =>
|
||||
sdk()
|
||||
.client.file.list({ path: dir })
|
||||
.then((x) => x.data ?? []),
|
||||
onError: (message) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
|
|
@ -112,7 +115,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||
})
|
||||
})
|
||||
|
||||
const viewCache = createFileViewCache(serverSDK.scope)
|
||||
const viewCache = createFileViewCache(serverSDK().scope)
|
||||
const view = createMemo(() => viewCache.load(scope(), params.id))
|
||||
|
||||
const ensure = (file: string) => {
|
||||
|
|
@ -176,8 +179,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||
|
||||
setLoading(file)
|
||||
|
||||
const promise = sdk.client.file
|
||||
.read({ path: file })
|
||||
const promise = sdk()
|
||||
.client.file.read({ path: file })
|
||||
.then((x) => {
|
||||
if (scope() !== directory) return
|
||||
const content = x.data
|
||||
|
|
@ -200,12 +203,14 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||
}
|
||||
|
||||
const search = (query: string, dirs: "true" | "false") =>
|
||||
sdk.client.find.files({ query, dirs }).then(
|
||||
(x) => (x.data ?? []).map(path.normalize),
|
||||
() => [],
|
||||
)
|
||||
sdk()
|
||||
.client.find.files({ query, dirs })
|
||||
.then(
|
||||
(x) => (x.data ?? []).map(path.normalize),
|
||||
() => [],
|
||||
)
|
||||
|
||||
const stop = sdk.event.listen((e) => {
|
||||
const stop = sdk().event.listen((e) => {
|
||||
invalidateFromWatcher(e.details, {
|
||||
normalize: path.normalize,
|
||||
hasFile: (file) => Boolean(store.file[file]),
|
||||
|
|
|
|||
|
|
@ -246,7 +246,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||
}
|
||||
}
|
||||
|
||||
const target = Persist.serverGlobal(serverSdk.scope, "layout", ["layout.v6"])
|
||||
const target = Persist.serverGlobal(serverSdk().scope, "layout", ["layout.v6"])
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
{ ...target, migrate },
|
||||
createStore({
|
||||
|
|
@ -409,11 +409,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||
}
|
||||
|
||||
function enrich(project: { worktree: string; expanded: boolean }) {
|
||||
const [childStore] = serverSync.child(project.worktree, { bootstrap: false })
|
||||
const [childStore] = serverSync().child(project.worktree, { bootstrap: false })
|
||||
const projectID = childStore.project
|
||||
const metadata = projectID
|
||||
? serverSync.data.project.find((x) => x.id === projectID)
|
||||
: serverSync.data.project.find((x) => x.worktree === project.worktree)
|
||||
? serverSync().data.project.find((x) => x.id === projectID)
|
||||
: serverSync().data.project.find((x) => x.worktree === project.worktree)
|
||||
|
||||
// Preserve local icon override from per-workspace localStorage cache (childStore.icon).
|
||||
// Without this, different subdirectories of the same git repo would share the same
|
||||
|
|
@ -427,7 +427,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||
|
||||
const roots = createMemo(() => {
|
||||
const map = new Map<string, string>()
|
||||
for (const project of serverSync.data.project) {
|
||||
for (const project of serverSync().data.project) {
|
||||
const sandboxes = project.sandboxes ?? []
|
||||
for (const sandbox of sandboxes) {
|
||||
map.set(sandbox, project.worktree)
|
||||
|
|
@ -493,12 +493,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||
createEffect(() => {
|
||||
const projects = enriched()
|
||||
if (projects.length === 0) return
|
||||
if (!serverSync.ready) return
|
||||
if (!serverSync().ready) return
|
||||
|
||||
for (const project of projects) {
|
||||
if (!project.id) continue
|
||||
if (project.id === "global") continue
|
||||
serverSync.project.icon(project.worktree, project.icon?.override)
|
||||
serverSync().project.icon(project.worktree, project.icon?.override)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -532,12 +532,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||
colorRequested.set(worktree, color)
|
||||
|
||||
if (project.id === "global") {
|
||||
serverSync.project.meta(worktree, { icon: { color } })
|
||||
serverSync().project.meta(worktree, { icon: { color } })
|
||||
continue
|
||||
}
|
||||
|
||||
void serverSdk.client.project
|
||||
.update({ projectID: project.id, directory: worktree, icon: { color } })
|
||||
void serverSdk()
|
||||
.client.project.update({ projectID: project.id, directory: worktree, icon: { color } })
|
||||
.catch(() => {
|
||||
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
|
||||
})
|
||||
|
|
@ -554,7 +554,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||
sessionTimer = undefined
|
||||
void Promise.all(
|
||||
server.projects.list().map((project) => {
|
||||
return serverSync.project.loadSessions(project.worktree)
|
||||
return serverSync().project.loadSessions(project.worktree)
|
||||
}),
|
||||
)
|
||||
}, 0)
|
||||
|
|
@ -584,7 +584,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||
open(directory: string) {
|
||||
const root = rootFor(directory)
|
||||
if (server.projects.list().find((x) => x.worktree === root)) return
|
||||
void serverSync.project.loadSessions(root)
|
||||
void serverSync().project.loadSessions(root)
|
||||
server.projects.open(root)
|
||||
},
|
||||
close(directory: string) {
|
||||
|
|
|
|||
|
|
@ -64,12 +64,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
const models = useModels()
|
||||
|
||||
const id = createMemo(() => params.id || undefined)
|
||||
const list = createMemo(() => sync.data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
|
||||
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
|
||||
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
||||
|
||||
const [saved, setSaved] = persisted(
|
||||
{
|
||||
...Persist.serverWorkspace(serverSDK.scope, sdk.directory, "model-selection", ["model-selection.v1"]),
|
||||
...Persist.serverWorkspace(serverSDK().scope, sdk().directory, "model-selection", ["model-selection.v1"]),
|
||||
migrate,
|
||||
},
|
||||
createStore<Saved>({
|
||||
|
|
@ -124,14 +124,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
const scope = createMemo<State | undefined>(() => {
|
||||
const session = id()
|
||||
if (!session) return store.draft
|
||||
return saved.session[session] ?? handoff.get(handoffKey(serverSDK.scope, sdk.directory, session))
|
||||
return saved.session[session] ?? handoff.get(handoffKey(serverSDK().scope, sdk().directory, session))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const session = id()
|
||||
if (!session) return
|
||||
|
||||
const key = handoffKey(serverSDK.scope, sdk.directory, session)
|
||||
const key = handoffKey(serverSDK().scope, sdk().directory, session)
|
||||
const next = handoff.get(key)
|
||||
if (!next) return
|
||||
if (saved.session[session] !== undefined) {
|
||||
|
|
@ -144,8 +144,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
})
|
||||
|
||||
const configuredModel = () => {
|
||||
if (!sync.data.config.model) return
|
||||
const [providerID, modelID] = sync.data.config.model.split("/")
|
||||
const configured = sync().data.config.model
|
||||
if (!configured) return
|
||||
const [providerID, modelID] = configured.split("/")
|
||||
const model = { providerID, modelID }
|
||||
if (validModel(model)) return model
|
||||
}
|
||||
|
|
@ -363,7 +364,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
}
|
||||
|
||||
const result = {
|
||||
slug: createMemo(() => base64Encode(sdk.directory)),
|
||||
slug: createMemo(() => base64Encode(sdk().directory)),
|
||||
model,
|
||||
agent,
|
||||
session: {
|
||||
|
|
@ -374,13 +375,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
const next = clone(snapshot())
|
||||
if (!next) return
|
||||
|
||||
if (dir === sdk.directory) {
|
||||
if (dir === sdk().directory) {
|
||||
setSaved("session", session, next)
|
||||
setStore("draft", undefined)
|
||||
return
|
||||
}
|
||||
|
||||
handoff.set(handoffKey(serverSDK.scope, dir, session), next)
|
||||
handoff.set(handoffKey(serverSDK().scope, dir, session), next)
|
||||
setStore("draft", undefined)
|
||||
},
|
||||
restore(msg: { sessionID: string; agent: string; model: ModelKey }) {
|
||||
|
|
@ -388,7 +389,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
if (!session) return
|
||||
if (msg.sessionID !== session) return
|
||||
if (saved.session[session] !== undefined) return
|
||||
if (handoff.has(handoffKey(serverSDK.scope, sdk.directory, session))) return
|
||||
if (handoff.has(handoffKey(serverSDK().scope, sdk().directory, session))) return
|
||||
|
||||
setSaved("session", session, {
|
||||
agent: msg.agent,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export function useMcpToggle() {
|
|||
const language = useLanguage()
|
||||
|
||||
return useMutation(() => ({
|
||||
mutationFn: sync.mcp.toggle,
|
||||
mutationFn: sync().mcp.toggle,
|
||||
onError: (error) =>
|
||||
showToast({
|
||||
variant: "error",
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||
const currentSession = createMemo(() => params.id)
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.serverGlobal(serverSDK.scope, "notification", ["notification.v1"]),
|
||||
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
|
||||
createStore({
|
||||
list: [] as Notification[],
|
||||
}),
|
||||
|
|
@ -208,11 +208,11 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||
|
||||
const lookup = async (directory: string, sessionID?: string) => {
|
||||
if (!sessionID) return undefined
|
||||
const [syncStore] = serverSync.child(directory, { bootstrap: false })
|
||||
const [syncStore] = serverSync().child(directory, { bootstrap: false })
|
||||
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
|
||||
if (match.found) return syncStore.session[match.index]
|
||||
return serverSDK.client.session
|
||||
.get({ directory, sessionID })
|
||||
return serverSDK()
|
||||
.client.session.get({ directory, sessionID })
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
|
@ -286,7 +286,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||
})
|
||||
}
|
||||
|
||||
const unsub = serverSDK.event.listen((e) => {
|
||||
const unsub = serverSDK().event.listen((e) => {
|
||||
const event = e.details
|
||||
if (event.type !== "session.idle" && event.type !== "session.error") return
|
||||
|
||||
|
|
|
|||
|
|
@ -55,13 +55,13 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||
const permissionsEnabled = createMemo(() => {
|
||||
const directory = decode64(params.dir)
|
||||
if (!directory) return false
|
||||
const [store] = serverSync.child(directory)
|
||||
const [store] = serverSync().child(directory)
|
||||
return hasPermissionPromptRules(store.config.permission)
|
||||
})
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
{
|
||||
...Persist.serverGlobal(serverSDK.scope, "permission", ["permission.v3"]),
|
||||
...Persist.serverGlobal(serverSDK().scope, "permission", ["permission.v3"]),
|
||||
migrate(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value
|
||||
|
||||
|
|
@ -87,7 +87,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||
if (!ready()) return
|
||||
const directory = decode64(params.dir)
|
||||
if (!directory) return
|
||||
const [childStore] = serverSync.child(directory)
|
||||
const [childStore] = serverSync().child(directory)
|
||||
const perm = childStore.config.permission
|
||||
if (typeof perm === "string" && perm === "allow") {
|
||||
const key = directoryAcceptKey(directory)
|
||||
|
|
@ -119,9 +119,11 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||
}
|
||||
|
||||
const respond: PermissionRespondFn = (input) => {
|
||||
serverSDK.client.permission.respond(input).catch(() => {
|
||||
responded.delete(input.permissionID)
|
||||
})
|
||||
serverSDK()
|
||||
.client.permission.respond(input)
|
||||
.catch(() => {
|
||||
responded.delete(input.permissionID)
|
||||
})
|
||||
}
|
||||
|
||||
function respondOnce(permission: PermissionRequest, directory?: string) {
|
||||
|
|
@ -140,7 +142,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||
}
|
||||
|
||||
function isAutoAccepting(sessionID: string, directory?: string) {
|
||||
const session = directory ? serverSync.child(directory, { bootstrap: false })[0].session : []
|
||||
const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : []
|
||||
return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
|
||||
}
|
||||
|
||||
|
|
@ -149,7 +151,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||
}
|
||||
|
||||
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
|
||||
const session = directory ? serverSync.child(directory, { bootstrap: false })[0].session : []
|
||||
const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : []
|
||||
return autoRespondsPermission(store.autoAccept, session, permission, directory)
|
||||
}
|
||||
|
||||
|
|
@ -160,7 +162,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||
return next
|
||||
}
|
||||
|
||||
const unsubscribe = serverSDK.event.listen((e) => {
|
||||
const unsubscribe = serverSDK().event.listen((e) => {
|
||||
const event = e.details
|
||||
if (event?.type !== "permission.asked") return
|
||||
|
||||
|
|
@ -179,8 +181,8 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||
}),
|
||||
)
|
||||
|
||||
serverSDK.client.permission
|
||||
.list({ directory })
|
||||
serverSDK()
|
||||
.client.permission.list({ directory })
|
||||
.then((x) => {
|
||||
if (!isAutoAcceptingDirectory(directory)) return
|
||||
for (const perm of x.data ?? []) {
|
||||
|
|
@ -211,8 +213,8 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||
}),
|
||||
)
|
||||
|
||||
serverSDK.client.permission
|
||||
.list({ directory })
|
||||
serverSDK()
|
||||
.client.permission.list({ directory })
|
||||
.then((x) => {
|
||||
if (enableVersion.get(key) !== version) return
|
||||
if (!isAutoAccepting(sessionID, directory)) return
|
||||
|
|
@ -269,7 +271,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||
},
|
||||
permissionsEnabled,
|
||||
isPermissionAllowAll(directory: string) {
|
||||
const [childStore] = serverSync.child(directory)
|
||||
const [childStore] = serverSync().child(directory)
|
||||
const perm = childStore.config.permission
|
||||
return typeof perm === "string" && perm === "allow"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -153,6 +153,14 @@ const MAX_PROMPT_SESSIONS = 20
|
|||
|
||||
type PromptSession = ReturnType<typeof createPromptSession>
|
||||
|
||||
type PromptStore = {
|
||||
prompt: Prompt
|
||||
cursor?: number
|
||||
context: {
|
||||
items: (ContextItem & { key: string })[]
|
||||
}
|
||||
}
|
||||
|
||||
type Scope = { draftID: string } | { dir: string; id?: string }
|
||||
|
||||
function scopeKey(scope: Scope) {
|
||||
|
|
@ -174,25 +182,26 @@ function promptTarget(serverScope: ServerScope, scope: Scope) {
|
|||
function createPromptSession(serverScope: ServerScope, scope: Scope) {
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
promptTarget(serverScope, scope),
|
||||
createStore<{
|
||||
prompt: Prompt
|
||||
cursor?: number
|
||||
context: {
|
||||
items: (ContextItem & { key: string })[]
|
||||
}
|
||||
}>({
|
||||
prompt: clonePrompt(DEFAULT_PROMPT),
|
||||
cursor: undefined,
|
||||
context: {
|
||||
items: [],
|
||||
},
|
||||
}),
|
||||
createStore<PromptStore>(promptStore()),
|
||||
)
|
||||
|
||||
return { ready, ...createPromptStateValue(store, setStore) }
|
||||
}
|
||||
|
||||
function promptStore(): PromptStore {
|
||||
return {
|
||||
prompt: clonePrompt(DEFAULT_PROMPT),
|
||||
cursor: undefined,
|
||||
context: {
|
||||
items: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
|
||||
const actions = createPromptActions(setStore)
|
||||
|
||||
return {
|
||||
ready,
|
||||
current: () => store.prompt,
|
||||
cursor: createMemo(() => store.cursor),
|
||||
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
|
||||
|
|
@ -232,6 +241,15 @@ function createPromptSession(serverScope: ServerScope, scope: Scope) {
|
|||
}
|
||||
}
|
||||
|
||||
export function createPromptState() {
|
||||
const [store, setStore] = createStore<PromptStore>(promptStore())
|
||||
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
|
||||
return {
|
||||
ready: () => ready,
|
||||
...createPromptStateValue(store, setStore),
|
||||
}
|
||||
}
|
||||
|
||||
export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
|
||||
name: "Prompt",
|
||||
gate: false,
|
||||
|
|
@ -272,7 +290,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||
|
||||
const entry = createRoot(
|
||||
(dispose) => ({
|
||||
value: createPromptSession(serverSDK.scope, scope),
|
||||
value: createPromptSession(serverSDK().scope, scope),
|
||||
dispose,
|
||||
}),
|
||||
owner,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import { type Accessor, createMemo } from "solid-js"
|
||||
import { type ServerSDK, useServerSDK } from "./server-sdk"
|
||||
|
||||
export type DirectorySDK = ReturnType<ServerSDK["createDirSdkContext"]>
|
||||
|
||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
name: "SDK",
|
||||
init: (props: { directory: string }) => {
|
||||
// Resolves the directory-scoped SDK reactively from the (possibly changing) server.
|
||||
init: (props: { directory: string | Accessor<string> }) => {
|
||||
const serverSDK = useServerSDK()
|
||||
|
||||
return serverSDK.createDirSdkContext(props.directory)
|
||||
return createMemo(() => {
|
||||
const directory = typeof props.directory === "function" ? props.directory() : props.directory
|
||||
return serverSDK().createDirSdkContext(directory)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { Event } from "@opencode-ai/sdk/v2/client"
|
|||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { batch, onCleanup, onMount } from "solid-js"
|
||||
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { createSdkForServer } from "@/utils/server"
|
||||
import { useLanguage } from "./language"
|
||||
import { usePlatform } from "./platform"
|
||||
|
|
@ -21,7 +21,7 @@ export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: ()
|
|||
start()
|
||||
}
|
||||
|
||||
export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope) {
|
||||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope) {
|
||||
const platform = usePlatform()
|
||||
const abort = new AbortController()
|
||||
|
||||
|
|
@ -261,21 +261,31 @@ export function createServerSdkContext(server: ServerConnection.Any, scope: Serv
|
|||
}
|
||||
}
|
||||
|
||||
export type ServerSDK = ReturnType<typeof createServerSdkContext>
|
||||
type ServerSDKBase = ReturnType<typeof createServerSdkContextBase>
|
||||
export type ServerSDK = ServerSDKBase & {
|
||||
createDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
|
||||
}
|
||||
|
||||
export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope): ServerSDK {
|
||||
const sdk = createServerSdkContextBase(server, scope)
|
||||
return Object.assign(sdk, {
|
||||
createDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, sdk)),
|
||||
})
|
||||
}
|
||||
|
||||
export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleContext({
|
||||
name: "ServerSDK",
|
||||
init: (props: { server?: ServerConnection.Any }) => {
|
||||
// Returns an accessor so the resolved server can change reactively (e.g. a
|
||||
// /new-session draft retargeting its server) without re-instantiating the subtree.
|
||||
init: (props: { server?: Accessor<ServerConnection.Any | undefined> }) => {
|
||||
const global = useGlobal()
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
|
||||
const conn = props.server ?? server.current
|
||||
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
|
||||
|
||||
const ctx = global.createServerCtx(conn)
|
||||
return Object.assign(ctx.sdk, {
|
||||
createDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, ctx.sdk)),
|
||||
return createMemo<ServerSDK>(() => {
|
||||
const conn = props.server?.() ?? server.current
|
||||
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
|
||||
return global.createServerCtx(conn).sdk
|
||||
})
|
||||
},
|
||||
})
|
||||
|
|
@ -284,7 +294,7 @@ type SDKEventMap = {
|
|||
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||
}
|
||||
|
||||
function createDirSdkContext(directory: string, serverSDK: ServerSDK) {
|
||||
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
|
||||
const client = serverSDK.createClient({
|
||||
directory,
|
||||
throwOnError: true,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, Todo } from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { batch, getOwner, onCleanup, onMount, untrack } from "solid-js"
|
||||
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import type { InitError } from "../pages/error"
|
||||
import { ServerSDK, useServerSDK } from "./server-sdk"
|
||||
import { ServerSDK } from "./server-sdk"
|
||||
import {
|
||||
bootstrapDirectory,
|
||||
bootstrapGlobal,
|
||||
|
|
@ -84,8 +84,7 @@ function makeQueryOptionsApi(
|
|||
}
|
||||
export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
|
||||
|
||||
export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
|
||||
const serverSDK: ServerSDK = _serverSDK ?? useServerSDK()
|
||||
export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
const language = useLanguage()
|
||||
const owner = getOwner()
|
||||
if (!owner) throw new Error("ServerSync must be created within owner")
|
||||
|
|
@ -507,33 +506,37 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
|
|||
}
|
||||
}
|
||||
|
||||
export function createServerSyncContext(_serverSDK?: ServerSDK) {
|
||||
const inner = createServerSyncContextInner(_serverSDK)
|
||||
export function createServerSyncContext(serverSDK: ServerSDK) {
|
||||
const inner = createServerSyncContextInner(serverSDK)
|
||||
return Object.assign(inner, {
|
||||
createDirSyncContext: createRefCountMap(
|
||||
(dir) => createDirSyncContext(dir, inner, _serverSDK),
|
||||
(dir) => createDirSyncContext(dir, inner, serverSDK),
|
||||
(dir) => inner.disableMcp(dir),
|
||||
directoryKey,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
export type ServerSync = ReturnType<typeof createServerSyncContext>
|
||||
|
||||
export const { use: useServerSync, provider: ServerSyncProvider } = createSimpleContext({
|
||||
name: "ServerSync",
|
||||
gate: false,
|
||||
init: (props: { server?: ServerConnection.Any }) => {
|
||||
// Returns an accessor so the resolved server can change reactively without
|
||||
// re-instantiating the subtree (mirrors useServerSDK).
|
||||
init: (props: { server?: Accessor<ServerConnection.Any | undefined> }) => {
|
||||
const global = useGlobal()
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
|
||||
const conn = props.server ?? server.current
|
||||
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
|
||||
const ctx = global.createServerCtx(conn)
|
||||
|
||||
return ctx.sync
|
||||
return createMemo<ServerSync>(() => {
|
||||
const conn = props.server?.() ?? server.current
|
||||
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
|
||||
return global.createServerCtx(conn).sync
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export function useQueryOptions() {
|
||||
return useServerSync().queryOptions
|
||||
const sync = useServerSync()
|
||||
return createMemo(() => sync().queryOptions)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { createMemo } from "solid-js"
|
||||
import { useServerSync } from "./server-sync"
|
||||
import { useSDK } from "./sdk"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
|
|
@ -112,5 +113,7 @@ export const useSync = () => {
|
|||
const serverSync = useServerSync()
|
||||
const sdk = useSDK()
|
||||
|
||||
return serverSync.createDirSyncContext(sdk.directory)
|
||||
return createMemo(() => serverSync().createDirSyncContext(sdk().directory))
|
||||
}
|
||||
|
||||
export type DirectorySync = ReturnType<ReturnType<typeof useSync>>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { createStore, produce } from "solid-js/store"
|
|||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useSDK, type DirectorySDK } from "./sdk"
|
||||
import type { Platform } from "./platform"
|
||||
import { useServer } from "./server"
|
||||
import { defaultTitle, titleNumber } from "./terminal-title"
|
||||
|
|
@ -143,7 +143,7 @@ export function clearWorkspaceTerminals(
|
|||
}
|
||||
|
||||
function createWorkspaceTerminalSession(
|
||||
sdk: ReturnType<typeof useSDK>,
|
||||
sdk: DirectorySDK,
|
||||
dir: string,
|
||||
scope: ServerScopeValue,
|
||||
legacySessionID?: string,
|
||||
|
|
@ -202,7 +202,7 @@ function createWorkspaceTerminalSession(
|
|||
})
|
||||
onCleanup(unsub)
|
||||
|
||||
const update = (client: ReturnType<typeof useSDK>["client"], pty: Partial<LocalPTY> & { id: string }) => {
|
||||
const update = (client: DirectorySDK["client"], pty: Partial<LocalPTY> & { id: string }) => {
|
||||
const index = store.all.findIndex((x) => x.id === pty.id)
|
||||
const previous = index >= 0 ? store.all[index] : undefined
|
||||
if (index >= 0) {
|
||||
|
|
@ -223,7 +223,7 @@ function createWorkspaceTerminalSession(
|
|||
})
|
||||
}
|
||||
|
||||
const clone = async (client: ReturnType<typeof useSDK>["client"], id: string) => {
|
||||
const clone = async (client: DirectorySDK["client"], id: string) => {
|
||||
const index = store.all.findIndex((x) => x.id === id)
|
||||
const pty = store.all[index]
|
||||
if (!pty) return
|
||||
|
|
@ -412,7 +412,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
|||
}
|
||||
|
||||
const entry = createRoot((dispose) => ({
|
||||
value: createWorkspaceTerminalSession(sdk, dir, serverScope, legacySessionID),
|
||||
value: createWorkspaceTerminalSession(sdk(), dir, serverScope, legacySessionID),
|
||||
dispose,
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -22,10 +22,10 @@ export function useProviders() {
|
|||
const dir = createMemo(() => decode64(params.dir) ?? "")
|
||||
const providers = () => {
|
||||
if (dir()) {
|
||||
const [projectStore] = serverSync.child(dir())
|
||||
const [projectStore] = serverSync().child(dir())
|
||||
if (projectStore.provider_ready) return projectStore.provider
|
||||
}
|
||||
return serverSync.data.provider
|
||||
return serverSync().data.provider
|
||||
}
|
||||
return {
|
||||
all: () => providers().all,
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export function DirectoryDataProvider(props: ParentProps<{ directory: string; dr
|
|||
createEffect(() => {
|
||||
// A draft lives at /new-session?draftId=… and has no directory segment to normalize.
|
||||
if (props.draftID) return
|
||||
const next = sync.data.path.directory
|
||||
const next = sync().data.path.directory
|
||||
if (!next || next === props.directory) return
|
||||
const path = location.pathname.slice(slug().length + 1)
|
||||
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
|
||||
|
|
@ -28,12 +28,15 @@ export function DirectoryDataProvider(props: ParentProps<{ directory: string; dr
|
|||
|
||||
createResource(
|
||||
() => params.id,
|
||||
(id) => sync.session.sync(id).catch(() => {}),
|
||||
(id) =>
|
||||
sync()
|
||||
.session.sync(id)
|
||||
.catch(() => {}),
|
||||
)
|
||||
|
||||
return (
|
||||
<DataProvider
|
||||
data={sync.data}
|
||||
data={sync().data}
|
||||
directory={props.directory}
|
||||
onNavigateToSession={(sessionID: string) => navigate(`/${slug()}/session/${sessionID}`)}
|
||||
onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import { DialogSelectServer, useServerManagementController } from "@/components/
|
|||
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import {
|
||||
|
|
@ -80,7 +80,7 @@ const HOME_SEARCH_RESULT_META =
|
|||
let pendingHomeNavigation: { server: ServerConnection.Key; href: string } | undefined
|
||||
|
||||
function buildHomeSessionRecords(input: {
|
||||
sync: Pick<ReturnType<typeof useServerSync>, "child">
|
||||
sync: Pick<ServerSync, "child">
|
||||
projectDirectories: () => string[]
|
||||
projects: () => LocalProject[]
|
||||
projectByID: () => Map<string, LocalProject>
|
||||
|
|
@ -149,7 +149,7 @@ function HomeDesign() {
|
|||
if (!conn) return
|
||||
return global.createServerCtx(conn)
|
||||
})
|
||||
const focusedSync = () => focusedServerCtx()?.sync ?? sync
|
||||
const focusedSync = () => focusedServerCtx()?.sync ?? sync()
|
||||
const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
|
||||
const selectedProject = createMemo(() => projects().find((project) => project.worktree === state.selection.directory))
|
||||
const newSessionProject = createMemo(
|
||||
|
|
@ -1102,10 +1102,10 @@ function LegacyHome() {
|
|||
const global = useGlobal()
|
||||
const server = useServer()
|
||||
const language = useLanguage()
|
||||
const homedir = createMemo(() => sync.data.path.home)
|
||||
const homedir = createMemo(() => sync().data.path.home)
|
||||
const recent = createMemo(() => {
|
||||
return sync.data.project
|
||||
.slice()
|
||||
return sync()
|
||||
.data.project.slice()
|
||||
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
|
||||
.slice(0, 5)
|
||||
})
|
||||
|
|
@ -1164,7 +1164,7 @@ function LegacyHome() {
|
|||
{server.name}
|
||||
</Button>
|
||||
<Switch>
|
||||
<Match when={sync.data.project.length > 0}>
|
||||
<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>
|
||||
|
|
@ -1191,7 +1191,7 @@ function LegacyHome() {
|
|||
</ul>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={!sync.ready}>
|
||||
<Match when={!sync().ready}>
|
||||
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
|
||||
<div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
|
||||
<Button class="px-3" onClick={chooseProject}>
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ import { SidebarContent } from "./layout/sidebar-shell"
|
|||
export default function Layout(props: ParentProps) {
|
||||
const serverSDK = useServerSDK()
|
||||
const [store, setStore, , ready] = persisted(
|
||||
Persist.serverGlobal(serverSDK.scope, "layout.page", ["layout.page.v1"]),
|
||||
Persist.serverGlobal(serverSDK().scope, "layout.page", ["layout.page.v1"]),
|
||||
createStore({
|
||||
lastProjectSession: {} as { [directory: string]: { directory: string; id: string; at: number } },
|
||||
activeProject: undefined as string | undefined,
|
||||
|
|
@ -140,7 +140,7 @@ export default function Layout(props: ParentProps) {
|
|||
if (!slug) return { slug, dir: "" }
|
||||
const dir = decode64(slug)
|
||||
if (!dir) return { slug, dir: "" }
|
||||
const store = serverSync.peek(dir, { bootstrap: false })
|
||||
const store = serverSync().peek(dir, { bootstrap: false })
|
||||
return {
|
||||
slug,
|
||||
store,
|
||||
|
|
@ -213,7 +213,7 @@ export default function Layout(props: ParentProps) {
|
|||
active: () => state.hoverProject,
|
||||
el: () => state.nav?.querySelector<HTMLElement>("[data-component='sidebar-rail']") ?? state.nav,
|
||||
onActivate: (directory) => {
|
||||
serverSync.child(directory)
|
||||
serverSync().child(directory)
|
||||
setState("hoverProject", directory)
|
||||
},
|
||||
})
|
||||
|
|
@ -397,17 +397,17 @@ export default function Layout(props: ParentProps) {
|
|||
alertedAtBySession.delete(sessionKey)
|
||||
}
|
||||
|
||||
const unsub = serverSDK.event.listen((e) => {
|
||||
const unsub = serverSDK().event.listen((e) => {
|
||||
if (e.details?.type === "worktree.ready") {
|
||||
setBusy(e.name, false)
|
||||
WorktreeState.ready(serverSDK.scope, e.name)
|
||||
WorktreeState.ready(serverSDK().scope, e.name)
|
||||
return
|
||||
}
|
||||
|
||||
if (e.details?.type === "worktree.failed") {
|
||||
setBusy(e.name, false)
|
||||
WorktreeState.failed(
|
||||
serverSDK.scope,
|
||||
serverSDK().scope,
|
||||
e.name,
|
||||
e.details.properties?.message ?? language.t("common.requestFailed"),
|
||||
)
|
||||
|
|
@ -435,7 +435,7 @@ export default function Layout(props: ParentProps) {
|
|||
const props = e.details.properties
|
||||
if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return
|
||||
|
||||
const [store] = serverSync.child(directory, { bootstrap: false })
|
||||
const [store] = serverSync().child(directory, { bootstrap: false })
|
||||
const session = store.session.find((s) => s.id === props.sessionID)
|
||||
const sessionKey = `${directory}:${props.sessionID}`
|
||||
|
||||
|
|
@ -498,7 +498,7 @@ export default function Layout(props: ParentProps) {
|
|||
if (!currentDir() || !currentSession) return
|
||||
const sessionKey = `${currentDir()}:${currentSession}`
|
||||
dismissSessionAlert(sessionKey)
|
||||
const [store] = serverSync.child(currentDir(), { bootstrap: false })
|
||||
const [store] = serverSync().child(currentDir(), { bootstrap: false })
|
||||
const childSessions = store.session.filter((s) => s.parentID === currentSession)
|
||||
for (const child of childSessions) {
|
||||
dismissSessionAlert(`${currentDir()}:${child.id}`)
|
||||
|
|
@ -536,11 +536,11 @@ export default function Layout(props: ParentProps) {
|
|||
const direct = projects.find((p) => pathKey(p.worktree) === key)
|
||||
if (direct) return direct
|
||||
|
||||
const [child] = serverSync.child(directory, { bootstrap: false })
|
||||
const [child] = serverSync().child(directory, { bootstrap: false })
|
||||
const id = child.project
|
||||
if (!id) return
|
||||
|
||||
const meta = serverSync.data.project.find((p) => p.id === id)
|
||||
const meta = serverSync().data.project.find((p) => p.id === id)
|
||||
const root = meta?.worktree
|
||||
if (!root) return
|
||||
|
||||
|
|
@ -631,7 +631,7 @@ export default function Layout(props: ParentProps) {
|
|||
|
||||
const result: Session[] = []
|
||||
for (const dir of dirs) {
|
||||
const [dirStore] = serverSync.child(dir, { bootstrap: true })
|
||||
const [dirStore] = serverSync().child(dir, { bootstrap: true })
|
||||
const dirSessions = sortedRootSessions(dirStore, now)
|
||||
result.push(...dirSessions)
|
||||
}
|
||||
|
|
@ -683,10 +683,10 @@ export default function Layout(props: ParentProps) {
|
|||
|
||||
createEffect(() => {
|
||||
route()
|
||||
serverSDK.url
|
||||
serverSDK().url
|
||||
|
||||
prefetchToken.value += 1
|
||||
clearSessionPrefetchInflight(serverSDK.scope)
|
||||
clearSessionPrefetchInflight(serverSDK().scope)
|
||||
prefetchQueues.clear()
|
||||
})
|
||||
|
||||
|
|
@ -730,17 +730,17 @@ export default function Layout(props: ParentProps) {
|
|||
}
|
||||
|
||||
async function prefetchMessages(directory: string, sessionID: string, token: number) {
|
||||
const [store, setStore] = serverSync.child(directory, { bootstrap: false })
|
||||
const [store, setStore] = serverSync().child(directory, { bootstrap: false })
|
||||
|
||||
return runSessionPrefetch({
|
||||
scope: serverSDK.scope,
|
||||
scope: serverSDK().scope,
|
||||
directory,
|
||||
sessionID,
|
||||
task: (rev) =>
|
||||
retry(() => serverSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
|
||||
retry(() => serverSDK().client.session.messages({ directory, sessionID, limit: prefetchChunk }))
|
||||
.then((messages) => {
|
||||
if (prefetchToken.value !== token) return
|
||||
if (!isSessionPrefetchCurrent(serverSDK.scope, directory, sessionID, rev)) return
|
||||
if (!isSessionPrefetchCurrent(serverSDK().scope, directory, sessionID, rev)) return
|
||||
|
||||
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
||||
const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
|
||||
|
|
@ -755,9 +755,9 @@ export default function Layout(props: ParentProps) {
|
|||
}
|
||||
|
||||
if (stale.length > 0) {
|
||||
clearSessionPrefetch(serverSDK.scope, directory, stale)
|
||||
clearSessionPrefetch(serverSDK().scope, directory, stale)
|
||||
for (const id of stale) {
|
||||
serverSync.todo.set(id, undefined)
|
||||
serverSync().todo.set(id, undefined)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -767,7 +767,7 @@ export default function Layout(props: ParentProps) {
|
|||
sorted,
|
||||
)
|
||||
|
||||
if (!isSessionPrefetchCurrent(serverSDK.scope, directory, sessionID, rev)) return
|
||||
if (!isSessionPrefetchCurrent(serverSDK().scope, directory, sessionID, rev)) return
|
||||
|
||||
batch(() => {
|
||||
if (stale.length > 0) {
|
||||
|
|
@ -779,7 +779,7 @@ export default function Layout(props: ParentProps) {
|
|||
}
|
||||
|
||||
setStore("message", sessionID, reconcile(merged, { key: "id" }))
|
||||
setSessionPrefetch({ scope: serverSDK.scope, directory, sessionID, ...meta })
|
||||
setSessionPrefetch({ scope: serverSDK().scope, directory, sessionID, ...meta })
|
||||
|
||||
for (const message of items) {
|
||||
const currentParts = store.part[message.info.id] ?? []
|
||||
|
|
@ -822,9 +822,9 @@ export default function Layout(props: ParentProps) {
|
|||
const directory = session.directory
|
||||
if (!directory) return
|
||||
|
||||
const [store] = serverSync.child(directory, { bootstrap: false })
|
||||
const [store] = serverSync().child(directory, { bootstrap: false })
|
||||
const cached = untrack(() => {
|
||||
const info = getSessionPrefetch(serverSDK.scope, directory, session.id)
|
||||
const info = getSessionPrefetch(serverSDK().scope, directory, session.id)
|
||||
return shouldSkipSessionPrefetch({
|
||||
message: store.message[session.id] !== undefined,
|
||||
info,
|
||||
|
|
@ -927,7 +927,7 @@ export default function Layout(props: ParentProps) {
|
|||
if (!target) return
|
||||
|
||||
// warm up child store to prevent flicker
|
||||
serverSync.child(target.worktree)
|
||||
serverSync().child(target.worktree)
|
||||
void openProject(target.worktree)
|
||||
}
|
||||
|
||||
|
|
@ -936,7 +936,7 @@ export default function Layout(props: ParentProps) {
|
|||
const target = projects[index]
|
||||
if (!target) return
|
||||
|
||||
serverSync.child(target.worktree)
|
||||
serverSync().child(target.worktree)
|
||||
void openProject(target.worktree)
|
||||
}
|
||||
|
||||
|
|
@ -965,12 +965,12 @@ export default function Layout(props: ParentProps) {
|
|||
}
|
||||
|
||||
async function archiveSession(session: Session) {
|
||||
const [store, setStore] = serverSync.child(session.directory)
|
||||
const [store, setStore] = serverSync().child(session.directory)
|
||||
const sessions = store.session ?? []
|
||||
const index = sessions.findIndex((s) => s.id === session.id)
|
||||
const nextSession = sessions[index + 1] ?? sessions[index - 1]
|
||||
|
||||
await serverSDK.client.session.update({
|
||||
await serverSDK().client.session.update({
|
||||
directory: session.directory,
|
||||
sessionID: session.id,
|
||||
time: { archived: Date.now() },
|
||||
|
|
@ -1241,11 +1241,11 @@ export default function Layout(props: ParentProps) {
|
|||
)
|
||||
if (known) return known[0]
|
||||
|
||||
const [child] = serverSync.child(directory, { bootstrap: false })
|
||||
const [child] = serverSync().child(directory, { bootstrap: false })
|
||||
const id = child.project
|
||||
if (!id) return directory
|
||||
|
||||
const meta = serverSync.data.project.find((item) => item.id === id)
|
||||
const meta = serverSync().data.project.find((item) => item.id === id)
|
||||
return meta?.worktree ?? directory
|
||||
}
|
||||
|
||||
|
|
@ -1293,8 +1293,8 @@ export default function Layout(props: ParentProps) {
|
|||
}
|
||||
const refreshDirs = async (target?: string) => {
|
||||
if (!target || target === root || canOpen(target)) return canOpen(target)
|
||||
const listed = await serverSDK.client.worktree
|
||||
.list({ directory: root })
|
||||
const listed = await serverSDK()
|
||||
.client.worktree.list({ directory: root })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => [] as string[])
|
||||
dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
|
||||
|
|
@ -1302,14 +1302,14 @@ export default function Layout(props: ParentProps) {
|
|||
}
|
||||
const openSession = async (target: { directory: string; id: string }) => {
|
||||
if (!canOpen(target.directory)) return false
|
||||
const [data] = serverSync.child(target.directory, { bootstrap: false })
|
||||
const [data] = serverSync().child(target.directory, { bootstrap: false })
|
||||
if (data.session.some((item) => item.id === target.id)) {
|
||||
setStore("lastProjectSession", root, { directory: target.directory, id: target.id, at: Date.now() })
|
||||
navigateWithSidebarReset(`/${base64Encode(target.directory)}/session/${target.id}`)
|
||||
return true
|
||||
}
|
||||
const resolved = await serverSDK.client.session
|
||||
.get({ sessionID: target.id })
|
||||
const resolved = await serverSDK()
|
||||
.client.session.get({ sessionID: target.id })
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
if (!resolved?.directory) return false
|
||||
|
|
@ -1328,7 +1328,7 @@ export default function Layout(props: ParentProps) {
|
|||
}
|
||||
|
||||
const latest = latestRootSession(
|
||||
dirs.map((item) => serverSync.child(item, { bootstrap: false })[0]),
|
||||
dirs.map((item) => serverSync().child(item, { bootstrap: false })[0]),
|
||||
Date.now(),
|
||||
)
|
||||
if (latest && (await openSession(latest))) {
|
||||
|
|
@ -1339,8 +1339,8 @@ export default function Layout(props: ParentProps) {
|
|||
await Promise.all(
|
||||
dirs.map(async (item) => ({
|
||||
path: { directory: item },
|
||||
session: await serverSDK.client.session
|
||||
.list({ directory: item })
|
||||
session: await serverSDK()
|
||||
.client.session.list({ directory: item })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => []),
|
||||
})),
|
||||
|
|
@ -1402,11 +1402,11 @@ export default function Layout(props: ParentProps) {
|
|||
const name = next === getFilename(project.worktree) ? "" : next
|
||||
|
||||
if (project.id && project.id !== "global") {
|
||||
await serverSDK.client.project.update({ projectID: project.id, directory: project.worktree, name })
|
||||
await serverSDK().client.project.update({ projectID: project.id, directory: project.worktree, name })
|
||||
return
|
||||
}
|
||||
|
||||
serverSync.project.meta(project.worktree, { name })
|
||||
serverSync().project.meta(project.worktree, { name })
|
||||
}
|
||||
|
||||
const renameWorkspace = (directory: string, next: string, projectId?: string, branch?: string) => {
|
||||
|
|
@ -1495,8 +1495,8 @@ export default function Layout(props: ParentProps) {
|
|||
|
||||
setBusy(directory, true)
|
||||
|
||||
const result = await serverSDK.client.worktree
|
||||
.remove({ directory: root, worktreeRemoveInput: { directory } })
|
||||
const result = await serverSDK()
|
||||
.client.worktree.remove({ directory: root, worktreeRemoveInput: { directory } })
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
|
|
@ -1514,7 +1514,7 @@ export default function Layout(props: ParentProps) {
|
|||
clearLastProjectSession(root)
|
||||
}
|
||||
|
||||
serverSync.set(
|
||||
serverSync().set(
|
||||
"project",
|
||||
produce((draft) => {
|
||||
const project = draft.find((item) => item.worktree === root)
|
||||
|
|
@ -1553,8 +1553,8 @@ export default function Layout(props: ParentProps) {
|
|||
})
|
||||
const dismiss = () => toaster.dismiss(progress)
|
||||
|
||||
const sessions: Session[] = await serverSDK.client.session
|
||||
.list({ directory })
|
||||
const sessions: Session[] = await serverSDK()
|
||||
.client.session.list({ directory })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => [])
|
||||
|
||||
|
|
@ -1562,12 +1562,14 @@ export default function Layout(props: ParentProps) {
|
|||
directory,
|
||||
sessions.map((s) => s.id),
|
||||
platform,
|
||||
serverSDK.scope,
|
||||
serverSDK().scope,
|
||||
)
|
||||
await serverSDK.client.instance.dispose({ directory }).catch(() => undefined)
|
||||
await serverSDK()
|
||||
.client.instance.dispose({ directory })
|
||||
.catch(() => undefined)
|
||||
|
||||
const result = await serverSDK.client.worktree
|
||||
.reset({ directory: root, worktreeResetInput: { directory } })
|
||||
const result = await serverSDK()
|
||||
.client.worktree.reset({ directory: root, worktreeResetInput: { directory } })
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
|
|
@ -1588,8 +1590,8 @@ export default function Layout(props: ParentProps) {
|
|||
sessions
|
||||
.filter((session) => session.time.archived === undefined)
|
||||
.map((session) =>
|
||||
serverSDK.client.session
|
||||
.update({
|
||||
serverSDK()
|
||||
.client.session.update({
|
||||
sessionID: session.id,
|
||||
directory: session.directory,
|
||||
time: { archived: archivedAt },
|
||||
|
|
@ -1629,8 +1631,8 @@ export default function Layout(props: ParentProps) {
|
|||
})
|
||||
|
||||
onMount(() => {
|
||||
serverSDK.client.vcs
|
||||
.status({ directory: props.directory })
|
||||
serverSDK()
|
||||
.client.vcs.status({ directory: props.directory })
|
||||
.then((x) => {
|
||||
const files = x.data ?? []
|
||||
const dirty = files.length > 0
|
||||
|
|
@ -1688,8 +1690,8 @@ export default function Layout(props: ParentProps) {
|
|||
})
|
||||
|
||||
const refresh = async () => {
|
||||
const sessions = await serverSDK.client.session
|
||||
.list({ directory: props.directory })
|
||||
const sessions = await serverSDK()
|
||||
.client.session.list({ directory: props.directory })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => [])
|
||||
const active = sessions.filter((session) => session.time.archived === undefined)
|
||||
|
|
@ -1697,8 +1699,8 @@ export default function Layout(props: ParentProps) {
|
|||
}
|
||||
|
||||
onMount(() => {
|
||||
serverSDK.client.vcs
|
||||
.status({ directory: props.directory })
|
||||
serverSDK()
|
||||
.client.vcs.status({ directory: props.directory })
|
||||
.then((x) => {
|
||||
const files = x.data ?? []
|
||||
const dirty = files.length > 0
|
||||
|
|
@ -1831,7 +1833,7 @@ export default function Layout(props: ParentProps) {
|
|||
const next = new Set(dirs)
|
||||
for (const directory of next) {
|
||||
if (loadedSessionDirs.has(directory)) continue
|
||||
void serverSync.project.loadSessions(directory)
|
||||
void serverSync().project.loadSessions(directory)
|
||||
}
|
||||
|
||||
loadedSessionDirs.clear()
|
||||
|
|
@ -1876,7 +1878,7 @@ export default function Layout(props: ParentProps) {
|
|||
directory && pathKey(directory) !== pathKey(local) && !dirs.some((item) => pathKey(item) === pathKey(directory))
|
||||
? directory
|
||||
: undefined
|
||||
const pending = extra ? WorktreeState.get(serverSDK.scope, extra)?.status === "pending" : false
|
||||
const pending = extra ? WorktreeState.get(serverSDK().scope, extra)?.status === "pending" : false
|
||||
|
||||
const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree])
|
||||
if (pending && extra) return [local, extra, ...ordered.filter((item) => item !== local)]
|
||||
|
|
@ -1928,8 +1930,8 @@ export default function Layout(props: ParentProps) {
|
|||
|
||||
const createWorkspace = async (project: LocalProject) => {
|
||||
clearSidebarHoverState()
|
||||
const created = await serverSDK.client.worktree
|
||||
.create({ directory: project.worktree })
|
||||
const created = await serverSDK()
|
||||
.client.worktree.create({ directory: project.worktree })
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
|
|
@ -1948,7 +1950,7 @@ export default function Layout(props: ParentProps) {
|
|||
const root = pathKey(local)
|
||||
|
||||
setBusy(created.directory, true)
|
||||
WorktreeState.pending(serverSDK.scope, created.directory)
|
||||
WorktreeState.pending(serverSDK().scope, created.directory)
|
||||
setStore("workspaceExpanded", key, true)
|
||||
if (key !== created.directory) {
|
||||
setStore("workspaceExpanded", created.directory, true)
|
||||
|
|
@ -1962,7 +1964,7 @@ export default function Layout(props: ParentProps) {
|
|||
return [created.directory, ...next]
|
||||
})
|
||||
|
||||
serverSync.child(created.directory)
|
||||
serverSync().child(created.directory)
|
||||
navigateWithSidebarReset(`/${base64Encode(created.directory)}/session`)
|
||||
}
|
||||
|
||||
|
|
@ -2067,7 +2069,7 @@ export default function Layout(props: ParentProps) {
|
|||
if (!item) return false
|
||||
return item.vcs === "git" || layout.sidebar.workspaces(item.worktree)()
|
||||
})
|
||||
const homedir = createMemo(() => serverSync.data.path.home)
|
||||
const homedir = createMemo(() => serverSync().data.path.home)
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export function useSessionTabAvatarState(
|
|||
const permission = usePermission()
|
||||
const hasPermissions = createMemo(() => {
|
||||
if (!active()) return false
|
||||
const [store] = globalSync.child(directory(), { bootstrap: false })
|
||||
const [store] = globalSync().child(directory(), { bootstrap: false })
|
||||
return !!sessionPermissionRequest(store.session, store.permission, sessionId(), (item) => {
|
||||
return !permission.autoResponds(item, directory())
|
||||
})
|
||||
|
|
@ -23,7 +23,7 @@ export function useSessionTabAvatarState(
|
|||
const loading = createMemo(() => {
|
||||
if (!active()) return false
|
||||
if (hasPermissions()) return false
|
||||
const [store] = globalSync.child(directory(), { bootstrap: false })
|
||||
const [store] = globalSync().child(directory(), { bootstrap: false })
|
||||
return store.session_working(sessionId())
|
||||
})
|
||||
return { unread, loading }
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export const ProjectIcon = (props: {
|
|||
const hasError = createMemo(() => dirs().some((directory) => notification.project.unseenHasError(directory)))
|
||||
const hasPermissions = createMemo(() =>
|
||||
dirs().some((directory) => {
|
||||
const [store] = serverSync.child(directory, { bootstrap: false })
|
||||
const [store] = serverSync().child(directory, { bootstrap: false })
|
||||
return hasProjectPermissions(store.permission, (item) => !permission.autoResponds(item, directory))
|
||||
}),
|
||||
)
|
||||
|
|
@ -149,7 +149,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
|||
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.directory)
|
||||
const [sessionStore] = serverSync().child(props.session.directory)
|
||||
const hasPermissions = createMemo(() => {
|
||||
return !!sessionPermissionRequest(sessionStore.session, sessionStore.permission, props.session.id, (item) => {
|
||||
return !permission.autoResponds(item, props.session.directory)
|
||||
|
|
|
|||
|
|
@ -294,23 +294,23 @@ export const SortableProject = (props: {
|
|||
const hoverOpen = () => isHoverProject() && preview() && !selected() && !state.menu
|
||||
|
||||
const label = (directory: string) => {
|
||||
const [data] = serverSync.child(directory, { bootstrap: false })
|
||||
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 projectStore = createMemo(() => serverSync().child(props.project.worktree, { bootstrap: false })[0])
|
||||
const isWorking = createMemo(() =>
|
||||
dirs().some((directory) => {
|
||||
const [store] = serverSync.child(directory, { bootstrap: false })
|
||||
const [store] = serverSync().child(directory, { bootstrap: false })
|
||||
return Object.keys(store.session_status).some((id) => store.session_working(id))
|
||||
}),
|
||||
)
|
||||
const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()))
|
||||
const workspaceSessions = (directory: string) => {
|
||||
const [data] = serverSync.child(directory, { bootstrap: false })
|
||||
const [data] = serverSync().child(directory, { bootstrap: false })
|
||||
return sortedRootSessions(data, props.sortNow())
|
||||
}
|
||||
const tile = () => (
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ export const WorkspaceDragOverlay = (props: {
|
|||
const directory = props.activeWorkspace()
|
||||
if (!directory) return
|
||||
|
||||
const [workspaceStore] = serverSync.child(directory, { bootstrap: false })
|
||||
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)
|
||||
|
|
@ -303,7 +303,7 @@ export const SortableWorkspace = (props: {
|
|||
const queryOptions = useQueryOptions()
|
||||
const language = useLanguage()
|
||||
const sortable = createSortable(props.directory)
|
||||
const [workspaceStore, setWorkspaceStore] = serverSync.child(props.directory, { bootstrap: false })
|
||||
const [workspaceStore, setWorkspaceStore] = serverSync().child(props.directory, { bootstrap: false })
|
||||
const [menu, setMenu] = createStore({
|
||||
open: false,
|
||||
pendingRename: false,
|
||||
|
|
@ -321,14 +321,14 @@ export const SortableWorkspace = (props: {
|
|||
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 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)
|
||||
await serverSync().project.loadSessions(props.directory)
|
||||
}
|
||||
|
||||
const workspaceEditActive = createMemo(() => props.ctx.editorOpen(`workspace:${props.directory}`))
|
||||
|
|
@ -357,7 +357,7 @@ export const SortableWorkspace = (props: {
|
|||
|
||||
createEffect(() => {
|
||||
if (!boot()) return
|
||||
serverSync.child(props.directory, { bootstrap: true })
|
||||
serverSync().child(props.directory, { bootstrap: true })
|
||||
})
|
||||
|
||||
return (
|
||||
|
|
@ -450,18 +450,18 @@ export const LocalWorkspace = (props: {
|
|||
const queryOptions = useQueryOptions()
|
||||
const language = useLanguage()
|
||||
const workspace = createMemo(() => {
|
||||
const [store, setStore] = serverSync.child(props.project.worktree)
|
||||
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 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)
|
||||
await serverSync().project.loadSessions(props.project.worktree)
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -30,8 +30,8 @@ export default function NewSessionPage() {
|
|||
|
||||
const newSessionWorktree = createMemo(() => {
|
||||
if (store.worktree === "create") return "create"
|
||||
const project = sync.project
|
||||
if (project && sdk.directory !== project.worktree) return sdk.directory
|
||||
const project = sync().project
|
||||
if (project && sdk().directory !== project.worktree) return sdk().directory
|
||||
return "main"
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -268,10 +268,10 @@ export default function Page() {
|
|||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||
}
|
||||
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
|
||||
const isChildSession = createMemo(() => !!info()?.parentID)
|
||||
const diffs = createMemo(() => (params.id ? list(sync.data.session_diff[params.id]) : []))
|
||||
const canReview = createMemo(() => !!sync.project)
|
||||
const diffs = createMemo(() => (params.id ? list(sync().data.session_diff[params.id]) : []))
|
||||
const canReview = createMemo(() => !!sync().project)
|
||||
const reviewTab = createMemo(() => isDesktop())
|
||||
const tabState = createSessionTabs({
|
||||
tabs,
|
||||
|
|
@ -283,21 +283,21 @@ export default function Page() {
|
|||
const activeTab = tabState.activeTab
|
||||
const activeFileTab = tabState.activeFileTab
|
||||
const revertMessageID = createMemo(() => info()?.revert?.messageID)
|
||||
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
|
||||
const messagesReady = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return true
|
||||
return sync.data.message[id] !== undefined
|
||||
return sync().data.message[id] !== undefined
|
||||
})
|
||||
const historyMore = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return false
|
||||
return sync.session.history.more(id)
|
||||
return sync().session.history.more(id)
|
||||
})
|
||||
const historyLoading = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return false
|
||||
return sync.session.history.loading(id)
|
||||
return sync().session.history.loading(id)
|
||||
})
|
||||
const userMessages = createMemo(
|
||||
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
||||
|
|
@ -357,7 +357,7 @@ export default function Page() {
|
|||
})
|
||||
|
||||
const [followup, setFollowup] = persisted(
|
||||
Persist.serverWorkspace(serverSDK.scope, sdk.directory, "followup", ["followup.v1"]),
|
||||
Persist.serverWorkspace(serverSDK().scope, sdk().directory, "followup", ["followup.v1"]),
|
||||
createStore<{
|
||||
items: Record<string, FollowupItem[] | undefined>
|
||||
failed: Record<string, string | undefined>
|
||||
|
|
@ -404,16 +404,16 @@ export default function Page() {
|
|||
}, desktopReviewOpen())
|
||||
|
||||
const turnDiffs = createMemo(() => list(lastUserMessage()?.summary?.diffs))
|
||||
const nogit = createMemo(() => !!sync.project && sync.project.vcs !== "git")
|
||||
const nogit = createMemo(() => {
|
||||
const project = sync().project
|
||||
return !!project && project.vcs !== "git"
|
||||
})
|
||||
const changesOptions = createMemo<ChangeMode[]>(() => {
|
||||
const list: ChangeMode[] = []
|
||||
if (sync.project?.vcs === "git") list.push("git")
|
||||
if (
|
||||
sync.project?.vcs === "git" &&
|
||||
sync.data.vcs?.branch &&
|
||||
sync.data.vcs?.default_branch &&
|
||||
sync.data.vcs.branch !== sync.data.vcs.default_branch
|
||||
) {
|
||||
const project = sync().project
|
||||
const vcs = sync().data.vcs
|
||||
if (project?.vcs === "git") list.push("git")
|
||||
if (project?.vcs === "git" && vcs?.branch && vcs?.default_branch && vcs.branch !== vcs.default_branch) {
|
||||
list.push("branch")
|
||||
}
|
||||
list.push("turn")
|
||||
|
|
@ -429,19 +429,20 @@ export default function Page() {
|
|||
if (store.changes === "git" || store.changes === "branch") return store.changes
|
||||
})
|
||||
const vcsKey = createMemo(
|
||||
() => ["session-vcs", sdk.directory, sync.data.vcs?.branch ?? "", sync.data.vcs?.default_branch ?? ""] as const,
|
||||
() =>
|
||||
["session-vcs", sdk().directory, sync().data.vcs?.branch ?? "", sync().data.vcs?.default_branch ?? ""] as const,
|
||||
)
|
||||
const vcsQuery = createQuery(() => {
|
||||
const mode = vcsMode()
|
||||
const enabled = wantsReview() && sync.project?.vcs === "git"
|
||||
const enabled = wantsReview() && sync().project?.vcs === "git"
|
||||
|
||||
return {
|
||||
queryKey: [...vcsKey(), mode] as const,
|
||||
enabled,
|
||||
queryFn: mode
|
||||
? () =>
|
||||
sdk.client.vcs
|
||||
.diff({ mode })
|
||||
sdk()
|
||||
.client.vcs.diff({ mode })
|
||||
.then((result) => list(result.data))
|
||||
.catch((error) => {
|
||||
console.debug("[session-review] failed to load vcs diff", { mode, error })
|
||||
|
|
@ -466,8 +467,8 @@ export default function Page() {
|
|||
|
||||
const newSessionWorktree = createMemo(() => {
|
||||
if (store.newSessionWorktree === "create") return "create"
|
||||
const project = sync.project
|
||||
if (project && sdk.directory !== project.worktree) return sdk.directory
|
||||
const project = sync().project
|
||||
if (project && sdk().directory !== project.worktree) return sdk().directory
|
||||
return "main"
|
||||
})
|
||||
|
||||
|
|
@ -529,11 +530,11 @@ export default function Page() {
|
|||
}
|
||||
|
||||
function upsert(next: Project) {
|
||||
const list = serverSync.data.project
|
||||
sync.set("project", next.id)
|
||||
const list = serverSync().data.project
|
||||
sync().set("project", next.id)
|
||||
const idx = list.findIndex((item) => item.id === next.id)
|
||||
if (idx >= 0) {
|
||||
serverSync.set(
|
||||
serverSync().set(
|
||||
"project",
|
||||
list.map((item, i) => (i === idx ? { ...item, ...next } : item)),
|
||||
)
|
||||
|
|
@ -541,14 +542,14 @@ export default function Page() {
|
|||
}
|
||||
const at = list.findIndex((item) => item.id > next.id)
|
||||
if (at >= 0) {
|
||||
serverSync.set("project", [...list.slice(0, at), next, ...list.slice(at)])
|
||||
serverSync().set("project", [...list.slice(0, at), next, ...list.slice(at)])
|
||||
return
|
||||
}
|
||||
serverSync.set("project", [...list, next])
|
||||
serverSync().set("project", [...list, next])
|
||||
}
|
||||
|
||||
const gitMutation = useMutation(() => ({
|
||||
mutationFn: () => sdk.client.project.initGit(),
|
||||
mutationFn: () => sdk().client.project.initGit(),
|
||||
onSuccess: (x) => {
|
||||
if (!x.data) return
|
||||
upsert(x.data)
|
||||
|
|
@ -593,7 +594,7 @@ export default function Page() {
|
|||
const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
|
||||
|
||||
const [sessionSync] = createResource(
|
||||
() => [sdk.directory, params.id] as const,
|
||||
() => [sdk().directory, params.id] as const,
|
||||
([directory, id]) => {
|
||||
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
|
||||
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
|
||||
|
|
@ -601,11 +602,11 @@ export default function Page() {
|
|||
refreshTimer = undefined
|
||||
if (!id) return
|
||||
|
||||
const cached = untrack(() => sync.data.message[id] !== undefined)
|
||||
const cached = untrack(() => sync().data.message[id] !== undefined)
|
||||
const stale = !cached
|
||||
? false
|
||||
: (() => {
|
||||
const info = getSessionPrefetch(serverSDK.scope, directory, id)
|
||||
const info = getSessionPrefetch(serverSDK().scope, directory, id)
|
||||
if (!info) return true
|
||||
return Date.now() - info.at > SESSION_PREFETCH_TTL
|
||||
})()
|
||||
|
|
@ -616,12 +617,12 @@ export default function Page() {
|
|||
refreshTimer = undefined
|
||||
if (params.id !== id) return
|
||||
untrack(() => {
|
||||
if (stale) void sync.session.sync(id, { force: true })
|
||||
if (stale) void sync().session.sync(id, { force: true })
|
||||
})
|
||||
}, 0)
|
||||
})
|
||||
|
||||
return sync.session.sync(id)
|
||||
return sync().session.sync(id)
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -630,9 +631,9 @@ export default function Page() {
|
|||
() => {
|
||||
const id = params.id
|
||||
return [
|
||||
sdk.directory,
|
||||
sdk().directory,
|
||||
id,
|
||||
id ? (sync.data.session_status[id]?.type ?? "idle") : "idle",
|
||||
id ? (sync().data.session_status[id]?.type ?? "idle") : "idle",
|
||||
id ? composer.blocked() : false,
|
||||
] as const
|
||||
},
|
||||
|
|
@ -643,15 +644,17 @@ export default function Page() {
|
|||
todoTimer = undefined
|
||||
if (!id) return
|
||||
if (status === "idle" && !blocked) return
|
||||
const cached = untrack(() => sync.data.todo[id] !== undefined || serverSync.data.session_todo[id] !== undefined)
|
||||
const cached = untrack(
|
||||
() => sync().data.todo[id] !== undefined || serverSync().data.session_todo[id] !== undefined,
|
||||
)
|
||||
|
||||
todoFrame = requestAnimationFrame(() => {
|
||||
todoFrame = undefined
|
||||
todoTimer = window.setTimeout(() => {
|
||||
todoTimer = undefined
|
||||
if (sdk.directory !== dir || params.id !== id) return
|
||||
if (sdk().directory !== dir || params.id !== id) return
|
||||
untrack(() => {
|
||||
void sync.session.todo(id, cached ? { force: true } : undefined)
|
||||
void sync().session.todo(id, cached ? { force: true } : undefined)
|
||||
})
|
||||
}, 0)
|
||||
})
|
||||
|
|
@ -684,7 +687,7 @@ export default function Page() {
|
|||
),
|
||||
)
|
||||
|
||||
const stopVcs = sdk.event.listen((evt) => {
|
||||
const stopVcs = sdk().event.listen((evt) => {
|
||||
if (evt.details.type !== "file.watcher.updated") return
|
||||
const props =
|
||||
typeof evt.details.properties === "object" && evt.details.properties
|
||||
|
|
@ -827,7 +830,7 @@ export default function Page() {
|
|||
|
||||
createEffect(
|
||||
on(
|
||||
() => sync.data.session_status[params.id ?? ""]?.type,
|
||||
() => sync().data.session_status[params.id ?? ""]?.type,
|
||||
(next, prev) => {
|
||||
if (next !== "idle" || prev === undefined || prev === "idle") return
|
||||
refreshVcs()
|
||||
|
|
@ -1098,10 +1101,10 @@ export default function Page() {
|
|||
if (!id) return
|
||||
|
||||
if (!wantsReview()) return
|
||||
if (sync.data.session_diff[id] !== undefined) return
|
||||
if (sync.status === "loading") return
|
||||
if (sync().data.session_diff[id] !== undefined) return
|
||||
if (sync().status === "loading") return
|
||||
|
||||
void sync.session.diff(id)
|
||||
void sync().session.diff(id)
|
||||
})
|
||||
|
||||
createEffect(
|
||||
|
|
@ -1116,14 +1119,14 @@ export default function Page() {
|
|||
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
if (!untrack(() => sync.data.session_diff[id] !== undefined)) return
|
||||
if (!untrack(() => sync().data.session_diff[id] !== undefined)) return
|
||||
|
||||
diffFrame = requestAnimationFrame(() => {
|
||||
diffFrame = undefined
|
||||
diffTimer = window.setTimeout(() => {
|
||||
diffTimer = undefined
|
||||
if (sessionKey() !== key) return
|
||||
void sync.session.diff(id, { force: true })
|
||||
void sync().session.diff(id, { force: true })
|
||||
}, 0)
|
||||
})
|
||||
},
|
||||
|
|
@ -1133,10 +1136,10 @@ export default function Page() {
|
|||
|
||||
let treeDir: string | undefined
|
||||
createEffect(() => {
|
||||
const dir = sdk.directory
|
||||
const dir = sdk().directory
|
||||
if (!isDesktop()) return
|
||||
if (!layout.fileTree.opened()) return
|
||||
if (sync.status === "loading") return
|
||||
if (sync().status === "loading") return
|
||||
|
||||
fileTreeTab()
|
||||
const refresh = treeDir !== dir
|
||||
|
|
@ -1146,7 +1149,7 @@ export default function Page() {
|
|||
|
||||
createEffect(
|
||||
on(
|
||||
() => sdk.directory,
|
||||
() => sdk().directory,
|
||||
() => {
|
||||
const tab = activeFileTab()
|
||||
if (!tab) return
|
||||
|
|
@ -1249,7 +1252,7 @@ export default function Page() {
|
|||
visibleUserMessages,
|
||||
historyMore,
|
||||
historyLoading,
|
||||
loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
|
||||
loadMore: (sessionID) => sync().session.history.loadMore(sessionID),
|
||||
userScrolled: autoScroll.userScrolled,
|
||||
scroller: () => scroller,
|
||||
onBeforeLoad: () => captureHistoryAnchor(),
|
||||
|
|
@ -1295,8 +1298,8 @@ export default function Page() {
|
|||
)
|
||||
|
||||
const draft = (id: string) =>
|
||||
extractPromptFromParts(sync.data.part[id] ?? [], {
|
||||
directory: sdk.directory,
|
||||
extractPromptFromParts(sync().data.part[id] ?? [], {
|
||||
directory: sdk().directory,
|
||||
attachmentName: language.t("common.attachment"),
|
||||
})
|
||||
|
||||
|
|
@ -1319,7 +1322,7 @@ export default function Page() {
|
|||
}
|
||||
|
||||
const merge = (next: NonNullable<ReturnType<typeof info>>) =>
|
||||
sync.set("session", (list) => {
|
||||
sync().set("session", (list) => {
|
||||
const idx = list.findIndex((item) => item.id === next.id)
|
||||
if (idx < 0) return list
|
||||
const out = list.slice()
|
||||
|
|
@ -1328,7 +1331,7 @@ export default function Page() {
|
|||
})
|
||||
|
||||
const roll = (sessionID: string, next: NonNullable<ReturnType<typeof info>>["revert"]) =>
|
||||
sync.set("session", (list) => {
|
||||
sync().set("session", (list) => {
|
||||
const idx = list.findIndex((item) => item.id === sessionID)
|
||||
if (idx < 0) return list
|
||||
const out = list.slice()
|
||||
|
|
@ -1336,7 +1339,7 @@ export default function Page() {
|
|||
return out
|
||||
})
|
||||
|
||||
const busy = (sessionID: string) => sync.data.session_working(sessionID)
|
||||
const busy = (sessionID: string) => sync().data.session_working(sessionID)
|
||||
|
||||
const queuedFollowups = createMemo(() => {
|
||||
const id = params.id
|
||||
|
|
@ -1359,11 +1362,11 @@ export default function Page() {
|
|||
setFollowup("failed", input.sessionID, undefined)
|
||||
|
||||
const ok = await sendFollowupDraft({
|
||||
client: sdk.client,
|
||||
sync,
|
||||
serverSync,
|
||||
client: sdk().client,
|
||||
sync: sync(),
|
||||
serverSync: serverSync(),
|
||||
draft: item,
|
||||
optimisticBusy: item.sessionDirectory === sdk.directory,
|
||||
optimisticBusy: item.sessionDirectory === sdk().directory,
|
||||
}).catch((err) => {
|
||||
setFollowup("failed", input.sessionID, input.id)
|
||||
fail(err)
|
||||
|
|
@ -1421,7 +1424,7 @@ export default function Page() {
|
|||
const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) })))
|
||||
|
||||
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
|
||||
if (sync.session.get(sessionID)?.parentID) return Promise.resolve()
|
||||
if (sync().session.get(sessionID)?.parentID) return Promise.resolve()
|
||||
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
|
||||
if (!item) return Promise.resolve()
|
||||
if (followupBusy(sessionID)) return Promise.resolve()
|
||||
|
|
@ -1453,7 +1456,11 @@ export default function Page() {
|
|||
}
|
||||
|
||||
const halt = (sessionID: string) =>
|
||||
busy(sessionID) ? sdk.client.session.abort({ sessionID }).catch(() => {}) : Promise.resolve()
|
||||
busy(sessionID)
|
||||
? sdk()
|
||||
.client.session.abort({ sessionID })
|
||||
.catch(() => {})
|
||||
: Promise.resolve()
|
||||
|
||||
const revertMutation = useMutation(() => ({
|
||||
mutationFn: async (input: { sessionID: string; messageID: string }) => {
|
||||
|
|
@ -1465,7 +1472,7 @@ export default function Page() {
|
|||
prompt.set(value)
|
||||
})
|
||||
await halt(input.sessionID)
|
||||
.then(() => sdk.client.session.revert(input))
|
||||
.then(() => sdk().client.session.revert(input))
|
||||
.then((result) => {
|
||||
if (result.data) merge(result.data)
|
||||
})
|
||||
|
|
@ -1498,9 +1505,9 @@ export default function Page() {
|
|||
})
|
||||
|
||||
const task = !next
|
||||
? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID }))
|
||||
? halt(sessionID).then(() => sdk().client.session.unrevert({ sessionID }))
|
||||
: halt(sessionID).then(() =>
|
||||
sdk.client.session.revert({
|
||||
sdk().client.session.revert({
|
||||
sessionID,
|
||||
messageID: next.id,
|
||||
}),
|
||||
|
|
@ -1588,7 +1595,7 @@ export default function Page() {
|
|||
visibleUserMessages,
|
||||
historyMore,
|
||||
historyLoading,
|
||||
loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
|
||||
loadMore: (sessionID) => sync().session.history.loadMore(sessionID),
|
||||
currentMessageId: () => store.messageId,
|
||||
pendingMessage: () => ui.pendingMessage,
|
||||
setPendingMessage: (value) => setUi("pendingMessage", value),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Show, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { useNavigate, useSearchParams } from "@solidjs/router"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
|
|
@ -18,6 +18,17 @@ import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
|
|||
import type { FollowupDraft } from "@/components/prompt-input/submit"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
||||
import { createQuery } from "@tanstack/solid-query"
|
||||
import { useQueryOptions } from "@/context/server-sync"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
|
||||
export function SessionComposerRegion(props: {
|
||||
state: SessionComposerState
|
||||
|
|
@ -54,10 +65,70 @@ export function SessionComposerRegion(props: {
|
|||
const language = useLanguage()
|
||||
const route = useSessionKey()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const queryOptions = useQueryOptions()
|
||||
const local = useLocal()
|
||||
const providers = useProviders()
|
||||
const settings = useSettings()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const view = layout.view(route.sessionKey)
|
||||
|
||||
const agentsQuery = createQuery(() => queryOptions().agents(pathKey(sdk().directory)))
|
||||
const globalProvidersQuery = createQuery(() => queryOptions().providers(null))
|
||||
const providersQuery = createQuery(() => queryOptions().providers(pathKey(sdk().directory)))
|
||||
const selectProject = (worktree: string) => {
|
||||
layout.projects.open(worktree)
|
||||
server.projects.touch(worktree)
|
||||
if (search.draftId) {
|
||||
tabs.updateDraft(search.draftId, { server: server.key, directory: worktree })
|
||||
return
|
||||
}
|
||||
navigate(`/${base64Encode(worktree)}/session`)
|
||||
}
|
||||
const addProject = (title: string) => {
|
||||
if (!server.current) return
|
||||
pickDirectory({
|
||||
server: server.current,
|
||||
title,
|
||||
onSelect: (result) => {
|
||||
const directory = Array.isArray(result) ? result[0] : result
|
||||
if (directory) selectProject(directory)
|
||||
},
|
||||
})
|
||||
}
|
||||
const controls = createMemo(() => ({
|
||||
agents: {
|
||||
available: sync().data.agent,
|
||||
options: local.agent.list().map((agent) => agent.name),
|
||||
current: local.agent.current()?.name ?? "",
|
||||
loading: agentsQuery.isLoading,
|
||||
visible: settings.visibility.customAgents(),
|
||||
select: local.agent.set,
|
||||
},
|
||||
model: {
|
||||
selection: local.model,
|
||||
paid: providers.paid().length > 0,
|
||||
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
|
||||
},
|
||||
projects: {
|
||||
available: layout.projects.list(),
|
||||
directory: sdk().directory,
|
||||
select: selectProject,
|
||||
add: addProject,
|
||||
},
|
||||
session: {
|
||||
id: route.params.id,
|
||||
tabs: layout.tabs(route.sessionKey),
|
||||
reviewPanel: view.reviewPanel,
|
||||
},
|
||||
newLayoutDesigns: settings.general.newLayoutDesigns(),
|
||||
}))
|
||||
|
||||
const handoffPrompt = createMemo(() => getSessionHandoff(route.sessionKey())?.prompt)
|
||||
const info = createMemo(() => (route.params.id ? sync.session.get(route.params.id) : undefined))
|
||||
const info = createMemo(() => (route.params.id ? sync().session.get(route.params.id) : undefined))
|
||||
const parentID = createMemo(() => info()?.parentID)
|
||||
const child = createMemo(() => !!parentID())
|
||||
const showComposer = createMemo(() => !props.state.blocked() || child())
|
||||
|
|
@ -263,6 +334,7 @@ export function SessionComposerRegion(props: {
|
|||
fallback={
|
||||
<Show when={!props.state.blocked()}>
|
||||
<PromptInput
|
||||
controls={controls()}
|
||||
variant={props.placement === "inline" ? "new-session" : undefined}
|
||||
ref={props.inputRef}
|
||||
newSessionWorktree={props.newSessionWorktree}
|
||||
|
|
|
|||
|
|
@ -32,12 +32,12 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
|
|||
const permission = usePermission()
|
||||
|
||||
const questionRequest = createMemo((): QuestionRequest | undefined => {
|
||||
return sessionQuestionRequest(sync.data.session, sync.data.question, params.id)
|
||||
return sessionQuestionRequest(sync().data.session, sync().data.question, params.id)
|
||||
})
|
||||
|
||||
const permissionRequest = createMemo((): PermissionRequest | undefined => {
|
||||
return sessionPermissionRequest(sync.data.session, sync.data.permission, params.id, (item) => {
|
||||
return !permission.autoResponds(item, sdk.directory)
|
||||
return sessionPermissionRequest(sync().data.session, sync().data.permission, params.id, (item) => {
|
||||
return !permission.autoResponds(item, sdk().directory)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -50,14 +50,14 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
|
|||
const todos = createMemo((): Todo[] => {
|
||||
const id = params.id
|
||||
if (!id) return []
|
||||
return serverSync.data.session_todo[id] ?? []
|
||||
return serverSync().data.session_todo[id] ?? []
|
||||
})
|
||||
|
||||
const done = createMemo(
|
||||
() => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
|
||||
)
|
||||
|
||||
const live = createMemo(() => sync.data.session_working(params.id ?? "") || blocked())
|
||||
const live = createMemo(() => sync().data.session_working(params.id ?? "") || blocked())
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
responding: undefined as string | undefined,
|
||||
|
|
@ -78,8 +78,8 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
|
|||
if (store.responding === perm.id) return
|
||||
|
||||
setStore("responding", perm.id)
|
||||
sdk.client.permission
|
||||
.respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
|
||||
sdk()
|
||||
.client.permission.respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
|
||||
.catch((err: unknown) => {
|
||||
const description = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description })
|
||||
|
|
@ -111,8 +111,8 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
|
|||
const clear = () => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
serverSync.todo.set(id, [])
|
||||
sync.set("todo", id, [])
|
||||
serverSync().todo.set(id, [])
|
||||
sync().set("todo", id, [])
|
||||
}
|
||||
|
||||
createEffect(
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||
const sdk = useSDK()
|
||||
const serverSDK = useServerSDK()
|
||||
const language = useLanguage()
|
||||
const cacheKey = ScopedKey.from(serverSDK.scope, props.request.id)
|
||||
const cacheKey = ScopedKey.from(serverSDK().scope, props.request.id)
|
||||
|
||||
const questions = createMemo(() => props.request.questions)
|
||||
const total = createMemo(() => questions().length)
|
||||
|
|
@ -209,7 +209,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||
}
|
||||
|
||||
const replyMutation = useMutation(() => ({
|
||||
mutationFn: (answers: QuestionAnswer[]) => sdk.client.question.reply({ requestID: props.request.id, answers }),
|
||||
mutationFn: (answers: QuestionAnswer[]) => sdk().client.question.reply({ requestID: props.request.id, answers }),
|
||||
onMutate: () => {
|
||||
props.onSubmit()
|
||||
},
|
||||
|
|
@ -221,7 +221,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||
}))
|
||||
|
||||
const rejectMutation = useMutation(() => ({
|
||||
mutationFn: () => sdk.client.question.reject({ requestID: props.request.id }),
|
||||
mutationFn: () => sdk().client.question.reject({ requestID: props.request.id }),
|
||||
onMutate: () => {
|
||||
props.onSubmit()
|
||||
},
|
||||
|
|
|
|||
|
|
@ -281,7 +281,7 @@ export function MessageTimeline(props: {
|
|||
const sessionMessages = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return emptyMessages
|
||||
return sync.data.message[id] ?? emptyMessages
|
||||
return sync().data.message[id] ?? emptyMessages
|
||||
})
|
||||
const messageByID = createMemo(() => new Map(sessionMessages().map((message) => [message.id, message] as const)))
|
||||
const assistantMessagesByParent = createMemo(() => {
|
||||
|
|
@ -305,10 +305,10 @@ export function MessageTimeline(props: {
|
|||
const sessionStatus = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return idle
|
||||
return sync.data.session_status[id] ?? idle
|
||||
return sync().data.session_status[id] ?? idle
|
||||
})
|
||||
const working = createMemo(() => sessionStatus().type !== "idle")
|
||||
const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent))
|
||||
const tint = createMemo(() => messageAgentColor(sessionMessages(), sync().data.agent))
|
||||
|
||||
const [timeoutDone, setTimeoutDone] = createSignal(true)
|
||||
|
||||
|
|
@ -347,25 +347,25 @@ export function MessageTimeline(props: {
|
|||
const info = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return
|
||||
return sync.session.get(id)
|
||||
return sync().session.get(id)
|
||||
})
|
||||
const titleValue = createMemo(() => info()?.title)
|
||||
const titleLabel = createMemo(() => sessionTitle(titleValue()))
|
||||
const shareUrl = createMemo(() => info()?.share?.url)
|
||||
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
||||
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
|
||||
const parentID = createMemo(() => info()?.parentID)
|
||||
const parent = createMemo(() => {
|
||||
const id = parentID()
|
||||
if (!id) return
|
||||
return sync.session.get(id)
|
||||
return sync().session.get(id)
|
||||
})
|
||||
const parentMessages = createMemo(() => {
|
||||
const id = parentID()
|
||||
if (!id) return emptyMessages
|
||||
return sync.data.message[id] ?? emptyMessages
|
||||
return sync().data.message[id] ?? emptyMessages
|
||||
})
|
||||
const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
|
||||
const getMsgParts = (msgId: string) => sync.data.part[msgId] ?? emptyParts
|
||||
const getMsgParts = (msgId: string) => sync().data.part[msgId] ?? emptyParts
|
||||
const childTaskDescription = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return
|
||||
|
|
@ -664,14 +664,14 @@ export function MessageTimeline(props: {
|
|||
}
|
||||
|
||||
const shareMutation = useMutation(() => ({
|
||||
mutationFn: (id: string) => serverSDK.client.session.share({ sessionID: id, directory: sdk.directory }),
|
||||
mutationFn: (id: string) => serverSDK().client.session.share({ sessionID: id, directory: sdk().directory }),
|
||||
onError: (err) => {
|
||||
console.error("Failed to share session", err)
|
||||
},
|
||||
}))
|
||||
|
||||
const unshareMutation = useMutation(() => ({
|
||||
mutationFn: (id: string) => serverSDK.client.session.unshare({ sessionID: id, directory: sdk.directory }),
|
||||
mutationFn: (id: string) => serverSDK().client.session.unshare({ sessionID: id, directory: sdk().directory }),
|
||||
onError: (err) => {
|
||||
console.error("Failed to unshare session", err)
|
||||
},
|
||||
|
|
@ -679,9 +679,9 @@ export function MessageTimeline(props: {
|
|||
|
||||
const titleMutation = useMutation(() => ({
|
||||
mutationFn: (input: { id: string; title: string }) =>
|
||||
sdk.client.session.update({ sessionID: input.id, title: input.title }),
|
||||
sdk().client.session.update({ sessionID: input.id, title: input.title }),
|
||||
onSuccess: (_, input) => {
|
||||
sync.set(
|
||||
sync().set(
|
||||
produce((draft) => {
|
||||
const index = draft.session.findIndex((s) => s.id === input.id)
|
||||
if (index !== -1) draft.session[index].title = input.title
|
||||
|
|
@ -731,8 +731,8 @@ export function MessageTimeline(props: {
|
|||
() => [parentID(), childTaskDescription()] as const,
|
||||
([id, description]) => {
|
||||
if (!id || description) return
|
||||
if (sync.data.message[id] !== undefined) return
|
||||
void sync.session.sync(id)
|
||||
if (sync().data.message[id] !== undefined) return
|
||||
void sync().session.sync(id)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
|
|
@ -780,25 +780,25 @@ export function MessageTimeline(props: {
|
|||
}
|
||||
|
||||
const archiveSession = async (sessionID: string) => {
|
||||
const session = sync.session.get(sessionID)
|
||||
const session = sync().session.get(sessionID)
|
||||
if (!session) return
|
||||
|
||||
const sessions = sync.data.session ?? []
|
||||
const sessions = sync().data.session ?? []
|
||||
const index = sessions.findIndex((s) => s.id === sessionID)
|
||||
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||
|
||||
await sdk.client.session
|
||||
.update({ sessionID, time: { archived: Date.now() } })
|
||||
await sdk()
|
||||
.client.session.update({ sessionID, time: { archived: Date.now() } })
|
||||
.then(() => {
|
||||
sync.set(
|
||||
sync().set(
|
||||
produce((draft) => {
|
||||
const index = draft.session.findIndex((s) => s.id === sessionID)
|
||||
if (index !== -1) draft.session.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
sync.session.evict(sessionID)
|
||||
sync().session.evict(sessionID)
|
||||
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
|
||||
notifySessionTabsRemoved({ directory: sdk.directory, sessionIDs: [sessionID] })
|
||||
notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [sessionID] })
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
|
|
@ -809,15 +809,15 @@ export function MessageTimeline(props: {
|
|||
}
|
||||
|
||||
const deleteSession = async (sessionID: string) => {
|
||||
const session = sync.session.get(sessionID)
|
||||
const session = sync().session.get(sessionID)
|
||||
if (!session) return false
|
||||
|
||||
const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
|
||||
const sessions = (sync().data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
|
||||
const index = sessions.findIndex((s) => s.id === sessionID)
|
||||
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||
|
||||
const result = await sdk.client.session
|
||||
.delete({ sessionID })
|
||||
const result = await sdk()
|
||||
.client.session.delete({ sessionID })
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
|
|
@ -831,7 +831,7 @@ export function MessageTimeline(props: {
|
|||
|
||||
const removed = new Set<string>([sessionID])
|
||||
const byParent = new Map<string, string[]>()
|
||||
for (const item of sync.data.session) {
|
||||
for (const item of sync().data.session) {
|
||||
const parentID = item.parentID
|
||||
if (!parentID) continue
|
||||
const existing = byParent.get(parentID)
|
||||
|
|
@ -859,16 +859,16 @@ export function MessageTimeline(props: {
|
|||
|
||||
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
|
||||
|
||||
sync.set(
|
||||
sync().set(
|
||||
produce((draft) => {
|
||||
draft.session = draft.session.filter((s) => !removed.has(s.id))
|
||||
}),
|
||||
)
|
||||
|
||||
for (const id of removed) {
|
||||
sync.session.evict(id)
|
||||
sync().session.evict(id)
|
||||
}
|
||||
notifySessionTabsRemoved({ directory: sdk.directory, sessionIDs: [...removed] })
|
||||
notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [...removed] })
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -880,7 +880,7 @@ export function MessageTimeline(props: {
|
|||
|
||||
function DialogDeleteSession(props: { sessionID: string }) {
|
||||
const name = createMemo(
|
||||
() => sessionTitle(sync.session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
|
||||
() => sessionTitle(sync().session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
|
||||
)
|
||||
const handleDelete = async () => {
|
||||
await deleteSession(props.sessionID)
|
||||
|
|
|
|||
|
|
@ -53,8 +53,8 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
|
|||
const layout = useLayout()
|
||||
|
||||
const readFile = async (path: string) => {
|
||||
return sdk.client.file
|
||||
.read({ path })
|
||||
return sdk()
|
||||
.client.file.read({ path })
|
||||
.then((x) => x.data)
|
||||
.catch((error) => {
|
||||
console.debug("[session-review] failed to read file", { path, error })
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ export function useUsageExceededDialogs() {
|
|||
)
|
||||
|
||||
onCleanup(
|
||||
sdk.event.on("session.status", (evt) => {
|
||||
sdk().event.on("session.status", (evt) => {
|
||||
if (evt.properties.sessionID !== params.id) return
|
||||
if (evt.properties.status.type !== "retry") return
|
||||
const { action } = evt.properties.status
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||
const info = () => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
return sync.session.get(id)
|
||||
return sync().session.get(id)
|
||||
}
|
||||
const hasReview = () => !!params.id
|
||||
const normalizeTab = (tab: string) => {
|
||||
|
|
@ -73,7 +73,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||
const messages = () => {
|
||||
const id = params.id
|
||||
if (!id) return []
|
||||
return sync.data.message[id] ?? []
|
||||
return sync().data.message[id] ?? []
|
||||
}
|
||||
const userMessages = () => messages().filter((m) => m.role === "user") as UserMessage[]
|
||||
const visibleUserMessages = () => {
|
||||
|
|
@ -122,8 +122,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||
|
||||
const isAutoAcceptActive = () => {
|
||||
const sessionID = params.id
|
||||
if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory)
|
||||
return permission.isAutoAcceptingDirectory(sdk.directory)
|
||||
if (sessionID) return permission.isAutoAccepting(sessionID, sdk().directory)
|
||||
return permission.isAutoAcceptingDirectory(sdk().directory)
|
||||
}
|
||||
const write = async (value: string) => {
|
||||
const body = typeof document === "undefined" ? undefined : document.body
|
||||
|
|
@ -175,8 +175,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||
return
|
||||
}
|
||||
|
||||
const url = await sdk.client.session
|
||||
.share({ sessionID })
|
||||
const url = await sdk()
|
||||
.client.session.share({ sessionID })
|
||||
.then((res) => res.data?.share?.url)
|
||||
.catch(() => undefined)
|
||||
if (!url) {
|
||||
|
|
@ -195,8 +195,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
|
||||
await sdk.client.session
|
||||
.unshare({ sessionID })
|
||||
await sdk()
|
||||
.client.session.unshare({ sessionID })
|
||||
.then(() =>
|
||||
showToast({
|
||||
title: language.t("toast.session.unshare.success.title"),
|
||||
|
|
@ -263,12 +263,12 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||
|
||||
const toggleAutoAccept = () => {
|
||||
const sessionID = params.id
|
||||
if (sessionID) permission.toggleAutoAccept(sessionID, sdk.directory)
|
||||
else permission.toggleAutoAcceptDirectory(sdk.directory)
|
||||
if (sessionID) permission.toggleAutoAccept(sessionID, sdk().directory)
|
||||
else permission.toggleAutoAcceptDirectory(sdk().directory)
|
||||
|
||||
const active = sessionID
|
||||
? permission.isAutoAccepting(sessionID, sdk.directory)
|
||||
: permission.isAutoAcceptingDirectory(sdk.directory)
|
||||
? permission.isAutoAccepting(sessionID, sdk().directory)
|
||||
: permission.isAutoAcceptingDirectory(sdk().directory)
|
||||
showToast({
|
||||
title: active
|
||||
? language.t("toast.permissions.autoaccept.on.title")
|
||||
|
|
@ -283,18 +283,20 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
|
||||
if (sync.data.session_working(params.id ?? "")) {
|
||||
await sdk.client.session.abort({ sessionID }).catch(() => {})
|
||||
if (sync().data.session_working(params.id ?? "")) {
|
||||
await sdk()
|
||||
.client.session.abort({ sessionID })
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const revert = info()?.revert?.messageID
|
||||
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
|
||||
if (!message) return
|
||||
|
||||
await sdk.client.session.revert({ sessionID, messageID: message.id })
|
||||
const parts = sync.data.part[message.id]
|
||||
await sdk().client.session.revert({ sessionID, messageID: message.id })
|
||||
const parts = sync().data.part[message.id]
|
||||
if (parts) {
|
||||
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
|
||||
const restored = extractPromptFromParts(parts, { directory: sdk().directory })
|
||||
prompt.set(restored)
|
||||
}
|
||||
|
||||
|
|
@ -311,14 +313,14 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||
|
||||
const next = userMessages().find((x) => x.id > revertMessageID)
|
||||
if (!next) {
|
||||
await sdk.client.session.unrevert({ sessionID })
|
||||
await sdk().client.session.unrevert({ sessionID })
|
||||
prompt.reset()
|
||||
const last = findLast(userMessages(), (x) => x.id >= revertMessageID)
|
||||
setActiveMessage(last)
|
||||
return
|
||||
}
|
||||
|
||||
await sdk.client.session.revert({ sessionID, messageID: next.id })
|
||||
await sdk().client.session.revert({ sessionID, messageID: next.id })
|
||||
const prev = findLast(userMessages(), (x) => x.id < next.id)
|
||||
setActiveMessage(prev)
|
||||
}
|
||||
|
|
@ -336,7 +338,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||
return
|
||||
}
|
||||
|
||||
await sdk.client.session.summarize({
|
||||
await sdk().client.session.summarize({
|
||||
sessionID,
|
||||
modelID: model.id,
|
||||
providerID: model.provider.id,
|
||||
|
|
@ -350,7 +352,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||
}
|
||||
|
||||
const shareCmds = () => {
|
||||
if (sync.data.config.share === "disabled") return []
|
||||
if (sync().data.config.share === "disabled") return []
|
||||
return [
|
||||
sessionCommand({
|
||||
id: "session.share",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.7",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.7",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import { i18n, type Key } from "~/i18n"
|
|||
import { localeFromRequest } from "~/lib/language"
|
||||
import { createModelTpmLimiter } from "./modelTpmLimiter"
|
||||
import { createModelTpsLimiter } from "./modelTpsLimiter"
|
||||
import { createProviderBudgetTracker } from "./providerBudgetTracker"
|
||||
import { accumulateUsage, HOT_WORKSPACES } from "./usageBatcher"
|
||||
|
||||
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
|
||||
|
|
@ -132,6 +133,10 @@ export async function handler(
|
|||
const modelTpmLimits = await modelTpmLimiter?.check()
|
||||
const modelTpsLimiter = createModelTpsLimiter(modelInfo.providers)
|
||||
const modelTpsLimits = await modelTpsLimiter?.check()
|
||||
const providerBudgetTracker = createProviderBudgetTracker(
|
||||
modelInfo.providers.map((provider) => ({ ...zenData.providers[provider.id], ...provider })),
|
||||
)
|
||||
const providerBudgetUsage = await providerBudgetTracker?.check()
|
||||
|
||||
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
|
||||
const providerInfo = selectProvider(
|
||||
|
|
@ -145,12 +150,16 @@ export async function handler(
|
|||
stickyProvider,
|
||||
modelTpmLimits,
|
||||
modelTpsLimits,
|
||||
providerBudgetUsage,
|
||||
)
|
||||
validateModelSettings(billingSource, authInfo)
|
||||
updateProviderKey(authInfo, providerInfo)
|
||||
logger.metric({
|
||||
provider: providerInfo.id,
|
||||
"provider.model": providerInfo.model,
|
||||
...(providerBudgetUsage?.[providerInfo.id]
|
||||
? { "provider.budget_usage": providerBudgetUsage?.[providerInfo.id] }
|
||||
: {}),
|
||||
})
|
||||
|
||||
const startTimestamp = Date.now()
|
||||
|
|
@ -257,6 +266,7 @@ export async function handler(
|
|||
const costInfo = calculateCost(modelInfo, usageInfo)
|
||||
await trialLimiter?.track(usageInfo)
|
||||
await modelTpmLimiter?.track(providerInfo.id, providerInfo.model, usageInfo)
|
||||
await providerBudgetTracker?.track(providerInfo.id, costInfo.totalCostInCent)
|
||||
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
|
||||
await reload(billingSource, authInfo, costInfo)
|
||||
json.cost = calculateOccurredCost(billingSource, costInfo)
|
||||
|
|
@ -317,6 +327,7 @@ export async function handler(
|
|||
timestampLastByte,
|
||||
usageInfo,
|
||||
)
|
||||
await providerBudgetTracker?.track(providerInfo.id, costInfo.totalCostInCent)
|
||||
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
|
||||
await reload(billingSource, authInfo, costInfo)
|
||||
const cost = calculateOccurredCost(billingSource, costInfo)
|
||||
|
|
@ -483,6 +494,7 @@ export async function handler(
|
|||
stickyProviderId: string | undefined,
|
||||
modelTpmLimits: Record<string, number> | undefined,
|
||||
modelTpsLimits: Record<string, { qualify: number; unqualify: number }> | undefined,
|
||||
providerBudgetUsage: Record<string, number> | undefined,
|
||||
) {
|
||||
const modelProvider = (() => {
|
||||
// Byok is top priority b/c if user set their own API key, we should use it
|
||||
|
|
@ -505,6 +517,12 @@ export async function handler(
|
|||
const providers = allProviders
|
||||
.filter((provider) => provider.weight !== 0)
|
||||
.filter((provider) => !retry.excludeProviders.includes(provider.id))
|
||||
.filter((provider) => {
|
||||
if (provider.budgetMode !== "fill") return true
|
||||
const budget = zenData.providers[provider.id]?.budget
|
||||
if (budget === undefined) return false
|
||||
return (providerBudgetUsage?.[provider.id] ?? 0) < centsToMicroCents(budget * 100)
|
||||
})
|
||||
.filter((provider) => {
|
||||
if (!provider.tpmLimit) return true
|
||||
const usage = modelTpmLimits?.[`${provider.id}/${provider.model}`] ?? 0
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
|
||||
import { buildRateLimitKey, getRedis } from "./redis"
|
||||
|
||||
export function createProviderBudgetTracker(
|
||||
providers: {
|
||||
id: string
|
||||
budget?: number
|
||||
budgetContribution?: number
|
||||
budgetMode?: "always" | "fill"
|
||||
}[],
|
||||
) {
|
||||
const tracked = providers.filter(
|
||||
(provider) => provider.budget !== undefined && provider.budgetContribution !== undefined,
|
||||
)
|
||||
if (tracked.length === 0) return undefined
|
||||
|
||||
const interval = new Date()
|
||||
.toISOString()
|
||||
.replace(/[^0-9]/g, "")
|
||||
.substring(0, 12)
|
||||
const redis = getRedis()
|
||||
const keys = Object.fromEntries(
|
||||
tracked.map((provider) => [provider.id, buildRateLimitKey("provider-budget", provider.id, interval)]),
|
||||
)
|
||||
|
||||
return {
|
||||
check: async () => {
|
||||
const ids = tracked.filter((provider) => provider.budgetMode === "fill").map((provider) => provider.id)
|
||||
if (ids.length === 0) return {}
|
||||
const values = await redis.mget<(string | number | null)[]>(ids.map((id) => keys[id]))
|
||||
return Object.fromEntries(ids.map((id, index) => [id, Number(values[index] ?? 0)]))
|
||||
},
|
||||
track: async (provider: string, costInCent: number) => {
|
||||
const config = tracked.find((item) => item.id === provider)
|
||||
if (!config) return
|
||||
if (config.budgetContribution === undefined) return
|
||||
const cost = centsToMicroCents(costInCent * config.budgetContribution)
|
||||
if (cost <= 0) return
|
||||
const pipeline = redis.pipeline()
|
||||
pipeline.incrby(keys[provider], cost)
|
||||
pipeline.expire(keys[provider], 120)
|
||||
await pipeline.exec()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ export namespace ZenData {
|
|||
priority: z.number().optional(),
|
||||
tpmLimit: z.number().optional(),
|
||||
tpsGoal: z.number().optional(),
|
||||
budgetMode: z.enum(["always", "fill"]).optional(),
|
||||
budgetContribution: z.number().optional(),
|
||||
weight: z.number().optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
storeModel: z.string().optional(),
|
||||
|
|
@ -54,6 +56,7 @@ export namespace ZenData {
|
|||
payloadModifier: z.record(z.string(), z.any()).optional(),
|
||||
payloadMappings: z.record(z.string(), z.string()).optional(),
|
||||
adjustCacheUsage: z.boolean().optional(),
|
||||
budget: z.number().optional(),
|
||||
})
|
||||
|
||||
const ModelsSchema = z.object({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.7",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/console-support",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.7",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.7",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@ import { Location } from "./location"
|
|||
import { EventV2 } from "./event"
|
||||
import { Policy } from "./policy"
|
||||
import { State } from "./state"
|
||||
import { Credential } from "./credential"
|
||||
import { IntegrationSchema } from "./integration/schema"
|
||||
import { Integration } from "./integration"
|
||||
|
||||
export type ProviderRecord = {
|
||||
provider: ProviderV2.Info
|
||||
|
|
@ -35,12 +34,7 @@ export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundErr
|
|||
export const PolicyActions = Schema.Literals(["provider.use"])
|
||||
|
||||
export const Event = {
|
||||
ModelUpdated: EventV2.define({
|
||||
type: "catalog.model.updated",
|
||||
schema: {
|
||||
model: ModelV2.Info,
|
||||
},
|
||||
}),
|
||||
Updated: EventV2.define({ type: "catalog.updated", schema: {} }),
|
||||
}
|
||||
|
||||
type Data = {
|
||||
|
|
@ -96,26 +90,17 @@ export const layer = Layer.effect(
|
|||
const plugin = yield* PluginV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const policy = yield* Policy.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
const project = (provider: ProviderV2.Info, active: Map<IntegrationSchema.ID, Credential.Stored>) => {
|
||||
const credential = active.get(IntegrationSchema.ID.make(provider.id))
|
||||
if (!credential) return provider
|
||||
const body = { ...provider.request.body }
|
||||
if (credential.value.type === "key") {
|
||||
body.apiKey = credential.value.key
|
||||
Object.assign(body, credential.value.metadata ?? {})
|
||||
}
|
||||
if (credential.value.type === "oauth") body.apiKey = credential.value.access
|
||||
return new ProviderV2.Info({
|
||||
...provider,
|
||||
enabled: { via: "credential", credentialID: credential.id },
|
||||
request: { ...provider.request, body },
|
||||
})
|
||||
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined, connected: boolean) => {
|
||||
if (provider.disabled) return false
|
||||
if (typeof provider.request.body.apiKey === "string") return true
|
||||
if (connected) return true
|
||||
return !integration
|
||||
}
|
||||
|
||||
const resolve = (model: ModelV2.Info, provider: ProviderV2.Info) => {
|
||||
const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => {
|
||||
const api =
|
||||
model.api.type === "native" && !model.api.url && Object.keys(model.api.settings).length === 0
|
||||
? { ...provider.api, id: model.api.id }
|
||||
|
|
@ -203,18 +188,16 @@ export const layer = Layer.effect(
|
|||
},
|
||||
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog, reason) {
|
||||
if (reason !== "plugin.added") yield* plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid)
|
||||
if (!policy.hasStatements()) return
|
||||
for (const record of [...catalog.provider.list()]) {
|
||||
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
|
||||
catalog.provider.remove(record.provider.id)
|
||||
if (policy.hasStatements()) {
|
||||
for (const record of [...catalog.provider.list()]) {
|
||||
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
|
||||
catalog.provider.remove(record.provider.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
yield* events.publish(Event.Updated, {})
|
||||
}),
|
||||
})
|
||||
const active = Effect.fn("CatalogV2.active")(function* () {
|
||||
return new Map((yield* credentials.all()).map((credential) => [credential.integrationID, credential]))
|
||||
})
|
||||
|
||||
yield* events.subscribe(PluginV2.Event.Added).pipe(
|
||||
// Plugin registries are location scoped even though the event bus is process scoped.
|
||||
Stream.filter(
|
||||
|
|
@ -233,18 +216,23 @@ export const layer = Layer.effect(
|
|||
provider: {
|
||||
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
|
||||
const record = yield* getRecord(providerID)
|
||||
return project(record.provider, yield* active())
|
||||
return record.provider
|
||||
}),
|
||||
|
||||
all: Effect.fn("CatalogV2.provider.all")(function* () {
|
||||
const credentials = yield* active()
|
||||
return Array.fromIterable(state.get().providers.values()).map((record) =>
|
||||
project(record.provider, credentials),
|
||||
)
|
||||
return Array.fromIterable(state.get().providers.values()).map((record) => record.provider)
|
||||
}),
|
||||
|
||||
available: Effect.fn("CatalogV2.provider.available")(function* () {
|
||||
return (yield* result.provider.all()).filter((provider) => provider.enabled)
|
||||
const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
|
||||
const connections = yield* integrations.connection.list()
|
||||
return (yield* result.provider.all()).filter((provider) =>
|
||||
available(
|
||||
provider,
|
||||
active.get(Integration.ID.make(provider.id)),
|
||||
connections.has(Integration.ID.make(provider.id)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
},
|
||||
|
||||
|
|
@ -253,33 +241,32 @@ export const layer = Layer.effect(
|
|||
const record = yield* getRecord(providerID)
|
||||
const model = record.models.get(modelID)
|
||||
if (!model) return yield* new ModelNotFoundError({ providerID, modelID })
|
||||
return resolve(model, project(record.provider, yield* active()))
|
||||
return projectModel(model, record.provider)
|
||||
}),
|
||||
|
||||
all: Effect.fn("CatalogV2.model.all")(function* () {
|
||||
const credentials = yield* active()
|
||||
return pipe(
|
||||
Array.fromIterable(state.get().providers.values()),
|
||||
Array.flatMap((record) => {
|
||||
const provider = project(record.provider, credentials)
|
||||
return Array.fromIterable(record.models.values()).map((model) => resolve(model, provider))
|
||||
return Array.fromIterable(record.models.values()).map((model) => projectModel(model, record.provider))
|
||||
}),
|
||||
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
|
||||
)
|
||||
}),
|
||||
|
||||
available: Effect.fn("CatalogV2.model.available")(function* () {
|
||||
const providers = new Map((yield* result.provider.all()).map((provider) => [provider.id, provider]))
|
||||
return (yield* result.model.all()).filter(
|
||||
(model) => providers.get(model.providerID)?.enabled !== false && model.enabled,
|
||||
)
|
||||
const providers = new Set((yield* result.provider.available()).map((provider) => provider.id))
|
||||
return (yield* result.model.all()).filter((model) => providers.has(model.providerID) && model.enabled)
|
||||
}),
|
||||
|
||||
default: Effect.fn("CatalogV2.model.default")(function* () {
|
||||
const defaultModel = state.get().defaultModel
|
||||
if (defaultModel) {
|
||||
const provider = yield* result.provider.get(defaultModel.providerID).pipe(Effect.option)
|
||||
if (Option.isSome(provider) && provider.value.enabled !== false) {
|
||||
if (
|
||||
Option.isSome(provider) &&
|
||||
(yield* result.provider.available()).some((item) => item.id === provider.value.id)
|
||||
) {
|
||||
const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option)
|
||||
if (Option.isSome(model) && model.value.enabled) return model
|
||||
}
|
||||
|
|
@ -295,11 +282,11 @@ export const layer = Layer.effect(
|
|||
small: Effect.fn("CatalogV2.model.small")(function* (providerID) {
|
||||
const record = state.get().providers.get(providerID)
|
||||
if (!record) return Option.none<ModelV2.Info>()
|
||||
const provider = project(record.provider, yield* active())
|
||||
const provider = record.provider
|
||||
|
||||
if (providerID === ProviderV2.ID.opencode) {
|
||||
const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
|
||||
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(resolve(gpt5Nano, provider))
|
||||
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(projectModel(gpt5Nano, provider))
|
||||
}
|
||||
|
||||
const candidates = pipe(
|
||||
|
|
@ -327,7 +314,7 @@ export const layer = Layer.effect(
|
|||
return pipe(
|
||||
items,
|
||||
Array.sortWith((item) => (item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2, Order.Number),
|
||||
Array.map((item) => resolve(item.model, provider)),
|
||||
Array.map((item) => projectModel(item.model, provider)),
|
||||
Array.head,
|
||||
)
|
||||
}
|
||||
|
|
@ -348,6 +335,7 @@ export const layer = Layer.effect(
|
|||
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
|
||||
|
||||
export const locationLayer = layer.pipe(
|
||||
Layer.provideMerge(Integration.locationLayer),
|
||||
Layer.provideMerge(PluginV2.locationLayer),
|
||||
Layer.provideMerge(Policy.locationLayer),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ export * as ConfigProviderPlugin from "./provider"
|
|||
import { Effect } from "effect"
|
||||
import { Catalog } from "../../catalog"
|
||||
import { Config } from "../../config"
|
||||
import { Integration } from "../../integration"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { ModelRequest } from "../../model-request"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
|
|
@ -13,9 +14,33 @@ export const Plugin = PluginV2.define({
|
|||
effect: Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const config = yield* Config.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const transform = yield* catalog.transform()
|
||||
const integrationTransform = yield* integrations.transform()
|
||||
const entries = yield* config.entries()
|
||||
const files = entries.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
const configuredIntegrations = new Set(
|
||||
files.flatMap((file) =>
|
||||
Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => (provider.env === undefined ? [] : [id])),
|
||||
),
|
||||
)
|
||||
yield* integrationTransform((integrations) => {
|
||||
for (const file of files) {
|
||||
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
|
||||
const integrationID = Integration.ID.make(id)
|
||||
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
|
||||
integrations.update(integrationID, (integration) => {
|
||||
integration.name = item.name ?? integration.name
|
||||
})
|
||||
if (item.env !== undefined) {
|
||||
integrations.method.update({
|
||||
integrationID,
|
||||
method: { type: "env", names: [...item.env] },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
yield* transform((catalog) => {
|
||||
const configuredDefault = Config.latest(entries, "model")
|
||||
|
|
@ -28,8 +53,6 @@ export const Plugin = PluginV2.define({
|
|||
const providerID = ProviderV2.ID.make(id)
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
if (item.name !== undefined) provider.name = item.name
|
||||
if (item.env !== undefined) provider.env = [...item.env]
|
||||
provider.enabled = { via: "custom", data: {} }
|
||||
if (item.api !== undefined) provider.api = { ...item.api }
|
||||
if (item.request !== undefined) {
|
||||
Object.assign(provider.request.headers, item.request.headers)
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ export interface Interface {
|
|||
readonly all: () => Effect.Effect<Stored[]>
|
||||
/** Returns stored credentials belonging to one integration. */
|
||||
readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Stored[]>
|
||||
/** Returns one stored credential by ID. */
|
||||
readonly get: (id: ID) => Effect.Effect<Stored | undefined>
|
||||
/** Replaces any credential for an integration and returns the new record. */
|
||||
readonly create: (input: {
|
||||
readonly integrationID: IntegrationSchema.ID
|
||||
|
|
@ -99,6 +101,10 @@ export const layer = Layer.effect(
|
|||
return credential ? [credential] : []
|
||||
})
|
||||
}),
|
||||
get: Effect.fn("Credential.get")(function* (id) {
|
||||
const row = yield* db.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get().pipe(Effect.orDie)
|
||||
return row ? stored(row) : undefined
|
||||
}),
|
||||
create: Effect.fn("Credential.create")(function* (input) {
|
||||
const credential = new Stored({
|
||||
id: ID.create(),
|
||||
|
|
|
|||
|
|
@ -29,15 +29,16 @@ export const When = Schema.Struct({
|
|||
}).annotate({ identifier: "Integration.When" })
|
||||
export type When = typeof When.Type
|
||||
|
||||
export class TextPrompt extends Schema.Class<TextPrompt>("Integration.TextPrompt")({
|
||||
export const TextPrompt = Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
key: Schema.String,
|
||||
message: Schema.String,
|
||||
placeholder: Schema.optional(Schema.String),
|
||||
when: Schema.optional(When),
|
||||
}) {}
|
||||
}).annotate({ identifier: "Integration.TextPrompt" })
|
||||
export type TextPrompt = typeof TextPrompt.Type
|
||||
|
||||
export class SelectPrompt extends Schema.Class<SelectPrompt>("Integration.SelectPrompt")({
|
||||
export const SelectPrompt = Schema.Struct({
|
||||
type: Schema.Literal("select"),
|
||||
key: Schema.String,
|
||||
message: Schema.String,
|
||||
|
|
@ -49,27 +50,31 @@ export class SelectPrompt extends Schema.Class<SelectPrompt>("Integration.Select
|
|||
}),
|
||||
),
|
||||
when: Schema.optional(When),
|
||||
}) {}
|
||||
}).annotate({ identifier: "Integration.SelectPrompt" })
|
||||
export type SelectPrompt = typeof SelectPrompt.Type
|
||||
|
||||
export const Prompt = Schema.Union([TextPrompt, SelectPrompt]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type Prompt = typeof Prompt.Type
|
||||
|
||||
export class OAuthMethod extends Schema.Class<OAuthMethod>("Integration.OAuthMethod")({
|
||||
export const OAuthMethod = Schema.Struct({
|
||||
id: MethodID,
|
||||
type: Schema.Literal("oauth"),
|
||||
label: Schema.String,
|
||||
prompts: Schema.optional(Schema.Array(Prompt)),
|
||||
}) {}
|
||||
}).annotate({ identifier: "Integration.OAuthMethod" })
|
||||
export type OAuthMethod = typeof OAuthMethod.Type
|
||||
|
||||
export class KeyMethod extends Schema.Class<KeyMethod>("Integration.KeyMethod")({
|
||||
export const KeyMethod = Schema.Struct({
|
||||
type: Schema.Literal("key"),
|
||||
label: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
}).annotate({ identifier: "Integration.KeyMethod" })
|
||||
export type KeyMethod = typeof KeyMethod.Type
|
||||
|
||||
export class EnvMethod extends Schema.Class<EnvMethod>("Integration.EnvMethod")({
|
||||
export const EnvMethod = Schema.Struct({
|
||||
type: Schema.Literal("env"),
|
||||
names: Schema.Array(Schema.String),
|
||||
}) {}
|
||||
}).annotate({ identifier: "Integration.EnvMethod" })
|
||||
export type EnvMethod = typeof EnvMethod.Type
|
||||
|
||||
export const Method = Schema.Union([OAuthMethod, KeyMethod, EnvMethod]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type Method = typeof Method.Type
|
||||
|
|
@ -197,7 +202,11 @@ export interface Interface {
|
|||
readonly get: (id: ID) => Effect.Effect<Info | undefined>
|
||||
/** Returns all integrations with their methods and current connections. */
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
readonly connect: {
|
||||
readonly connection: {
|
||||
/** Returns active connections for every registered or credential-backed integration. */
|
||||
readonly list: () => Effect.Effect<Map<ID, IntegrationConnection.Info>>
|
||||
/** Returns the active connection for one integration. */
|
||||
readonly forIntegration: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined>
|
||||
/** Runs a key method and stores the resulting credential. */
|
||||
readonly key: (input: {
|
||||
/** Integration receiving the credential. */
|
||||
|
|
@ -218,6 +227,13 @@ export interface Interface {
|
|||
/** User-facing label for the credential created on completion. */
|
||||
readonly label?: string
|
||||
}) => Effect.Effect<Attempt, AuthorizationError>
|
||||
/** Updates a stored credential exposed as a connection. */
|
||||
readonly update: (
|
||||
credentialID: Credential.ID,
|
||||
updates: Partial<Pick<Credential.Stored, "label">>,
|
||||
) => Effect.Effect<void>
|
||||
/** Removes a stored credential connection. */
|
||||
readonly remove: (credentialID: Credential.ID) => Effect.Effect<void>
|
||||
}
|
||||
readonly attempt: {
|
||||
/** Returns the current state of an OAuth attempt. */
|
||||
|
|
@ -326,23 +342,32 @@ export const locationLayer = Layer.effect(
|
|||
})
|
||||
|
||||
const connections = (entry: Entry, saved: readonly Credential.Stored[]): IntegrationConnection.Info[] => {
|
||||
const connected = saved.map(
|
||||
(credential) =>
|
||||
new IntegrationConnection.CredentialInfo({ type: "credential", id: credential.id, label: credential.label }),
|
||||
)
|
||||
const connected = saved.map((credential) => ({
|
||||
type: "credential" as const,
|
||||
id: credential.id,
|
||||
label: credential.label,
|
||||
}))
|
||||
const detected = entry.methods
|
||||
.filter((method) => method.type === "env")
|
||||
.flatMap((method) => method.names.filter((name) => process.env[name]))
|
||||
.map(
|
||||
(name, index) =>
|
||||
new IntegrationConnection.EnvInfo({
|
||||
type: "env",
|
||||
name,
|
||||
}),
|
||||
)
|
||||
.map((name) => ({ type: "env" as const, name }))
|
||||
return [...connected, ...detected]
|
||||
}
|
||||
|
||||
const activeConnection = (
|
||||
entry: Entry | undefined,
|
||||
saved: readonly Credential.Stored[],
|
||||
): IntegrationConnection.Info | undefined => {
|
||||
const credential = saved.at(-1)
|
||||
if (credential) return { type: "credential", id: credential.id, label: credential.label }
|
||||
if (!entry) return
|
||||
const name = entry.methods
|
||||
.filter((method) => method.type === "env")
|
||||
.flatMap((method) => method.names)
|
||||
.find((name) => process.env[name])
|
||||
if (name) return { type: "env", name }
|
||||
}
|
||||
|
||||
const project = (entry: Entry, saved: readonly Credential.Stored[]) =>
|
||||
new Info({
|
||||
id: entry.ref.id,
|
||||
|
|
@ -382,6 +407,7 @@ export const locationLayer = Layer.effect(
|
|||
? new Credential.OAuth({ ...exit.value, methodID: result.methodID })
|
||||
: exit.value,
|
||||
})
|
||||
yield* events.publish(Event.Updated, {})
|
||||
}
|
||||
yield* close(result.scope)
|
||||
})
|
||||
|
|
@ -421,8 +447,21 @@ export const locationLayer = Layer.effect(
|
|||
}),
|
||||
)).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
}),
|
||||
connect: {
|
||||
key: Effect.fn("Integration.connect.key")(function* (input) {
|
||||
connection: {
|
||||
list: Effect.fn("Integration.connection.list")(function* () {
|
||||
const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
|
||||
return new Map(
|
||||
new Set([...state.get().integrations.keys(), ...saved.keys()]).values().flatMap((id) => {
|
||||
const connection = activeConnection(state.get().integrations.get(id), saved.get(id) ?? [])
|
||||
return connection ? [[id, connection] as const] : []
|
||||
}),
|
||||
)
|
||||
}),
|
||||
forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) {
|
||||
const entry = state.get().integrations.get(id)
|
||||
return activeConnection(entry, yield* credentials.list(id))
|
||||
}),
|
||||
key: Effect.fn("Integration.connection.key")(function* (input) {
|
||||
const method = state
|
||||
.get()
|
||||
.integrations.get(input.integrationID)
|
||||
|
|
@ -433,8 +472,9 @@ export const locationLayer = Layer.effect(
|
|||
label: input.label,
|
||||
value: new Credential.Key({ type: "key", key: input.key }),
|
||||
})
|
||||
yield* events.publish(Event.Updated, {})
|
||||
}),
|
||||
oauth: Effect.fn("Integration.connect.oauth")(function* (input) {
|
||||
oauth: Effect.fn("Integration.connection.oauth")(function* (input) {
|
||||
const method = state.get().integrations.get(input.integrationID)?.implementations.get(input.methodID)
|
||||
if (!method) {
|
||||
return yield* Effect.die(`OAuth method not found: ${input.integrationID}/${input.methodID}`)
|
||||
|
|
@ -474,6 +514,14 @@ export const locationLayer = Layer.effect(
|
|||
time,
|
||||
})
|
||||
}),
|
||||
update: Effect.fn("Integration.connection.update")(function* (credentialID, updates) {
|
||||
yield* credentials.update(credentialID, updates)
|
||||
yield* events.publish(Event.Updated, {})
|
||||
}),
|
||||
remove: Effect.fn("Integration.connection.remove")(function* (credentialID) {
|
||||
yield* credentials.remove(credentialID)
|
||||
yield* events.publish(Event.Updated, {})
|
||||
}),
|
||||
},
|
||||
attempt: {
|
||||
status: Effect.fn("Integration.attempt.status")(function* (attemptID) {
|
||||
|
|
|
|||
|
|
@ -3,16 +3,18 @@ export * as IntegrationConnection from "./connection"
|
|||
import { Schema } from "effect"
|
||||
import { Credential } from "../credential"
|
||||
|
||||
export class CredentialInfo extends Schema.Class<CredentialInfo>("Connection.CredentialInfo")({
|
||||
export const CredentialInfo = Schema.Struct({
|
||||
type: Schema.Literal("credential"),
|
||||
id: Credential.ID,
|
||||
label: Schema.String,
|
||||
}) {}
|
||||
}).annotate({ identifier: "Connection.CredentialInfo" })
|
||||
export type CredentialInfo = typeof CredentialInfo.Type
|
||||
|
||||
export class EnvInfo extends Schema.Class<EnvInfo>("Connection.EnvInfo")({
|
||||
export const EnvInfo = Schema.Struct({
|
||||
type: Schema.Literal("env"),
|
||||
name: Schema.String,
|
||||
}) {}
|
||||
}).annotate({ identifier: "Connection.EnvInfo" })
|
||||
export type EnvInfo = typeof EnvInfo.Type
|
||||
|
||||
export const Info = Schema.Union([CredentialInfo, EnvInfo])
|
||||
.pipe(Schema.toTaggedUnion("type"))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Project } from "./project"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { AbsolutePath, optionalOmitUndefined } from "./schema"
|
||||
import { WorkspaceV2 } from "./workspace"
|
||||
|
||||
export * as Location from "./location"
|
||||
|
|
@ -12,7 +12,7 @@ export class Ref extends Schema.Class<Ref>("Location.Ref")({
|
|||
|
||||
export class Info extends Schema.Class<Info>("Location.Info")({
|
||||
directory: AbsolutePath,
|
||||
workspaceID: WorkspaceV2.ID.pipe(Schema.optional),
|
||||
workspaceID: optionalOmitUndefined(WorkspaceV2.ID),
|
||||
project: Schema.Struct({
|
||||
id: Project.ID,
|
||||
directory: AbsolutePath,
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import { AgentPlugin } from "./agent"
|
|||
import { CommandPlugin } from "./command"
|
||||
import { SkillPlugin } from "./skill"
|
||||
import { ConfigProviderPlugin } from "../config/plugin/provider"
|
||||
import { EnvPlugin } from "./env"
|
||||
import { ModelsDevPlugin } from "./models-dev"
|
||||
import { ProviderPlugins } from "./provider"
|
||||
import { SkillV2 } from "../skill"
|
||||
|
|
@ -99,7 +98,6 @@ export const layer = Layer.effect(
|
|||
})
|
||||
|
||||
const boot = Effect.gen(function* () {
|
||||
yield* add(EnvPlugin)
|
||||
yield* add(AgentPlugin.Plugin)
|
||||
yield* add(CommandPlugin.Plugin)
|
||||
yield* add(SkillPlugin.Plugin)
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../plugin"
|
||||
|
||||
export const EnvPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("env"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
const key = item.provider.env.find((env) => process.env[env])
|
||||
if (!key) continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.enabled = {
|
||||
via: "env",
|
||||
name: key,
|
||||
}
|
||||
})
|
||||
}
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
|
@ -70,16 +70,11 @@ export const ModelsDevPlugin = PluginV2.define({
|
|||
integrations.update(integrationID, (integration) => (integration.name = item.name))
|
||||
integrations.method.update({
|
||||
integrationID,
|
||||
method: new Integration.KeyMethod({
|
||||
type: "key",
|
||||
}),
|
||||
method: { type: "key" },
|
||||
})
|
||||
integrations.method.update({
|
||||
integrationID,
|
||||
method: new Integration.EnvMethod({
|
||||
type: "env",
|
||||
names: [...item.env],
|
||||
}),
|
||||
method: { type: "env", names: [...item.env] },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -88,7 +83,6 @@ export const ModelsDevPlugin = PluginV2.define({
|
|||
const providerID = ProviderV2.ID.make(item.id)
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.name = item.name
|
||||
provider.env = [...item.env]
|
||||
provider.api = item.npm
|
||||
? {
|
||||
type: "aisdk",
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
import { Effect } from "effect"
|
||||
import { Integration } from "../../integration"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
|
||||
export const LLMGatewayPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("llmgateway"),
|
||||
effect: Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.enabled === false) continue
|
||||
if (item.provider.disabled) continue
|
||||
if (!(yield* integrations.get(Integration.ID.make(item.provider.id)))) continue
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue
|
||||
|
|
|
|||
|
|
@ -32,11 +32,11 @@ const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
|
|||
|
||||
export const browser = {
|
||||
integrationID: Integration.ID.make("openai"),
|
||||
method: new Integration.OAuthMethod({
|
||||
method: {
|
||||
id: browserMethodID,
|
||||
type: "oauth",
|
||||
label: "ChatGPT Pro/Plus (browser)",
|
||||
}),
|
||||
},
|
||||
authorize: () =>
|
||||
Effect.gen(function* () {
|
||||
const pkce = yield* Effect.promise(generatePKCE)
|
||||
|
|
@ -89,11 +89,11 @@ export const browser = {
|
|||
|
||||
export const headless = {
|
||||
integrationID: Integration.ID.make("openai"),
|
||||
method: new Integration.OAuthMethod({
|
||||
method: {
|
||||
id: headlessMethodID,
|
||||
type: "oauth",
|
||||
label: "ChatGPT Pro/Plus (headless)",
|
||||
}),
|
||||
},
|
||||
authorize: () =>
|
||||
Effect.gen(function* () {
|
||||
const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>(
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
import { Effect } from "effect"
|
||||
import { Integration } from "../../integration"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const OpencodePlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("opencode"),
|
||||
effect: Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
let hasKey = false
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
const item = evt.provider.get(ProviderV2.ID.opencode)
|
||||
if (!item) return
|
||||
const integration = yield* integrations.get(Integration.ID.make(item.provider.id))
|
||||
hasKey = Boolean(
|
||||
process.env.OPENCODE_API_KEY ||
|
||||
item.provider.env.some((env) => process.env[env]) ||
|
||||
item.provider.request.body.apiKey ||
|
||||
(item.provider.enabled && item.provider.enabled.via === "credential"),
|
||||
process.env.OPENCODE_API_KEY || integration?.connections.length || item.provider.request.body.apiKey,
|
||||
)
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
if (!hasKey) provider.request.body.apiKey = "public"
|
||||
|
|
|
|||
|
|
@ -70,14 +70,17 @@ export const SnowflakeCortexPlugin = PluginV2.define({
|
|||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return
|
||||
const pat =
|
||||
process.env.SNOWFLAKE_CORTEX_PAT ?? (typeof evt.options.apiKey === "string" ? evt.options.apiKey : undefined)
|
||||
const token =
|
||||
process.env.SNOWFLAKE_CORTEX_TOKEN ??
|
||||
process.env.SNOWFLAKE_CORTEX_PAT ??
|
||||
(typeof evt.options.token === "string" ? evt.options.token : undefined) ??
|
||||
(typeof evt.options.apiKey === "string" ? evt.options.apiKey : undefined)
|
||||
const upstream = typeof evt.options.fetch === "function" ? (evt.options.fetch as FetchLike) : undefined
|
||||
if (evt.options.includeUsage !== false) evt.options.includeUsage = true
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible"))
|
||||
evt.sdk = mod.createOpenAICompatible({
|
||||
...evt.options,
|
||||
...(pat ? { apiKey: pat } : {}),
|
||||
...(token ? { apiKey: token } : {}),
|
||||
fetch: cortexFetch(upstream) as typeof fetch,
|
||||
} as any)
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -245,6 +245,7 @@ export const layer = Layer.effect(
|
|||
(sourceDirectory) =>
|
||||
Effect.forEach(strategies(), (strategy) =>
|
||||
strategy.list(sourceDirectory).pipe(
|
||||
Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed([])),
|
||||
Effect.map((items) =>
|
||||
items.map((item) => ({
|
||||
directory: item.directory,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ export * as ProviderV2 from "./provider"
|
|||
|
||||
import { withStatics } from "./schema"
|
||||
import { Schema } from "effect"
|
||||
import { Credential } from "./credential"
|
||||
|
||||
export const ID = Schema.String.pipe(
|
||||
Schema.brand("ProviderV2.ID"),
|
||||
|
|
@ -48,22 +47,7 @@ export type Request = typeof Request.Type
|
|||
export class Info extends Schema.Class<Info>("ProviderV2.Info")({
|
||||
id: ID,
|
||||
name: Schema.String,
|
||||
enabled: Schema.Union([
|
||||
Schema.Literal(false),
|
||||
Schema.Struct({
|
||||
via: Schema.Literal("env"),
|
||||
name: Schema.String,
|
||||
}),
|
||||
Schema.Struct({
|
||||
via: Schema.Literal("credential"),
|
||||
credentialID: Credential.ID,
|
||||
}),
|
||||
Schema.Struct({
|
||||
via: Schema.Literal("custom"),
|
||||
data: Schema.Record(Schema.String, Schema.Any),
|
||||
}),
|
||||
]),
|
||||
env: Schema.String.pipe(Schema.Array),
|
||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||
api: Api,
|
||||
request: Request,
|
||||
}) {
|
||||
|
|
@ -71,8 +55,6 @@ export class Info extends Schema.Class<Info>("ProviderV2.Info")({
|
|||
return new Info({
|
||||
id: providerID,
|
||||
name: providerID,
|
||||
enabled: false,
|
||||
env: [],
|
||||
api: {
|
||||
type: "native",
|
||||
settings: {},
|
||||
|
|
|
|||
|
|
@ -2,22 +2,27 @@ export * as Pty from "./pty"
|
|||
|
||||
import type { Disp, Proc } from "#pty"
|
||||
import { Context, Effect, Layer, Schema, Types } from "effect"
|
||||
import { Config } from "./config"
|
||||
import { EventV2 } from "./event"
|
||||
import { Location } from "./location"
|
||||
import { NonNegativeInt, PositiveInt } from "./schema"
|
||||
import { PtyID } from "./pty/schema"
|
||||
import { Shell } from "./shell"
|
||||
import { lazy } from "./util/lazy"
|
||||
|
||||
const BUFFER_LIMIT = 1024 * 1024 * 2
|
||||
const BUFFER_CHUNK = 64 * 1024
|
||||
const encoder = new TextEncoder()
|
||||
// Exited sessions stay observable (status, exit code, retained output) until removed explicitly.
|
||||
// Cap retention so abandoned terminals do not accumulate unbounded buffers.
|
||||
const EXITED_LIMIT = 25
|
||||
const pty = lazy(() => import("#pty"))
|
||||
|
||||
type Socket = {
|
||||
readyState: number
|
||||
data?: unknown
|
||||
send: (data: string | Uint8Array | ArrayBuffer) => void
|
||||
close: (code?: number, reason?: string) => void
|
||||
type Subscriber = {
|
||||
readonly onData: (chunk: string) => void
|
||||
readonly onEnd: (event: { exitCode?: number }) => void
|
||||
active: boolean
|
||||
detached: boolean
|
||||
pending: string[]
|
||||
end?: { exitCode?: number }
|
||||
}
|
||||
|
||||
type Active = {
|
||||
|
|
@ -26,22 +31,10 @@ type Active = {
|
|||
buffer: string
|
||||
bufferCursor: number
|
||||
cursor: number
|
||||
subscribers: Map<unknown, Socket>
|
||||
subscribers: Map<object, Subscriber>
|
||||
listeners: Disp[]
|
||||
}
|
||||
|
||||
const sock = (ws: Socket) => (ws.data && typeof ws.data === "object" ? ws.data : ws)
|
||||
|
||||
// WebSocket control frame: 0x00 + UTF-8 JSON.
|
||||
const meta = (cursor: number) => {
|
||||
const json = JSON.stringify({ cursor })
|
||||
const bytes = encoder.encode(json)
|
||||
const out = new Uint8Array(bytes.length + 1)
|
||||
out[0] = 0
|
||||
out.set(bytes, 1)
|
||||
return out
|
||||
}
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
id: PtyID,
|
||||
title: Schema.String,
|
||||
|
|
@ -51,6 +44,8 @@ export const Info = Schema.Struct({
|
|||
status: Schema.Literals(["running", "exited"]),
|
||||
// Windows ConPTY assigns the child pid asynchronously, so 0 is valid at spawn time.
|
||||
pid: NonNegativeInt,
|
||||
// Present once status is "exited".
|
||||
exitCode: Schema.optional(NonNegativeInt),
|
||||
}).annotate({ identifier: "Pty" })
|
||||
|
||||
export type Info = Types.DeepMutable<typeof Info.Type>
|
||||
|
|
@ -65,14 +60,6 @@ export const CreateInput = Schema.Struct({
|
|||
|
||||
export type CreateInput = Types.DeepMutable<typeof CreateInput.Type>
|
||||
|
||||
export type PreparedCreate = {
|
||||
readonly command: string
|
||||
readonly args: string[]
|
||||
readonly cwd: string
|
||||
readonly title?: string
|
||||
readonly env: Record<string, string>
|
||||
}
|
||||
|
||||
export const UpdateInput = Schema.Struct({
|
||||
title: Schema.optional(Schema.String),
|
||||
size: Schema.optional(
|
||||
|
|
@ -85,10 +72,34 @@ export const UpdateInput = Schema.Struct({
|
|||
|
||||
export type UpdateInput = Types.DeepMutable<typeof UpdateInput.Type>
|
||||
|
||||
export type AttachInput = {
|
||||
// Absolute output cursor to replay from. -1 tails from the current end; omitted replays the full retained buffer.
|
||||
readonly cursor?: number
|
||||
// Callbacks fire synchronously from the native PTY data path; keep them non-blocking.
|
||||
readonly onData: (chunk: string) => void
|
||||
// Fired once when the session stops producing output: process exit (exitCode set), removal, or service teardown.
|
||||
readonly onEnd: (event: { exitCode?: number }) => void
|
||||
}
|
||||
|
||||
export type Attachment = {
|
||||
// Retained output from the requested cursor to the current end.
|
||||
readonly replay: string
|
||||
// Absolute output cursor after replay.
|
||||
readonly cursor: number
|
||||
readonly write: (data: string) => void
|
||||
// Starts live delivery after the caller has applied replay and cursor metadata.
|
||||
readonly activate: () => void
|
||||
readonly detach: () => void
|
||||
}
|
||||
|
||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Pty.NotFoundError", {
|
||||
ptyID: PtyID,
|
||||
}) {}
|
||||
|
||||
export class ExitedError extends Schema.TaggedErrorClass<ExitedError>()("Pty.ExitedError", {
|
||||
ptyID: PtyID,
|
||||
}) {}
|
||||
|
||||
export const Event = {
|
||||
Created: EventV2.define({ type: "pty.created", schema: { info: Info } }),
|
||||
Updated: EventV2.define({ type: "pty.updated", schema: { info: Info } }),
|
||||
|
|
@ -99,19 +110,11 @@ export const Event = {
|
|||
export interface Interface {
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
readonly get: (id: PtyID) => Effect.Effect<Info, NotFoundError>
|
||||
readonly create: (input: PreparedCreate) => Effect.Effect<Info>
|
||||
readonly create: (input: CreateInput) => Effect.Effect<Info>
|
||||
readonly update: (id: PtyID, input: UpdateInput) => Effect.Effect<Info, NotFoundError>
|
||||
readonly remove: (id: PtyID) => Effect.Effect<void, NotFoundError>
|
||||
readonly resize: (id: PtyID, cols: number, rows: number) => Effect.Effect<void, NotFoundError>
|
||||
readonly write: (id: PtyID, data: string) => Effect.Effect<void, NotFoundError>
|
||||
readonly connect: (
|
||||
id: PtyID,
|
||||
ws: Socket,
|
||||
cursor?: number,
|
||||
) => Effect.Effect<
|
||||
{ onMessage: (message: string | ArrayBuffer) => void; onClose: () => void } | undefined,
|
||||
NotFoundError
|
||||
>
|
||||
readonly attach: (id: PtyID, input: AttachInput) => Effect.Effect<Attachment, NotFoundError | ExitedError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Pty") {}
|
||||
|
|
@ -121,28 +124,41 @@ export const layer = Layer.effect(
|
|||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const location = yield* Location.Service
|
||||
const config = yield* Config.Service
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
const sessions = new Map<PtyID, Active>()
|
||||
const exitOrder: PtyID[] = []
|
||||
|
||||
function notifyEnd(session: Active, event: { exitCode?: number }) {
|
||||
for (const subscriber of session.subscribers.values()) {
|
||||
if (!subscriber.active) {
|
||||
subscriber.end = event
|
||||
continue
|
||||
}
|
||||
try {
|
||||
subscriber.onEnd(event)
|
||||
} catch {}
|
||||
}
|
||||
session.subscribers.clear()
|
||||
}
|
||||
|
||||
function teardown(session: Active) {
|
||||
for (const listener of session.listeners) listener.dispose()
|
||||
session.listeners.length = 0
|
||||
try {
|
||||
session.process.kill()
|
||||
} catch {}
|
||||
for (const [sub, ws] of session.subscribers.entries()) {
|
||||
if (session.info.status === "running") {
|
||||
try {
|
||||
if (sock(ws) === sub) ws.close()
|
||||
session.process.kill()
|
||||
} catch {}
|
||||
}
|
||||
session.subscribers.clear()
|
||||
notifyEnd(session, {})
|
||||
}
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
for (const session of sessions.values()) teardown(session)
|
||||
sessions.clear()
|
||||
exitOrder.length = 0
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -154,12 +170,13 @@ export const layer = Layer.effect(
|
|||
|
||||
const removeSession = Effect.fnUntraced(function* (id: PtyID) {
|
||||
const session = sessions.get(id)
|
||||
if (!session) return false
|
||||
if (!session) return
|
||||
sessions.delete(id)
|
||||
const index = exitOrder.indexOf(id)
|
||||
if (index !== -1) exitOrder.splice(index, 1)
|
||||
yield* Effect.logInfo("removing session", { id })
|
||||
teardown(session)
|
||||
yield* events.publish(Event.Deleted, { id: session.info.id })
|
||||
return true
|
||||
})
|
||||
|
||||
const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
|
||||
|
|
@ -175,26 +192,34 @@ export const layer = Layer.effect(
|
|||
return (yield* requireSession(id)).info
|
||||
})
|
||||
|
||||
const create = Effect.fn("Pty.create")(function* (input: PreparedCreate) {
|
||||
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
|
||||
const id = PtyID.ascending()
|
||||
yield* Effect.logInfo("creating session", { id, cmd: input.command, args: input.args, cwd: input.cwd })
|
||||
const command = input.command || Shell.preferred(Config.latest(yield* config.entries(), "shell"))
|
||||
const args = Shell.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
|
||||
const cwd = input.cwd || location.directory
|
||||
const env = {
|
||||
...process.env,
|
||||
...input.env,
|
||||
TERM: "xterm-256color",
|
||||
OPENCODE_TERMINAL: "1",
|
||||
} as Record<string, string>
|
||||
if (process.platform === "win32") {
|
||||
env.LC_ALL = "C.UTF-8"
|
||||
env.LC_CTYPE = "C.UTF-8"
|
||||
env.LANG = "C.UTF-8"
|
||||
}
|
||||
yield* Effect.logInfo("creating session", { id, cmd: command, args, cwd })
|
||||
const { spawn } = yield* Effect.promise(() => pty())
|
||||
const proc = yield* Effect.sync(() =>
|
||||
spawn(input.command, input.args, {
|
||||
name: "xterm-256color",
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
}),
|
||||
)
|
||||
const info = {
|
||||
const proc = yield* Effect.sync(() => spawn(command, args, { name: "xterm-256color", cwd, env }))
|
||||
const info: Info = {
|
||||
id,
|
||||
title: input.title || `Terminal ${id.slice(-4)}`,
|
||||
command: input.command,
|
||||
args: input.args,
|
||||
cwd: input.cwd,
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
status: "running",
|
||||
pid: proc.pid,
|
||||
} as const
|
||||
}
|
||||
const session: Active = {
|
||||
info,
|
||||
process: proc,
|
||||
|
|
@ -208,15 +233,15 @@ export const layer = Layer.effect(
|
|||
session.listeners.push(
|
||||
proc.onData((chunk) => {
|
||||
session.cursor += chunk.length
|
||||
for (const [key, ws] of session.subscribers.entries()) {
|
||||
if (ws.readyState !== 1 || sock(ws) !== key) {
|
||||
session.subscribers.delete(key)
|
||||
for (const [token, subscriber] of session.subscribers.entries()) {
|
||||
if (!subscriber.active) {
|
||||
subscriber.pending.push(chunk)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
ws.send(chunk)
|
||||
subscriber.onData(chunk)
|
||||
} catch {
|
||||
session.subscribers.delete(key)
|
||||
session.subscribers.delete(token)
|
||||
}
|
||||
}
|
||||
session.buffer += chunk
|
||||
|
|
@ -227,12 +252,19 @@ export const layer = Layer.effect(
|
|||
}),
|
||||
proc.onExit(({ exitCode }) => {
|
||||
if (session.info.status === "exited") return
|
||||
session.info.status = "exited"
|
||||
session.info.exitCode = exitCode
|
||||
notifyEnd(session, { exitCode })
|
||||
exitOrder.push(id)
|
||||
runFork(
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.logInfo("session exited", { id, exitCode })
|
||||
session.info.status = "exited"
|
||||
yield* events.publish(Event.Exited, { id, exitCode })
|
||||
yield* removeSession(id)
|
||||
while (exitOrder.length > EXITED_LIMIT) {
|
||||
const oldest = exitOrder[0]
|
||||
if (!oldest) break
|
||||
yield* removeSession(oldest)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
|
@ -244,66 +276,71 @@ export const layer = Layer.effect(
|
|||
const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) {
|
||||
const session = yield* requireSession(id)
|
||||
if (input.title) session.info.title = input.title
|
||||
if (input.size) session.process.resize(input.size.cols, input.size.rows)
|
||||
if (input.size && session.info.status === "running") session.process.resize(input.size.cols, input.size.rows)
|
||||
yield* events.publish(Event.Updated, { info: session.info })
|
||||
return session.info
|
||||
})
|
||||
|
||||
const resize = Effect.fn("Pty.resize")(function* (id: PtyID, cols: number, rows: number) {
|
||||
const session = yield* requireSession(id)
|
||||
if (session.info.status === "running") session.process.resize(cols, rows)
|
||||
})
|
||||
|
||||
const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) {
|
||||
const session = yield* requireSession(id)
|
||||
if (session.info.status === "running") session.process.write(data)
|
||||
})
|
||||
|
||||
const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) {
|
||||
const session = yield* requireSession(id).pipe(Effect.tapError(() => Effect.sync(() => ws.close())))
|
||||
yield* Effect.logInfo("client connected to session", { id, directory: location.directory })
|
||||
const sub = sock(ws)
|
||||
session.subscribers.delete(sub)
|
||||
session.subscribers.set(sub, ws)
|
||||
const cleanup = () => session.subscribers.delete(sub)
|
||||
const attach = Effect.fn("Pty.attach")(function* (id: PtyID, input: AttachInput) {
|
||||
const session = yield* requireSession(id)
|
||||
if (session.info.status !== "running") return yield* new ExitedError({ ptyID: id })
|
||||
yield* Effect.logInfo("client attached to session", { id, directory: location.directory })
|
||||
const token = {}
|
||||
const subscriber: Subscriber = {
|
||||
onData: input.onData,
|
||||
onEnd: input.onEnd,
|
||||
active: false,
|
||||
detached: false,
|
||||
pending: [],
|
||||
}
|
||||
session.subscribers.set(token, subscriber)
|
||||
const start = session.bufferCursor
|
||||
const end = session.cursor
|
||||
const from =
|
||||
cursor === -1 ? end : typeof cursor === "number" && Number.isSafeInteger(cursor) ? Math.max(0, cursor) : 0
|
||||
const data = (() => {
|
||||
input.cursor === -1
|
||||
? end
|
||||
: typeof input.cursor === "number" && Number.isSafeInteger(input.cursor)
|
||||
? Math.max(0, input.cursor)
|
||||
: 0
|
||||
const replay = (() => {
|
||||
if (!session.buffer || from >= end) return ""
|
||||
const offset = Math.max(0, from - start)
|
||||
if (offset >= session.buffer.length) return ""
|
||||
return session.buffer.slice(offset)
|
||||
})()
|
||||
if (data) {
|
||||
try {
|
||||
for (let i = 0; i < data.length; i += BUFFER_CHUNK) ws.send(data.slice(i, i + BUFFER_CHUNK))
|
||||
} catch {
|
||||
cleanup()
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
ws.send(meta(end))
|
||||
} catch {
|
||||
cleanup()
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
return {
|
||||
onMessage: (message: string | ArrayBuffer) => {
|
||||
session.process.write(typeof message === "string" ? message : new TextDecoder().decode(message))
|
||||
replay,
|
||||
cursor: end,
|
||||
write: (data: string) => {
|
||||
if (session.info.status === "running") session.process.write(data)
|
||||
},
|
||||
onClose: () => {
|
||||
cleanup()
|
||||
activate: () => {
|
||||
if (subscriber.active || subscriber.detached) return
|
||||
subscriber.active = true
|
||||
try {
|
||||
for (const chunk of subscriber.pending) subscriber.onData(chunk)
|
||||
subscriber.pending.length = 0
|
||||
if (subscriber.end) subscriber.onEnd(subscriber.end)
|
||||
} catch {
|
||||
session.subscribers.delete(token)
|
||||
}
|
||||
},
|
||||
detach: () => {
|
||||
subscriber.detached = true
|
||||
subscriber.pending.length = 0
|
||||
subscriber.end = undefined
|
||||
session.subscribers.delete(token)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({ list, get, create, update, remove, resize, write, connect })
|
||||
return Service.of({ list, get, create, update, remove, write, attach })
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer))
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
import { Effect } from "effect"
|
||||
|
||||
const inputDecoder = new TextDecoder("utf-8", { fatal: true })
|
||||
|
||||
export function handlePtyInput(
|
||||
handler: { onMessage: (message: string | ArrayBuffer) => void },
|
||||
message: string | Uint8Array,
|
||||
) {
|
||||
if (typeof message === "string") {
|
||||
handler.onMessage(message)
|
||||
return Effect.void
|
||||
}
|
||||
return Effect.try({
|
||||
try: () => inputDecoder.decode(message),
|
||||
catch: () => new Error("invalid PTY websocket input"),
|
||||
}).pipe(
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
Effect.flatMap((decoded) => {
|
||||
if (decoded === undefined) return Effect.void
|
||||
handler.onMessage(decoded)
|
||||
return Effect.void
|
||||
}),
|
||||
)
|
||||
}
|
||||
37
packages/core/src/pty/protocol.ts
Normal file
37
packages/core/src/pty/protocol.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
export * as PtyProtocol from "./protocol"
|
||||
|
||||
// Wire protocol for PTY websocket transports. The PTY domain service is transport-free; server
|
||||
// routes adapt Pty.attach to websockets with these helpers so every surface speaks one protocol.
|
||||
//
|
||||
// Outbound frames are raw UTF-8 terminal chunks. One control frame — a 0x00 byte followed by
|
||||
// UTF-8 JSON — carries the absolute output cursor after replay so clients can resume later.
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true })
|
||||
|
||||
// Replay can be megabytes; send it in bounded frames.
|
||||
export const REPLAY_CHUNK = 64 * 1024
|
||||
|
||||
export function metaFrame(cursor: number) {
|
||||
const bytes = encoder.encode(JSON.stringify({ cursor }))
|
||||
const out = new Uint8Array(bytes.length + 1)
|
||||
out[0] = 0
|
||||
out.set(bytes, 1)
|
||||
return out
|
||||
}
|
||||
|
||||
export function chunks(data: string) {
|
||||
const out: string[] = []
|
||||
for (let i = 0; i < data.length; i += REPLAY_CHUNK) out.push(data.slice(i, i + REPLAY_CHUNK))
|
||||
return out
|
||||
}
|
||||
|
||||
// Inbound client frames are UTF-8 text or binary; invalid UTF-8 input is dropped.
|
||||
export function decodeInput(message: string | Uint8Array | ArrayBuffer) {
|
||||
if (typeof message === "string") return message
|
||||
try {
|
||||
return decoder.decode(message instanceof ArrayBuffer ? new Uint8Array(message) : message)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,9 @@ import { Auth, type AnyRoute } from "@opencode-ai/llm/route"
|
|||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import { produce } from "immer"
|
||||
import { Catalog } from "../../catalog"
|
||||
import { Credential } from "../../credential"
|
||||
import { Integration } from "../../integration"
|
||||
import { IntegrationConnection } from "../../integration/connection"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { ModelRequest } from "../../model-request"
|
||||
import { PluginBoot } from "../../plugin/boot"
|
||||
|
|
@ -45,10 +48,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
|||
/** Test or embedding seam for supplying a model resolver directly. */
|
||||
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
|
||||
|
||||
const apiKey = (model: ModelV2.Info, provider?: ProviderV2.Info) => {
|
||||
const apiKey = (model: ModelV2.Info, connection?: IntegrationConnection.Info, credential?: Credential.Stored) => {
|
||||
if (credential?.value.type === "key") return Auth.value(credential.value.key)
|
||||
if (credential?.value.type === "oauth") return Auth.value(credential.value.access)
|
||||
const value = model.request.body.apiKey ?? model.api.settings?.apiKey
|
||||
if (typeof value === "string") return Auth.value(value)
|
||||
return provider?.enabled !== false && provider?.enabled.via === "env" ? Auth.config(provider.enabled.name) : undefined
|
||||
return connection?.type === "env" ? Auth.config(connection.name) : undefined
|
||||
}
|
||||
|
||||
const withDefaults = (model: ModelV2.Info, route: AnyRoute) => {
|
||||
|
|
@ -83,41 +88,48 @@ const apiName = (model: ModelV2.Info) =>
|
|||
|
||||
export const fromCatalogModel = (
|
||||
model: ModelV2.Info,
|
||||
provider?: ProviderV2.Info,
|
||||
connection?: IntegrationConnection.Info,
|
||||
credential?: Credential.Stored,
|
||||
): Effect.Effect<Model, UnsupportedApiError> => {
|
||||
const key = apiKey(model, provider)
|
||||
if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/openai") {
|
||||
const resolved =
|
||||
credential?.value.metadata === undefined
|
||||
? model
|
||||
: produce(model, (draft) => {
|
||||
Object.assign(draft.request.body, credential.value.metadata)
|
||||
})
|
||||
const key = apiKey(resolved, connection, credential)
|
||||
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") {
|
||||
return Effect.succeed(
|
||||
withDefaults(model, OpenAIResponses.route)
|
||||
withDefaults(resolved, OpenAIResponses.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
.model({ id: model.api.id }),
|
||||
.model({ id: resolved.api.id }),
|
||||
)
|
||||
}
|
||||
if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/anthropic") {
|
||||
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/anthropic") {
|
||||
return Effect.succeed(
|
||||
withDefaults(model, AnthropicMessages.route)
|
||||
withDefaults(resolved, AnthropicMessages.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
|
||||
.model({ id: model.api.id }),
|
||||
.model({ id: resolved.api.id }),
|
||||
)
|
||||
}
|
||||
if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/openai-compatible" && model.api.url) {
|
||||
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai-compatible" && resolved.api.url) {
|
||||
return Effect.succeed(
|
||||
withDefaults(model, OpenAICompatibleChat.route)
|
||||
withDefaults(resolved, OpenAICompatibleChat.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
.model({ id: model.api.id }),
|
||||
.model({ id: resolved.api.id }),
|
||||
)
|
||||
}
|
||||
return Effect.fail(
|
||||
new UnsupportedApiError({
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
api: apiName(model),
|
||||
providerID: resolved.providerID,
|
||||
modelID: resolved.id,
|
||||
api: apiName(resolved),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const resolve = (session: SessionSchema.Info, model: ModelV2.Info, provider?: ProviderV2.Info) =>
|
||||
fromCatalogModel(withVariant(model, session.model?.variant), provider)
|
||||
export const resolve = (session: SessionSchema.Info, model: ModelV2.Info) =>
|
||||
fromCatalogModel(withVariant(model, session.model?.variant))
|
||||
|
||||
export const supported = (model: ModelV2.Info) =>
|
||||
model.api.type === "aisdk" &&
|
||||
|
|
@ -130,6 +142,8 @@ export const locationLayer = Layer.effect(
|
|||
Service,
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const boot = yield* PluginBoot.Service
|
||||
return Service.of({
|
||||
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
|
||||
|
|
@ -140,7 +154,12 @@ export const locationLayer = Layer.effect(
|
|||
: (Option.getOrUndefined((yield* catalog.model.default()).pipe(Option.filter(supported))) ??
|
||||
(yield* catalog.model.available()).find(supported))
|
||||
if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
|
||||
return yield* resolve(session, selected, yield* catalog.provider.get(selected.providerID))
|
||||
const connection = yield* integrations.connection.forIntegration(Integration.ID.make(selected.providerID))
|
||||
return yield* fromCatalogModel(
|
||||
withVariant(selected, session.model?.variant),
|
||||
connection,
|
||||
connection?.type === "credential" ? yield* credentials.get(connection.id) : undefined,
|
||||
)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { which } from "@opencode-ai/core/util/which"
|
||||
export * as Shell from "./shell"
|
||||
|
||||
import path from "path"
|
||||
import { spawn, type ChildProcess } from "child_process"
|
||||
import { readFile } from "fs/promises"
|
||||
import { statSync } from "fs"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { Flag } from "./flag/flag"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { which } from "./util/which"
|
||||
|
||||
const SIGKILL_TIMEOUT_MS = 200
|
||||
const META: Record<string, { deny?: boolean; login?: boolean; posix?: boolean; ps?: boolean }> = {
|
||||
|
|
@ -47,7 +50,7 @@ export async function killTree(proc: ChildProcess, opts?: { exited?: () => boole
|
|||
if (!opts?.exited?.()) {
|
||||
process.kill(-pid, "SIGKILL")
|
||||
}
|
||||
} catch (_e) {
|
||||
} catch {
|
||||
proc.kill("SIGTERM")
|
||||
await sleep(SIGKILL_TIMEOUT_MS)
|
||||
if (!opts?.exited?.()) {
|
||||
|
|
@ -56,9 +59,13 @@ export async function killTree(proc: ChildProcess, opts?: { exited?: () => boole
|
|||
}
|
||||
}
|
||||
|
||||
function stat(file: string) {
|
||||
return statSync(file, { throwIfNoEntry: false }) ?? undefined
|
||||
}
|
||||
|
||||
function full(file: string) {
|
||||
if (process.platform !== "win32") return file
|
||||
const shell = Filesystem.windowsPath(file)
|
||||
const shell = FSUtil.windowsPath(file)
|
||||
if (path.win32.dirname(shell) !== ".") {
|
||||
if (shell.startsWith("/") && name(shell) === "bash") return gitbash() || shell
|
||||
return shell
|
||||
|
|
@ -76,13 +83,13 @@ function ok(file: string) {
|
|||
}
|
||||
|
||||
function rooted(file: string) {
|
||||
return path.isAbsolute(Filesystem.windowsPath(file))
|
||||
return path.isAbsolute(FSUtil.windowsPath(file))
|
||||
}
|
||||
|
||||
function resolve(file: string) {
|
||||
const shell = full(file)
|
||||
if (rooted(shell)) {
|
||||
if (Filesystem.stat(shell)?.isFile()) return shell
|
||||
if (stat(shell)?.isFile()) return shell
|
||||
return
|
||||
}
|
||||
return which(shell) ?? undefined
|
||||
|
|
@ -99,7 +106,7 @@ function win() {
|
|||
}
|
||||
|
||||
async function unix() {
|
||||
const text = await Filesystem.readText("/etc/shells").catch(() => "")
|
||||
const text = await readFile("/etc/shells", "utf8").catch(() => "")
|
||||
if (text) return Array.from(new Set(text.split("\n").filter((line) => line.trim() && !line.startsWith("#"))))
|
||||
return ["/bin/bash", "/bin/zsh", "/bin/sh"]
|
||||
}
|
||||
|
|
@ -109,7 +116,7 @@ function select(file: string | undefined, opts?: { acceptable?: boolean }) {
|
|||
const shell = resolve(file)
|
||||
if (shell) return shell
|
||||
}
|
||||
if (process.platform === "win32") return win()[0]!
|
||||
if (process.platform === "win32") return win()[0]
|
||||
return fallback()
|
||||
}
|
||||
|
||||
|
|
@ -119,7 +126,7 @@ export function gitbash() {
|
|||
const git = which("git")
|
||||
if (!git) return
|
||||
const file = path.join(git, "..", "..", "bin", "bash.exe")
|
||||
if (Filesystem.stat(file)?.size) return file
|
||||
if (stat(file)?.size) return file
|
||||
}
|
||||
|
||||
function fallback() {
|
||||
|
|
@ -130,7 +137,7 @@ function fallback() {
|
|||
}
|
||||
|
||||
export function name(file: string) {
|
||||
if (process.platform === "win32") return path.win32.parse(Filesystem.windowsPath(file)).name.toLowerCase()
|
||||
if (process.platform === "win32") return path.win32.parse(FSUtil.windowsPath(file)).name.toLowerCase()
|
||||
return path.basename(file).toLowerCase()
|
||||
}
|
||||
|
||||
|
|
@ -192,24 +199,28 @@ export function args(file: string, command: string, cwd: string) {
|
|||
return ["-c", command]
|
||||
}
|
||||
|
||||
const defaultPreferred = lazy(() => select(process.env.SHELL))
|
||||
const defaultAcceptable = lazy(() => select(process.env.SHELL, { acceptable: true }))
|
||||
let defaultPreferred: string | undefined
|
||||
let defaultAcceptable: string | undefined
|
||||
|
||||
export function preferred(configShell?: string) {
|
||||
if (configShell) return select(configShell)
|
||||
return defaultPreferred()
|
||||
defaultPreferred ??= select(process.env.SHELL)
|
||||
return defaultPreferred
|
||||
}
|
||||
preferred.reset = () => {
|
||||
defaultPreferred = undefined
|
||||
}
|
||||
preferred.reset = () => defaultPreferred.reset()
|
||||
|
||||
export function acceptable(configShell?: string) {
|
||||
if (configShell) return select(configShell, { acceptable: true })
|
||||
return defaultAcceptable()
|
||||
defaultAcceptable ??= select(process.env.SHELL, { acceptable: true })
|
||||
return defaultAcceptable
|
||||
}
|
||||
acceptable.reset = () => {
|
||||
defaultAcceptable = undefined
|
||||
}
|
||||
acceptable.reset = () => defaultAcceptable.reset()
|
||||
|
||||
export async function list(): Promise<Item[]> {
|
||||
const shells = process.platform === "win32" ? win() : await unix()
|
||||
return shells.filter((s) => resolve(s)).map(info)
|
||||
}
|
||||
|
||||
export * as Shell from "./shell"
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Effect, Layer, Option } from "effect"
|
||||
import { DateTime, Effect, Fiber, Layer, Option, Stream } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
|
|
@ -25,13 +25,29 @@ const it = testEffect(
|
|||
Layer.provideMerge(
|
||||
Layer.mock(Credential.Service)({
|
||||
all: () => Effect.succeed([]),
|
||||
list: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
describe("CatalogV2", () => {
|
||||
it.effect("projects active credentials without rebuilding catalog state", () => {
|
||||
it.effect("publishes an updated event after catalog changes", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const events = yield* EventV2.Service
|
||||
const updated = yield* events
|
||||
.subscribe(Catalog.Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* (yield* catalog.transform())((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
|
||||
|
||||
expect((yield* Fiber.join(updated)).length).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("derives availability from active credentials without changing provider state", () => {
|
||||
const integrationID = Integration.ID.make("test")
|
||||
const first = {
|
||||
id: Credential.ID.create(),
|
||||
|
|
@ -53,6 +69,7 @@ describe("CatalogV2", () => {
|
|||
Layer.provideMerge(
|
||||
Layer.mock(Credential.Service)({
|
||||
all: () => Effect.sync(() => [active]),
|
||||
list: () => Effect.sync(() => [active]),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -62,18 +79,44 @@ describe("CatalogV2", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
|
||||
|
||||
expect(yield* catalog.provider.get(ProviderV2.ID.make("test"))).toMatchObject({
|
||||
enabled: { via: "credential", credentialID: first.id },
|
||||
request: { body: { apiKey: "first", tenant: "one" } },
|
||||
})
|
||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
|
||||
active = second
|
||||
expect(yield* catalog.provider.get(ProviderV2.ID.make("test"))).toMatchObject({
|
||||
enabled: { via: "credential", credentialID: second.id },
|
||||
request: { body: { apiKey: "second", tenant: "two" } },
|
||||
})
|
||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
|
||||
}).pipe(Effect.provide(layer))
|
||||
})
|
||||
|
||||
it.effect("projects environment connections without a catalog plugin", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = process.env.CATALOG_TEST_API_KEY
|
||||
process.env.CATALOG_TEST_API_KEY = "secret"
|
||||
return previous
|
||||
}),
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
yield* integrations.update((editor) =>
|
||||
editor.method.update({
|
||||
integrationID: Integration.ID.make(providerID),
|
||||
method: { type: "env", names: ["CATALOG_TEST_API_KEY"] },
|
||||
}),
|
||||
)
|
||||
yield* (yield* catalog.transform())((editor) => editor.provider.update(providerID, () => {}))
|
||||
|
||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID)
|
||||
}),
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
if (previous === undefined) delete process.env.CATALOG_TEST_API_KEY
|
||||
else process.env.CATALOG_TEST_API_KEY = previous
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("normalizes provider baseURL into api url", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
|
|
@ -292,9 +335,7 @@ describe("CatalogV2", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
|
||||
yield* transform((catalog) => {
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.enabled = { via: "custom", data: {} }
|
||||
})
|
||||
catalog.provider.update(providerID, () => {})
|
||||
catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => {
|
||||
model.time.released = DateTime.makeUnsafe(1000)
|
||||
})
|
||||
|
|
@ -316,9 +357,7 @@ describe("CatalogV2", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
|
||||
const models = (catalog: Catalog.Editor) => {
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.enabled = { via: "custom", data: {} }
|
||||
})
|
||||
catalog.provider.update(providerID, () => {})
|
||||
catalog.model.update(providerID, old, (model) => {
|
||||
model.time.released = DateTime.makeUnsafe(1000)
|
||||
})
|
||||
|
|
@ -349,12 +388,10 @@ describe("CatalogV2", () => {
|
|||
|
||||
yield* transform((catalog) => {
|
||||
catalog.provider.update(disabledProvider, (provider) => {
|
||||
provider.enabled = false
|
||||
provider.disabled = true
|
||||
})
|
||||
catalog.model.update(disabledProvider, disabledModel, () => {})
|
||||
catalog.provider.update(enabledProvider, (provider) => {
|
||||
provider.enabled = { via: "custom", data: {} }
|
||||
})
|
||||
catalog.provider.update(enabledProvider, () => {})
|
||||
catalog.model.update(enabledProvider, fallbackModel, () => {})
|
||||
catalog.model.default.set(disabledProvider, disabledModel)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@ import { Effect, Option, Schema } from "effect"
|
|||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { it } from "../plugin/provider-helper"
|
||||
import { it, withEnv } from "../plugin/provider-helper"
|
||||
|
||||
function request(headers: Record<string, string>, variant?: string) {
|
||||
return {
|
||||
|
|
@ -21,6 +22,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||
it.effect("partitions existing model variant bodies without changing config shape", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const providerID = ProviderV2.ID.opencode
|
||||
const modelID = ModelV2.ID.make("alpha-gpt-next")
|
||||
|
|
@ -59,6 +61,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||
effect: ConfigProviderPlugin.Plugin.effect.pipe(
|
||||
Effect.provideService(Config.Service, config),
|
||||
Effect.provideService(Catalog.Service, catalog),
|
||||
Effect.provideService(Integration.Service, integrations),
|
||||
),
|
||||
})
|
||||
|
||||
|
|
@ -80,6 +83,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||
it.effect("uses the effective provider package across layered config", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const providerID = ProviderV2.ID.opencode
|
||||
const modelID = ModelV2.ID.make("alpha-gpt-next")
|
||||
|
|
@ -118,6 +122,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||
effect: ConfigProviderPlugin.Plugin.effect.pipe(
|
||||
Effect.provideService(Config.Service, config),
|
||||
Effect.provideService(Catalog.Service, catalog),
|
||||
Effect.provideService(Integration.Service, integrations),
|
||||
),
|
||||
})
|
||||
|
||||
|
|
@ -131,118 +136,126 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||
)
|
||||
|
||||
it.effect("loads configured providers and applies later model overrides", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const providerID = ProviderV2.ID.make("custom")
|
||||
const modelID = ModelV2.ID.make("chat")
|
||||
const config = Config.Service.of({
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
model: "custom/first",
|
||||
providers: {
|
||||
custom: {
|
||||
name: "Configured",
|
||||
env: ["CUSTOM_API_KEY"],
|
||||
api: { type: "native", settings: {} },
|
||||
request: request({ first: "first", shared: "first" }),
|
||||
models: {
|
||||
chat: {
|
||||
name: "First",
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
disabled: true,
|
||||
limit: { context: 100, output: 50 },
|
||||
cost: { input: 1, output: 2 },
|
||||
request: request({ first: "first", shared: "first" }, "retained"),
|
||||
variants: [
|
||||
{
|
||||
id: "fast",
|
||||
headers: { first: "first", shared: "first" },
|
||||
},
|
||||
],
|
||||
withEnv({ CUSTOM_API_KEY: "secret" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const providerID = ProviderV2.ID.make("custom")
|
||||
const modelID = ModelV2.ID.make("chat")
|
||||
const config = Config.Service.of({
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
model: "custom/first",
|
||||
providers: {
|
||||
custom: {
|
||||
name: "Configured",
|
||||
env: ["CUSTOM_API_KEY"],
|
||||
api: { type: "native", settings: {} },
|
||||
request: request({ first: "first", shared: "first" }),
|
||||
models: {
|
||||
chat: {
|
||||
name: "First",
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
disabled: true,
|
||||
limit: { context: 100, output: 50 },
|
||||
cost: { input: 1, output: 2 },
|
||||
request: request({ first: "first", shared: "first" }, "retained"),
|
||||
variants: [
|
||||
{
|
||||
id: "fast",
|
||||
headers: { first: "first", shared: "first" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
model: "custom/default",
|
||||
providers: {
|
||||
custom: {
|
||||
api: { type: "aisdk", package: "custom-sdk", url: "https://example.test" },
|
||||
request: request({ last: "last", shared: "last" }),
|
||||
models: {
|
||||
default: {
|
||||
name: "Default",
|
||||
},
|
||||
chat: {
|
||||
api: { id: "api-chat" },
|
||||
name: "Last",
|
||||
limit: { output: 75 },
|
||||
request: request({ last: "last", shared: "last" }),
|
||||
variants: [
|
||||
{
|
||||
id: "fast",
|
||||
headers: { last: "last", shared: "last" },
|
||||
},
|
||||
{
|
||||
id: "slow",
|
||||
headers: { slow: "slow" },
|
||||
},
|
||||
],
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
model: "custom/default",
|
||||
providers: {
|
||||
custom: {
|
||||
api: { type: "aisdk", package: "custom-sdk", url: "https://example.test" },
|
||||
request: request({ last: "last", shared: "last" }),
|
||||
models: {
|
||||
default: {
|
||||
name: "Default",
|
||||
},
|
||||
chat: {
|
||||
api: { id: "api-chat" },
|
||||
name: "Last",
|
||||
limit: { output: 75 },
|
||||
request: request({ last: "last", shared: "last" }),
|
||||
variants: [
|
||||
{
|
||||
id: "fast",
|
||||
headers: { last: "last", shared: "last" },
|
||||
},
|
||||
{
|
||||
id: "slow",
|
||||
headers: { slow: "slow" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
custom: { name: "Renamed" },
|
||||
},
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
custom: { name: "Renamed" },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
]),
|
||||
})
|
||||
|
||||
yield* plugin.add({
|
||||
...ConfigProviderPlugin.Plugin,
|
||||
effect: ConfigProviderPlugin.Plugin.effect.pipe(
|
||||
Effect.provideService(Config.Service, config),
|
||||
Effect.provideService(Catalog.Service, catalog),
|
||||
),
|
||||
})
|
||||
yield* plugin.add({
|
||||
...ConfigProviderPlugin.Plugin,
|
||||
effect: ConfigProviderPlugin.Plugin.effect.pipe(
|
||||
Effect.provideService(Config.Service, config),
|
||||
Effect.provideService(Catalog.Service, catalog),
|
||||
Effect.provideService(Integration.Service, integrations),
|
||||
),
|
||||
})
|
||||
|
||||
const provider = yield* catalog.provider.get(providerID)
|
||||
const model = yield* catalog.model.get(providerID, modelID)
|
||||
expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default"))
|
||||
expect(provider.name).toBe("Renamed")
|
||||
expect(provider.env).toEqual(["CUSTOM_API_KEY"])
|
||||
expect(provider.enabled).toEqual({ via: "custom", data: {} })
|
||||
expect(provider.api).toEqual({ type: "aisdk", package: "custom-sdk", url: "https://example.test" })
|
||||
expect(provider.request.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.api.id).toBe(ModelV2.ID.make("api-chat"))
|
||||
expect(model.name).toBe("Last")
|
||||
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
|
||||
expect(model.enabled).toBe(false)
|
||||
expect(model.limit).toEqual({ context: 100, output: 75 })
|
||||
expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }])
|
||||
expect(model.request.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.request.variant).toBe("retained")
|
||||
expect(model.variants.map((variant) => variant.id)).toEqual([
|
||||
ModelV2.VariantID.make("fast"),
|
||||
ModelV2.VariantID.make("slow"),
|
||||
])
|
||||
expect(model.variants[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.variants[1]?.headers).toEqual({ slow: "slow" })
|
||||
}),
|
||||
const provider = yield* catalog.provider.get(providerID)
|
||||
const model = yield* catalog.model.get(providerID, modelID)
|
||||
expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default"))
|
||||
expect(provider.name).toBe("Renamed")
|
||||
expect((yield* integrations.get(Integration.ID.make("custom")))?.methods).toContainEqual({
|
||||
type: "env",
|
||||
names: ["CUSTOM_API_KEY"],
|
||||
})
|
||||
expect((yield* integrations.get(Integration.ID.make("custom")))?.name).toBe("Renamed")
|
||||
expect(provider.disabled).toBeUndefined()
|
||||
expect(provider.api).toEqual({ type: "aisdk", package: "custom-sdk", url: "https://example.test" })
|
||||
expect(provider.request.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.api.id).toBe(ModelV2.ID.make("api-chat"))
|
||||
expect(model.name).toBe("Last")
|
||||
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
|
||||
expect(model.enabled).toBe(false)
|
||||
expect(model.limit).toEqual({ context: 100, output: 75 })
|
||||
expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }])
|
||||
expect(model.request.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.request.variant).toBe("retained")
|
||||
expect(model.variants.map((variant) => variant.id)).toEqual([
|
||||
ModelV2.VariantID.make("fast"),
|
||||
ModelV2.VariantID.make("slow"),
|
||||
])
|
||||
expect(model.variants[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.variants[1]?.headers).toEqual({ slow: "slow" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Duration, Effect, Exit, Layer, Scope } from "effect"
|
||||
import { Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
|
||||
import * as TestClock from "effect/testing/TestClock"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { IntegrationConnection } from "@opencode-ai/core/integration/connection"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { it } from "./lib/effect"
|
||||
|
|
@ -25,7 +24,7 @@ function connectionLayer(
|
|||
}>,
|
||||
) {
|
||||
return Integration.locationLayer.pipe(
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provide(
|
||||
Layer.mock(Credential.Service)({
|
||||
create: (input) =>
|
||||
|
|
@ -103,7 +102,7 @@ describe("Integration", () => {
|
|||
.update((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
|
||||
method: { id: methodID, type: "oauth", label: "ChatGPT" },
|
||||
authorize,
|
||||
}),
|
||||
)
|
||||
|
|
@ -117,7 +116,7 @@ describe("Integration", () => {
|
|||
])
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT Override" }),
|
||||
method: { id: methodID, type: "oauth", label: "ChatGPT Override" },
|
||||
authorize,
|
||||
})
|
||||
})
|
||||
|
|
@ -140,15 +139,20 @@ describe("Integration", () => {
|
|||
}> = []
|
||||
return Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const events = yield* EventV2.Service
|
||||
const integrationID = Integration.ID.make("openai")
|
||||
yield* integrations.update((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: new Integration.KeyMethod({ type: "key", label: "API key" }),
|
||||
method: { type: "key", label: "API key" },
|
||||
}),
|
||||
)
|
||||
const updated = yield* events
|
||||
.subscribe(Integration.Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* integrations.connect.key({
|
||||
yield* integrations.connection.key({
|
||||
integrationID,
|
||||
key: "secret",
|
||||
label: "Work",
|
||||
|
|
@ -161,6 +165,7 @@ describe("Integration", () => {
|
|||
value: new Credential.Key({ type: "key", key: "secret" }),
|
||||
},
|
||||
])
|
||||
expect((yield* Fiber.join(updated)).length).toBe(1)
|
||||
}).pipe(Effect.provide(connectionLayer(created)))
|
||||
})
|
||||
|
||||
|
|
@ -177,7 +182,7 @@ describe("Integration", () => {
|
|||
yield* integrations.update((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
|
||||
method: { id: methodID, type: "oauth", label: "ChatGPT" },
|
||||
authorize: () =>
|
||||
Effect.succeed({
|
||||
mode: "code" as const,
|
||||
|
|
@ -198,7 +203,7 @@ describe("Integration", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
const attempt = yield* integrations.connect.oauth({
|
||||
const attempt = yield* integrations.connection.oauth({
|
||||
integrationID,
|
||||
methodID,
|
||||
inputs: {},
|
||||
|
|
@ -236,7 +241,7 @@ describe("Integration", () => {
|
|||
yield* integrations.update((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
|
||||
method: { id: methodID, type: "oauth", label: "ChatGPT" },
|
||||
authorize: () =>
|
||||
Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe(
|
||||
Effect.as({
|
||||
|
|
@ -249,7 +254,7 @@ describe("Integration", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} })
|
||||
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
|
||||
expect(yield* integrations.attempt.complete({ attemptID: attempt.attemptID }).pipe(Effect.flip)).toBeInstanceOf(
|
||||
Integration.CodeRequiredError,
|
||||
)
|
||||
|
|
@ -273,7 +278,7 @@ describe("Integration", () => {
|
|||
yield* integrations.update((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "Browser" }),
|
||||
method: { id: methodID, type: "oauth", label: "Browser" },
|
||||
authorize: () =>
|
||||
Effect.succeed({
|
||||
mode: "auto" as const,
|
||||
|
|
@ -286,7 +291,7 @@ describe("Integration", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} })
|
||||
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* integrations.attempt.status(attempt.attemptID)).toEqual({
|
||||
status: "complete",
|
||||
|
|
@ -310,7 +315,7 @@ describe("Integration", () => {
|
|||
yield* integrations.update((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "Browser" }),
|
||||
method: { id: methodID, type: "oauth", label: "Browser" },
|
||||
authorize: () =>
|
||||
Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe(
|
||||
Effect.as({
|
||||
|
|
@ -323,7 +328,7 @@ describe("Integration", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} })
|
||||
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
|
||||
expect(attempt.time.expires - attempt.time.created).toBe(Duration.toMillis(Duration.minutes(10)))
|
||||
yield* TestClock.adjust(Duration.minutes(10))
|
||||
yield* Effect.yieldNow
|
||||
|
|
@ -373,23 +378,28 @@ describe("Integration", () => {
|
|||
yield* integrations.update((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: new Integration.EnvMethod({
|
||||
method: {
|
||||
type: "env",
|
||||
names: ["INTEGRATION_TEST_ACME_KEY", "INTEGRATION_TEST_ACME_MISSING"],
|
||||
}),
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// Stored credentials and detected env vars appear as connections.
|
||||
expect((yield* integrations.get(integrationID))?.connections).toEqual([
|
||||
new IntegrationConnection.CredentialInfo({ type: "credential", id: rows[0]!.id, label: "Work" }),
|
||||
new IntegrationConnection.CredentialInfo({
|
||||
{ type: "credential", id: rows[0]!.id, label: "Work" },
|
||||
{
|
||||
type: "credential",
|
||||
id: rows[1]!.id,
|
||||
label: "Personal",
|
||||
}),
|
||||
new IntegrationConnection.EnvInfo({ type: "env", name: "INTEGRATION_TEST_ACME_KEY" }),
|
||||
},
|
||||
{ type: "env", name: "INTEGRATION_TEST_ACME_KEY" },
|
||||
])
|
||||
expect(yield* integrations.connection.forIntegration(integrationID)).toEqual({
|
||||
type: "credential",
|
||||
id: rows[1]!.id,
|
||||
label: "Personal",
|
||||
})
|
||||
}).pipe(Effect.provide(projectionLayer)),
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
|
|
|
|||
|
|
@ -28,8 +28,10 @@ const connections = Credential.layer.pipe(
|
|||
Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh)),
|
||||
Layer.provide(events),
|
||||
)
|
||||
const catalog = Catalog.layer.pipe(Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, connections)))
|
||||
const integrations = Integration.locationLayer.pipe(Layer.provide(events), Layer.provide(connections))
|
||||
const catalog = Catalog.layer.pipe(
|
||||
Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, connections, integrations)),
|
||||
)
|
||||
const layer = Layer.mergeAll(
|
||||
catalog.pipe(Layer.provide(connections)),
|
||||
integrations,
|
||||
|
|
@ -61,11 +63,11 @@ describe("ModelsDevPlugin", () => {
|
|||
id: Integration.ID.make("acme"),
|
||||
name: "Acme",
|
||||
methods: [
|
||||
new Integration.KeyMethod({ type: "key" }),
|
||||
new Integration.EnvMethod({
|
||||
{ type: "key" },
|
||||
{
|
||||
type: "env",
|
||||
names: ["ACME_API_KEY"],
|
||||
}),
|
||||
},
|
||||
],
|
||||
connections: [],
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ const integrations = Integration.locationLayer.pipe(
|
|||
Layer.provide(
|
||||
Layer.mock(Credential.Service)({
|
||||
create: () => Effect.die("unexpected credential creation"),
|
||||
all: () => Effect.succeed([]),
|
||||
list: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway"
|
||||
|
|
@ -8,6 +9,14 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
|
|||
import { expectPluginRegistered, it, provider } from "./provider-helper"
|
||||
|
||||
describe("LLMGatewayPlugin", () => {
|
||||
const add = Effect.fnUntraced(function* (plugin: PluginV2.Interface) {
|
||||
const integrations = yield* Integration.Service
|
||||
yield* plugin.add({
|
||||
...LLMGatewayPlugin,
|
||||
effect: LLMGatewayPlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)),
|
||||
})
|
||||
})
|
||||
|
||||
it.effect("is registered so legacy referer headers can be applied", () =>
|
||||
Effect.sync(() =>
|
||||
expectPluginRegistered(
|
||||
|
|
@ -21,25 +30,23 @@ describe("LLMGatewayPlugin", () => {
|
|||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(LLMGatewayPlugin)
|
||||
yield* add(plugin)
|
||||
const integrations = yield* Integration.Service
|
||||
yield* integrations.update((editor) => {
|
||||
editor.update(Integration.ID.make("llmgateway"), () => {})
|
||||
editor.update(Integration.ID.make("openrouter"), () => {})
|
||||
})
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const llmgateway = provider("llmgateway", {
|
||||
enabled: { via: "env", name: "LLMGATEWAY_API_KEY" },
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
|
||||
request: { headers: { Existing: "value" }, body: {} },
|
||||
})
|
||||
catalog.provider.update(llmgateway.id, (draft) => {
|
||||
draft.enabled = llmgateway.enabled
|
||||
draft.api = llmgateway.api
|
||||
draft.request = llmgateway.request
|
||||
})
|
||||
const openrouter = provider("openrouter", {
|
||||
enabled: { via: "env", name: "OPENROUTER_API_KEY" },
|
||||
})
|
||||
catalog.provider.update(openrouter.id, (draft) => {
|
||||
draft.enabled = openrouter.enabled
|
||||
})
|
||||
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({
|
||||
Existing: "value",
|
||||
|
|
@ -55,7 +62,7 @@ describe("LLMGatewayPlugin", () => {
|
|||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(LLMGatewayPlugin)
|
||||
yield* add(plugin)
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("llmgateway", {
|
||||
|
|
@ -66,7 +73,7 @@ describe("LLMGatewayPlugin", () => {
|
|||
})
|
||||
})
|
||||
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).enabled).toBe(false)
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).disabled).toBeUndefined()
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -21,16 +21,16 @@ describe("OpenAIPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
yield* add(plugin, yield* Integration.Service)
|
||||
expect((yield* (yield* Integration.Service).get(Integration.ID.make("openai")))?.methods).toEqual([
|
||||
new Integration.OAuthMethod({
|
||||
{
|
||||
id: Integration.MethodID.make("chatgpt-browser"),
|
||||
type: "oauth",
|
||||
label: "ChatGPT Pro/Plus (browser)",
|
||||
}),
|
||||
new Integration.OAuthMethod({
|
||||
},
|
||||
{
|
||||
id: Integration.MethodID.make("chatgpt-headless"),
|
||||
type: "oauth",
|
||||
label: "ChatGPT Pro/Plus (headless)",
|
||||
}),
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { DateTime, Effect, Layer, Option } from "effect"
|
|||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
|
|
@ -18,13 +19,18 @@ const locationLayer = Layer.succeed(
|
|||
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
|
||||
)
|
||||
|
||||
const pluginWithIntegrations = (integrations: Integration.Interface) => ({
|
||||
...OpencodePlugin,
|
||||
effect: OpencodePlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)),
|
||||
})
|
||||
|
||||
describe("OpencodePlugin", () => {
|
||||
it.effect("uses a public key and disables paid models without credentials", () =>
|
||||
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("opencode")
|
||||
|
|
@ -45,7 +51,7 @@ describe("OpencodePlugin", () => {
|
|||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("opencode")
|
||||
|
|
@ -66,7 +72,7 @@ describe("OpencodePlugin", () => {
|
|||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("opencode")
|
||||
|
|
@ -87,7 +93,7 @@ describe("OpencodePlugin", () => {
|
|||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("opencode")
|
||||
|
|
@ -108,13 +114,18 @@ describe("OpencodePlugin", () => {
|
|||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
const integrations = yield* Integration.Service
|
||||
yield* plugin.add(pluginWithIntegrations(integrations))
|
||||
yield* integrations.update((editor) => {
|
||||
editor.method.update({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
method: { type: "env", names: ["CUSTOM_OPENCODE_API_KEY"] },
|
||||
})
|
||||
})
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("opencode", { env: ["CUSTOM_OPENCODE_API_KEY"] })
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.env = [...item.env]
|
||||
})
|
||||
const item = provider("opencode")
|
||||
catalog.provider.update(item.id, () => {})
|
||||
const paid = model("opencode", "paid", { cost: cost(1) })
|
||||
catalog.model.update(item.id, paid.id, (draft) => {
|
||||
draft.cost = [...paid.cost]
|
||||
|
|
@ -131,7 +142,7 @@ describe("OpencodePlugin", () => {
|
|||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("opencode", {
|
||||
|
|
@ -154,37 +165,12 @@ describe("OpencodePlugin", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.effect("uses auth-enabled providers as credentials", () =>
|
||||
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("opencode", {
|
||||
enabled: { via: "credential", credentialID: Credential.ID.make("credential") },
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.enabled = item.enabled
|
||||
})
|
||||
const paid = model("opencode", "paid", { cost: cost(1) })
|
||||
catalog.model.update(item.id, paid.id, (draft) => {
|
||||
draft.cost = [...paid.cost]
|
||||
})
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined()
|
||||
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("ignores non-opencode providers and models", () =>
|
||||
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("openai")
|
||||
|
|
|
|||
|
|
@ -72,6 +72,48 @@ describe("SnowflakeCortexPlugin", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.effect("uses SNOWFLAKE_CORTEX_TOKEN env var", () =>
|
||||
withEnv({ SNOWFLAKE_CORTEX_TOKEN: "oauth-token", SNOWFLAKE_CORTEX_PAT: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* plugin.add(SnowflakeCortexPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("snowflake-cortex", "claude-sonnet-4-6"),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(result.sdk).toBeDefined()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("falls back to options.token when no Snowflake env token is set", () =>
|
||||
withEnv({ SNOWFLAKE_CORTEX_TOKEN: undefined, SNOWFLAKE_CORTEX_PAT: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* plugin.add(SnowflakeCortexPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("snowflake-cortex", "claude-sonnet-4-6"),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: {
|
||||
name: "snowflake-cortex",
|
||||
baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1",
|
||||
token: "options-token",
|
||||
},
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(result.sdk).toBeDefined()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("sets includeUsage on the SDK options", () =>
|
||||
withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () =>
|
||||
Effect.gen(function* () {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue