mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-28 14:11:49 -04:00
Compare commits
89 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b51c2ac056 | |||
| 6af9ae84ff | |||
| c0fbe920d3 | |||
| e556aca833 | |||
| 73bd8a264b | |||
| 077338fcc8 | |||
| b671a77145 | |||
| 30d09a7d7e | |||
| ee02fb4fce | |||
| 010133f6df | |||
| 3b0d8f0e6f | |||
| 4d59b059ee | |||
| 1be6d94267 | |||
| 302e9b45ab | |||
| 7211c9934a | |||
| 3bda0ce123 | |||
| 62320947d9 | |||
| 0cb9bb567e | |||
| b14adcaf83 | |||
| 5bd3da40a5 | |||
| abcbdad530 | |||
| debdea40ea | |||
| 775f24f049 | |||
| 470e360942 | |||
| f15398efc3 | |||
| 4333a44e65 | |||
| 8b4b0d67d7 | |||
| 9200e353bf | |||
| 31124312f6 | |||
| 4f622fa7cd | |||
| 4e4cf9e25e | |||
| 6da2f3c38f | |||
| 1b39d364bd | |||
| 856c569458 | |||
| 92807d0bb9 | |||
| 713658c07b | |||
| f5700808c5 | |||
| 7eb51d0507 | |||
| 02c37c401a | |||
| 33e3d1ebca | |||
| 1f2c59a1b6 | |||
| 65d2a4e00c | |||
| 7d4de3d9e4 | |||
| 9977ef0160 | |||
| 9a55d125f6 | |||
| 766aaf448d | |||
| f14d78afeb | |||
| 0261f04b90 | |||
| 9b49e7bec9 | |||
| 93cb113cef | |||
| 4216d35e4b | |||
| 863645c671 | |||
| 5592f5225b | |||
| 8db7487c89 | |||
| 0fd73a2976 | |||
| 80865407e0 | |||
| 28f4284bd7 | |||
| 7affee529b | |||
| 79c7e9446e | |||
| efb629a33a | |||
| 2ddc91a0e8 | |||
| c7871e14d4 | |||
| 9840f63b12 | |||
| 56a9c0150a | |||
| 203a0613b8 | |||
| 7d8f1bdab3 | |||
| 9eea5bc925 | |||
| f753103e82 | |||
| 1e35d33ecb | |||
| c5bf4edb10 | |||
| cce8bb0e1c | |||
| 02c66c5fc1 | |||
| 33390cc457 | |||
| b2afb35527 | |||
| 828148909d | |||
| 454145fe65 | |||
| 1291dc1f11 | |||
| c7d7f61146 | |||
| 13b6845e7e | |||
| d1d97014b4 | |||
| b31747124b | |||
| 49bec25ae5 | |||
| d66d0cb904 | |||
| 3193f3aa95 | |||
| ee5460a152 | |||
| b09a066fb5 | |||
| 423fad730c | |||
| 5ae2d6d3f6 | |||
| 993f046dd9 |
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/client": patch
|
||||
"@opencode-ai/protocol": patch
|
||||
"@opencode-ai/cli": patch
|
||||
---
|
||||
|
||||
Expose background-service lifecycle status, preserve one process-held owner through startup and failure, reconnect TUIs without activating replacement, and stop exact service instances gracefully.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/cli": patch
|
||||
---
|
||||
|
||||
Expose a TUI plugin slot at the top of the session view.
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/plugin": minor
|
||||
"@opencode-ai/sdk": minor
|
||||
"@opencode-ai/client": minor
|
||||
"@opencode-ai/protocol": minor
|
||||
---
|
||||
|
||||
Replace the V2 tool result model with one canonical representation per fact. Tools lose `structured`, projection callbacks, the `Structured` generic, and the exported `Tool.settle` interpreter; tool responses carry schema-validated `output`, model-visible `content`, and optional compact JSON `metadata`. Code Mode receives the validated encoded output. Durable tool success stores non-empty model content plus optional metadata; failure stores one error plus the final bounded partial snapshot. Progress carries metadata only, while `execute.after` hooks receive the canonical terminal outcome and managed `outputPaths`. A one-time migration rewrites existing projected tool rows and moves provider-hosted result payloads into provider-owned result state.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/client": patch
|
||||
"@opencode-ai/plugin": patch
|
||||
"@opencode-ai/protocol": patch
|
||||
---
|
||||
|
||||
Expose transient, read-only session generation through the HTTP API, generated clients, and V2 plugin session context.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/cli": patch
|
||||
---
|
||||
|
||||
Expose a TUI plugin slot above the session composer.
|
||||
@@ -0,0 +1,37 @@
|
||||
name: deploy-www
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- v2
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-www-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'v2')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Build
|
||||
working-directory: packages/www
|
||||
run: bun run build
|
||||
env:
|
||||
BLUME_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
|
||||
CLOUDFLARE_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
|
||||
|
||||
- name: Deploy
|
||||
working-directory: packages/www
|
||||
run: bun run deploy
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
|
||||
- name: Check generated documentation
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: packages/docs
|
||||
working-directory: packages/www
|
||||
run: bun run check:generated
|
||||
|
||||
e2e:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
- To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
|
||||
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
|
||||
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
|
||||
- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
|
||||
@@ -61,6 +60,7 @@ const { a, b } = obj
|
||||
### Imports
|
||||
|
||||
- Never alias imports. Do not use `import { foo as bar } from "..."` or renamed imports like `resolve as pathResolve`.
|
||||
- Never use type-position `import("...")` references such as `Schema.declare<import("@opencode-ai/plugin/effect/plugin").Plugin["effect"]>`. Only when two imports genuinely collide on a name and no other option exists, an aliased type import (`import type { Plugin as PluginDefinition } from "..."`) is permitted as a last resort — still strongly preferred not to.
|
||||
- Never use star imports. Do not use `import * as Foo from "..."` or `import type * as Foo from "..."`.
|
||||
- If a namespace-style value is needed, import the module's own exported namespace by name, for example `import { Project } from "@opencode-ai/core/project"`, then reference `Project.ID`.
|
||||
- Prefer dynamic imports for heavy modules that are only needed in selected code paths, especially in startup-sensitive entrypoints. Destructure dynamic import bindings near the top of the narrowest scope that needs them so they read like normal imports. Avoid inline chains such as `await import("./module").then((mod) => mod.value())` or `(await import("./module")).value()`. Keep branch-specific imports inside the branch that needs them to preserve lazy loading.
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
exact = true
|
||||
# Only install newly resolved package versions published at least 3 days ago.
|
||||
minimumReleaseAge = 259200
|
||||
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"]
|
||||
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opencode-ai/sdk", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"]
|
||||
|
||||
[test]
|
||||
root = "./do-not-run-tests-from-root"
|
||||
|
||||
+1
-1
@@ -15,6 +15,6 @@
|
||||
"@actions/github": "6.0.1",
|
||||
"@octokit/graphql": "9.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*"
|
||||
"@opencode-ai/sdk": "1.18.5"
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-0kcwV34P2C3yKg2eG9W2nW+OedrSBb+1TdpuUeYtauY=",
|
||||
"aarch64-linux": "sha256-yHVygApQchAB34wrtFR4GU0CkmZOlLsl3wsp15u0xzs=",
|
||||
"aarch64-darwin": "sha256-DyalcwyK2Wn5R6249keFcNVECbgtjYNjscOFqTi88FI=",
|
||||
"x86_64-darwin": "sha256-BkGw0GWN9W9q+/g4FYR0MqxUuFP80BPoERO+ypz/arQ="
|
||||
"x86_64-linux": "sha256-RFek0QoEEjsgbqmTE/SxQAmPtYyzs0IPR2ugFn5Okrs=",
|
||||
"aarch64-linux": "sha256-BmAxapY1YrAFn7mVq3/6A9+6Au5UIvSqBboHMkyJH3I=",
|
||||
"aarch64-darwin": "sha256-Sx3bGWQqLlgoa/RudJxanjSzhFRNklckT2ffnO2I5F4=",
|
||||
"x86_64-darwin": "sha256-CMOhiisHNowg06qadvgg4K+60zrynglwiT0qKYQ4NiA="
|
||||
}
|
||||
}
|
||||
|
||||
+9
-8
@@ -33,13 +33,12 @@
|
||||
"packages/*",
|
||||
"packages/console/*",
|
||||
"packages/stats/*",
|
||||
"packages/sdk/js",
|
||||
"packages/slack"
|
||||
],
|
||||
"catalog": {
|
||||
"@effect/opentelemetry": "4.0.0-beta.98",
|
||||
"@effect/platform-node": "4.0.0-beta.98",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.98",
|
||||
"@effect/opentelemetry": "4.0.0-beta.101",
|
||||
"@effect/platform-node": "4.0.0-beta.101",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.101",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@types/bun": "1.3.13",
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
@@ -51,6 +50,7 @@
|
||||
"@opentui/solid": "0.4.5",
|
||||
"@tanstack/solid-virtual": "3.13.32",
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
"ulid": "3.0.1",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@corvu/drawer": "0.2.4",
|
||||
@@ -69,7 +69,7 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-rc.2",
|
||||
"drizzle-orm": "1.0.0-rc.2",
|
||||
"effect": "4.0.0-beta.98",
|
||||
"effect": "4.0.0-beta.101",
|
||||
"ai": "6.0.168",
|
||||
"cross-spawn": "7.0.6",
|
||||
"hono": "4.10.7",
|
||||
@@ -124,7 +124,7 @@
|
||||
"@aws-sdk/client-s3": "3.933.0",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/sdk": "1.18.5",
|
||||
"heap-snapshot-toolkit": "1.1.3",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
@@ -152,7 +152,8 @@
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:"
|
||||
"@types/node": "catalog:",
|
||||
"effect": "catalog:"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||
@@ -166,7 +167,7 @@
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"effect@4.0.0-beta.98": "patches/effect@4.0.0-beta.98.patch",
|
||||
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
|
||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,7 +207,9 @@ Prompt caching is **on by default**. Every `LLMRequest` resolves to `cache: "aut
|
||||
|
||||
### Auto placement
|
||||
|
||||
`"auto"` places three breakpoints — last tool definition, last system part, latest user message. The last-user-message boundary is the load-bearing detail: in a tool-use loop, a single user turn expands into many assistant/tool round-trips, all sharing that prefix. Caching at that boundary lets every intra-turn API call hit.
|
||||
`"auto"` places up to four breakpoints — the last tool definition, the first system part, the last system part when distinct, and the final message boundary. These expose successively larger reusable prefixes for tools, the base agent, project instructions, and the active conversation. The rolling final-message boundary is the load-bearing detail in tool loops: it advances on every request so the previous cache entry stays within Anthropic's 20-block lookback.
|
||||
|
||||
Tools precede every system and conversation block in the provider prefix, so tool definitions must remain byte-stable and deterministically ordered for downstream breakpoints to remain reusable.
|
||||
|
||||
The math justifies the default: Anthropic's 5-minute cache write is 1.25× base, read is 0.1×, so a single reuse within 5 minutes already wins. One-shot completions below the per-model minimum-cacheable-token threshold silently no-op on the wire, so the worst case is harmless.
|
||||
|
||||
@@ -235,7 +237,7 @@ cache: {
|
||||
|
||||
### Manual hints
|
||||
|
||||
Inline `CacheHint` on any text / system / tool / tool-result part overrides automatic placement. The auto policy preserves manual hints; it only fills gaps.
|
||||
Inline `CacheHint` on any text / system / tool / tool-result part overrides automatic placement. The auto policy preserves manual hints, counts them against Anthropic and Bedrock's four-breakpoint limit, and only fills the remaining slots.
|
||||
|
||||
```ts
|
||||
LLM.request({
|
||||
@@ -251,8 +253,8 @@ LLM.request({
|
||||
|
||||
| Protocol | `cache: "auto"` |
|
||||
| ----------------------- | ------------------------------------------------------------------------- |
|
||||
| Anthropic Messages | emits up to 3 `cache_control` markers (4-breakpoint cap enforced) |
|
||||
| Bedrock Converse | emits up to 3 `cachePoint` blocks (4-breakpoint cap enforced) |
|
||||
| Anthropic Messages | emits up to 4 `cache_control` markers (4-breakpoint cap enforced) |
|
||||
| Bedrock Converse | emits up to 4 `cachePoint` blocks (4-breakpoint cap enforced) |
|
||||
| OpenAI Chat / Responses | no-op (implicit caching above 1024 tokens) |
|
||||
| Gemini | no-op (implicit caching on 2.5+; explicit `CachedContent` is out-of-band) |
|
||||
|
||||
|
||||
@@ -2,32 +2,31 @@
|
||||
// the policy designates. Runs once at compile time, before the per-protocol
|
||||
// body builder, so the existing inline-hint lowering path handles the rest.
|
||||
//
|
||||
// The default `"auto"` shape places one breakpoint at the last tool definition,
|
||||
// one at the last system part, and one at the latest user message. This
|
||||
// matches what production agent harnesses (LangChain's caching middleware,
|
||||
// kern-ai's 10x cost-reduction playbook) converge on for tool-use loops: the
|
||||
// latest user message stays put while a single turn explodes into many
|
||||
// assistant/tool round-trips, so caching at that boundary lets every
|
||||
// intra-turn API call hit the prefix.
|
||||
// The default `"auto"` shape places breakpoints at the last tool definition,
|
||||
// the first and last distinct system parts, and the conversation tail. This
|
||||
// exposes reusable tool, base-agent, project, and session prefixes while
|
||||
// advancing the tail after each tool result keeps the previous cache entry
|
||||
// within Anthropic's 20-block lookback during long agent turns.
|
||||
//
|
||||
// Manual `cache: CacheHint` placements on individual parts are preserved —
|
||||
// this function only fills gaps the caller left empty.
|
||||
// Manual `cache: CacheHint` placements on individual parts are preserved and
|
||||
// count against the four-breakpoint budget; auto only fills remaining slots.
|
||||
import { CacheHint, type CachePolicy, type CachePolicyObject } from "./schema/options"
|
||||
import { LLMRequest, Message, ToolDefinition, type ContentPart } from "./schema/messages"
|
||||
|
||||
const AUTO: CachePolicyObject = {
|
||||
tools: true,
|
||||
system: true,
|
||||
messages: "latest-user-message",
|
||||
messages: { tail: 1 },
|
||||
}
|
||||
|
||||
const NONE: CachePolicyObject = {}
|
||||
const BREAKPOINT_CAP = 4
|
||||
|
||||
// Resolution rules:
|
||||
// - undefined → "auto" — caching is on by default. The math favors it:
|
||||
// Anthropic 5m-cache write is 1.25x base, read is 0.1x,
|
||||
// so a single reuse within 5 minutes already wins.
|
||||
// - "auto" → tools + system + latest user msg.
|
||||
// - "auto" → tools + first/last system + final message boundary.
|
||||
// - "none" → no auto placement; manual `CacheHint`s still flow.
|
||||
// - object form → exactly what the caller asked for.
|
||||
const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
|
||||
@@ -44,18 +43,32 @@ const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse"]
|
||||
const makeHint = (ttlSeconds: number | undefined): CacheHint =>
|
||||
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
|
||||
|
||||
const markLastTool = (tools: ReadonlyArray<ToolDefinition>, hint: CacheHint): ReadonlyArray<ToolDefinition> => {
|
||||
interface Budget {
|
||||
remaining: number
|
||||
}
|
||||
|
||||
const markLastTool = (
|
||||
tools: ReadonlyArray<ToolDefinition>,
|
||||
hint: CacheHint,
|
||||
budget: Budget,
|
||||
): ReadonlyArray<ToolDefinition> => {
|
||||
if (tools.length === 0) return tools
|
||||
const last = tools.length - 1
|
||||
if (tools[last]!.cache) return tools
|
||||
if (tools[last]!.cache || budget.remaining === 0) return tools
|
||||
budget.remaining -= 1
|
||||
return tools.map((tool, i) => (i === last ? new ToolDefinition({ ...tool, cache: hint }) : tool))
|
||||
}
|
||||
|
||||
const markLastSystem = (system: LLMRequest["system"], hint: CacheHint): LLMRequest["system"] => {
|
||||
const markSystemBoundaries = (system: LLMRequest["system"], hint: CacheHint, budget: Budget): LLMRequest["system"] => {
|
||||
if (system.length === 0) return system
|
||||
const last = system.length - 1
|
||||
if (system[last]!.cache) return system
|
||||
return system.map((part, i) => (i === last ? { ...part, cache: hint } : part))
|
||||
let changed = false
|
||||
const next = system.map((part, index) => {
|
||||
if ((index !== 0 && index !== system.length - 1) || part.cache || budget.remaining === 0) return part
|
||||
budget.remaining -= 1
|
||||
changed = true
|
||||
return { ...part, cache: hint }
|
||||
})
|
||||
return changed ? next : system
|
||||
}
|
||||
|
||||
const lastIndexOfRole = (messages: ReadonlyArray<Message>, role: Message["role"]): number =>
|
||||
@@ -64,14 +77,20 @@ const lastIndexOfRole = (messages: ReadonlyArray<Message>, role: Message["role"]
|
||||
// Mark the last text part of `messages[index]`. If no text part exists, mark
|
||||
// the last content part regardless of type — that's the breakpoint position
|
||||
// in tool-result-only messages too.
|
||||
const markMessageAt = (messages: ReadonlyArray<Message>, index: number, hint: CacheHint): ReadonlyArray<Message> => {
|
||||
const markMessageAt = (
|
||||
messages: ReadonlyArray<Message>,
|
||||
index: number,
|
||||
hint: CacheHint,
|
||||
budget: Budget,
|
||||
): ReadonlyArray<Message> => {
|
||||
if (index < 0 || index >= messages.length) return messages
|
||||
const target = messages[index]!
|
||||
if (target.content.length === 0) return messages
|
||||
const lastTextIndex = target.content.findLastIndex((part) => part.type === "text")
|
||||
const markAt = lastTextIndex >= 0 ? lastTextIndex : target.content.length - 1
|
||||
const existing = target.content[markAt]!
|
||||
if ("cache" in existing && existing.cache) return messages
|
||||
if (("cache" in existing && existing.cache) || budget.remaining === 0) return messages
|
||||
budget.remaining -= 1
|
||||
const nextContent = target.content.map((part, i) => (i === markAt ? ({ ...part, cache: hint } as ContentPart) : part))
|
||||
const next = new Message({ ...target, content: nextContent })
|
||||
// Single pass over `messages`, substituting the one updated entry. Long
|
||||
@@ -86,25 +105,42 @@ const markMessages = (
|
||||
messages: ReadonlyArray<Message>,
|
||||
strategy: NonNullable<CachePolicyObject["messages"]>,
|
||||
hint: CacheHint,
|
||||
budget: Budget,
|
||||
): ReadonlyArray<Message> => {
|
||||
if (messages.length === 0) return messages
|
||||
if (strategy === "latest-user-message") return markMessageAt(messages, lastIndexOfRole(messages, "user"), hint)
|
||||
if (strategy === "latest-assistant") return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint)
|
||||
if (strategy === "latest-user-message")
|
||||
return markMessageAt(messages, lastIndexOfRole(messages, "user"), hint, budget)
|
||||
if (strategy === "latest-assistant")
|
||||
return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint, budget)
|
||||
const start = Math.max(0, messages.length - strategy.tail)
|
||||
let next = messages
|
||||
for (let i = start; i < messages.length; i++) next = markMessageAt(next, i, hint)
|
||||
for (let i = start; i < messages.length; i++) next = markMessageAt(next, i, hint, budget)
|
||||
return next
|
||||
}
|
||||
|
||||
const countHints = (request: LLMRequest) =>
|
||||
request.tools.reduce((count, tool) => count + (tool.cache === undefined ? 0 : 1), 0) +
|
||||
request.system.reduce((count, part) => count + (part.cache === undefined ? 0 : 1), 0) +
|
||||
request.messages.reduce(
|
||||
(count, message) =>
|
||||
count +
|
||||
message.content.reduce(
|
||||
(contentCount, part) => contentCount + ("cache" in part && part.cache !== undefined ? 1 : 0),
|
||||
0,
|
||||
),
|
||||
0,
|
||||
)
|
||||
|
||||
export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
|
||||
if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
|
||||
const policy = resolve(request.cache)
|
||||
if (!policy.tools && !policy.system && !policy.messages) return request
|
||||
|
||||
const hint = makeHint(policy.ttlSeconds)
|
||||
const tools = policy.tools ? markLastTool(request.tools, hint) : request.tools
|
||||
const system = policy.system ? markLastSystem(request.system, hint) : request.system
|
||||
const messages = policy.messages ? markMessages(request.messages, policy.messages, hint) : request.messages
|
||||
const budget = { remaining: Math.max(0, BREAKPOINT_CAP - countHints(request)) }
|
||||
const tools = policy.tools ? markLastTool(request.tools, hint, budget) : request.tools
|
||||
const system = policy.system ? markSystemBoundaries(request.system, hint, budget) : request.system
|
||||
const messages = policy.messages ? markMessages(request.messages, policy.messages, hint, budget) : request.messages
|
||||
|
||||
if (tools === request.tools && system === request.system && messages === request.messages) return request
|
||||
return LLMRequest.update(request, { tools, system, messages })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Route } from "../route/client"
|
||||
import { Auth } from "../route/auth"
|
||||
import { Endpoint } from "../route/endpoint"
|
||||
@@ -7,8 +8,10 @@ import { Protocol } from "../route/protocol"
|
||||
import {
|
||||
LLMError,
|
||||
LLMEvent,
|
||||
mergeJsonRecords,
|
||||
Usage,
|
||||
type CacheHint,
|
||||
type FinishReasonDetails,
|
||||
type FinishReason,
|
||||
type JsonSchema,
|
||||
type LLMRequest,
|
||||
@@ -17,7 +20,6 @@ import {
|
||||
type ProviderMetadata,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
type ToolContent,
|
||||
type ToolResultPart,
|
||||
} from "../schema"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
@@ -233,12 +235,27 @@ const AnthropicBodyFields = {
|
||||
export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
|
||||
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
|
||||
|
||||
const AnthropicUsage = Schema.Struct({
|
||||
input_tokens: Schema.optional(Schema.Number),
|
||||
output_tokens: Schema.optional(Schema.Number),
|
||||
cache_creation_input_tokens: optionalNull(Schema.Number),
|
||||
cache_read_input_tokens: optionalNull(Schema.Number),
|
||||
})
|
||||
const AnthropicUsage = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
input_tokens: Schema.optional(Schema.Number),
|
||||
output_tokens: Schema.optional(Schema.Number),
|
||||
cache_creation_input_tokens: optionalNull(Schema.Number),
|
||||
cache_read_input_tokens: optionalNull(Schema.Number),
|
||||
server_tool_use: optionalNull(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({ web_search_requests: Schema.optional(Schema.Number) }),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
),
|
||||
output_tokens_details: optionalNull(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({ thinking_tokens: Schema.optional(Schema.Number) }),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
type AnthropicUsage = Schema.Schema.Type<typeof AnthropicUsage>
|
||||
|
||||
const AnthropicStreamBlock = Schema.Struct({
|
||||
@@ -288,7 +305,12 @@ type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
|
||||
|
||||
interface ParserState {
|
||||
readonly tools: ToolStream.State<number>
|
||||
readonly reasoningSignatures: Readonly<Record<number, string>>
|
||||
readonly usage?: Usage
|
||||
readonly pendingFinish?: {
|
||||
readonly reason: FinishReasonDetails
|
||||
readonly providerMetadata?: ProviderMetadata
|
||||
}
|
||||
readonly lifecycle: Lifecycle.State
|
||||
}
|
||||
|
||||
@@ -403,7 +425,7 @@ const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: Me
|
||||
// Tool results may carry structured text, images, and documents. Keep media as provider-native
|
||||
// content instead of JSON-stringifying base64 into a prompt string.
|
||||
const lowerToolResultContentItem = Effect.fn("AnthropicMessages.lowerToolResultContentItem")(function* (
|
||||
item: ToolContent,
|
||||
item: Tool.Content,
|
||||
) {
|
||||
if (item.type === "text") return { type: "text" as const, text: item.text } satisfies AnthropicTextBlock
|
||||
return yield* lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name })
|
||||
@@ -414,7 +436,7 @@ const lowerToolResultContent = Effect.fn("AnthropicMessages.lowerToolResultConte
|
||||
// with existing cassettes and provider expectations.
|
||||
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
|
||||
// Preserve the narrowed array element type when compiled through a consumer package.
|
||||
const content: ReadonlyArray<ToolContent> = part.result.value
|
||||
const content: ReadonlyArray<Tool.Content> = part.result.value
|
||||
return yield* Effect.forEach(content, lowerToolResultContentItem)
|
||||
})
|
||||
|
||||
@@ -660,9 +682,8 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
||||
// `input_tokens` is the *non-cached* count per the Messages API docs, with
|
||||
// cache reads and writes as separate fields. We sum them to derive the
|
||||
// inclusive `inputTokens` the rest of the contract expects. Extended
|
||||
// thinking tokens are *not* broken out by Anthropic — they're billed as
|
||||
// part of `output_tokens`, so `reasoningTokens` stays `undefined` and
|
||||
// `outputTokens` carries the combined total.
|
||||
// thinking tokens are included in `output_tokens`; newer responses also
|
||||
// expose that subset through `output_tokens_details.thinking_tokens`.
|
||||
const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const nonCached = usage.input_tokens
|
||||
@@ -675,6 +696,7 @@ const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
|
||||
nonCachedInputTokens: nonCached,
|
||||
cacheReadInputTokens: cacheRead,
|
||||
cacheWriteInputTokens: cacheWrite,
|
||||
reasoningTokens: usage.output_tokens_details?.thinking_tokens,
|
||||
totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined),
|
||||
providerMetadata: { anthropic: usage },
|
||||
})
|
||||
@@ -693,18 +715,18 @@ const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => {
|
||||
const cacheWriteInputTokens = right.cacheWriteInputTokens ?? left.cacheWriteInputTokens
|
||||
const inputTokens = ProviderShared.sumTokens(nonCachedInputTokens, cacheReadInputTokens, cacheWriteInputTokens)
|
||||
const outputTokens = right.outputTokens ?? left.outputTokens
|
||||
const reasoningTokens = right.reasoningTokens ?? left.reasoningTokens
|
||||
return new Usage({
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
nonCachedInputTokens,
|
||||
cacheReadInputTokens,
|
||||
cacheWriteInputTokens,
|
||||
reasoningTokens,
|
||||
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
|
||||
providerMetadata: {
|
||||
anthropic: {
|
||||
...left.providerMetadata?.["anthropic"],
|
||||
...right.providerMetadata?.["anthropic"],
|
||||
},
|
||||
anthropic:
|
||||
mergeJsonRecords(left.providerMetadata?.["anthropic"], right.providerMetadata?.["anthropic"]) ?? {},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -763,6 +785,10 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
|
||||
tools: ToolStream.start(state.tools, event.index, {
|
||||
id: block.id ?? String(event.index),
|
||||
name: block.name ?? "",
|
||||
input:
|
||||
block.input !== undefined && (!ProviderShared.isRecord(block.input) || Object.keys(block.input).length > 0)
|
||||
? ProviderShared.encodeJson(block.input)
|
||||
: undefined,
|
||||
providerExecuted: block.type === "server_tool_use",
|
||||
}),
|
||||
},
|
||||
@@ -777,20 +803,31 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
|
||||
]
|
||||
}
|
||||
|
||||
if (block.type === "text" && block.text) {
|
||||
if (block.type === "text" && block.text !== undefined) {
|
||||
const events: LLMEvent[] = []
|
||||
const id = `text-${event.index ?? 0}`
|
||||
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id)
|
||||
return [
|
||||
{ ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, `text-${event.index ?? 0}`, block.text) },
|
||||
{ ...state, lifecycle: block.text ? Lifecycle.textDelta(lifecycle, events, id, block.text) : lifecycle },
|
||||
events,
|
||||
]
|
||||
}
|
||||
|
||||
if (block.type === "thinking" && block.thinking) {
|
||||
if (block.type === "thinking" && block.thinking !== undefined) {
|
||||
const events: LLMEvent[] = []
|
||||
const id = `reasoning-${event.index ?? 0}`
|
||||
const providerMetadata = block.signature === undefined ? undefined : anthropicMetadata({ signature: block.signature })
|
||||
const lifecycle = Lifecycle.reasoningStart(state.lifecycle, events, id, providerMetadata)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${event.index ?? 0}`, block.thinking),
|
||||
lifecycle: block.thinking
|
||||
? Lifecycle.reasoningDelta(lifecycle, events, id, block.thinking, providerMetadata)
|
||||
: lifecycle,
|
||||
reasoningSignatures:
|
||||
event.index === undefined || block.signature === undefined
|
||||
? state.reasoningSignatures
|
||||
: { ...state.reasoningSignatures, [event.index]: block.signature },
|
||||
},
|
||||
events,
|
||||
]
|
||||
@@ -799,7 +836,7 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
|
||||
// Redacted thinking surfaces as an empty reasoning part carrying the opaque
|
||||
// payload as `redactedData` metadata (same model as Vercel's
|
||||
// @ai-sdk/anthropic). The existing content_block_stop closes the part.
|
||||
if (block.type === "redacted_thinking" && block.data) {
|
||||
if (block.type === "redacted_thinking" && block.data !== undefined) {
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
{
|
||||
@@ -847,18 +884,13 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
|
||||
}
|
||||
|
||||
if (delta?.type === "signature_delta" && delta.signature) {
|
||||
const events: LLMEvent[] = []
|
||||
const index = event.index ?? 0
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningEnd(
|
||||
state.lifecycle,
|
||||
events,
|
||||
`reasoning-${event.index ?? 0}`,
|
||||
anthropicMetadata({ signature: delta.signature }),
|
||||
),
|
||||
reasoningSignatures: { ...state.reasoningSignatures, [index]: delta.signature },
|
||||
},
|
||||
events,
|
||||
NO_EVENTS,
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
@@ -889,31 +921,53 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun
|
||||
const result = yield* ToolStream.finish(ADAPTER, state.tools, event.index)
|
||||
const events: LLMEvent[] = []
|
||||
const resultEvents = result.events ?? []
|
||||
const signature = state.reasoningSignatures[event.index]
|
||||
const lifecycle = resultEvents.length
|
||||
? Lifecycle.stepStart(state.lifecycle, events)
|
||||
: Lifecycle.reasoningEnd(
|
||||
Lifecycle.textEnd(state.lifecycle, events, `text-${event.index}`),
|
||||
events,
|
||||
`reasoning-${event.index}`,
|
||||
signature === undefined ? undefined : anthropicMetadata({ signature }),
|
||||
)
|
||||
events.push(...resultEvents)
|
||||
return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult
|
||||
const reasoningSignatures = { ...state.reasoningSignatures }
|
||||
delete reasoningSignatures[event.index]
|
||||
return [{ ...state, lifecycle, tools: result.tools, reasoningSignatures }, events] satisfies StepResult
|
||||
})
|
||||
|
||||
const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult => {
|
||||
const usage = mergeUsage(state.usage, mapUsage(event.usage))
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
usage,
|
||||
pendingFinish: {
|
||||
reason: {
|
||||
normalized: mapFinishReason(event.delta?.stop_reason),
|
||||
raw: event.delta?.stop_reason ?? undefined,
|
||||
},
|
||||
providerMetadata:
|
||||
event.delta?.stop_sequence === null || event.delta?.stop_sequence === undefined
|
||||
? undefined
|
||||
: anthropicMetadata({ stopSequence: event.delta.stop_sequence }),
|
||||
},
|
||||
},
|
||||
NO_EVENTS,
|
||||
]
|
||||
}
|
||||
|
||||
const onMessageStop = (state: ParserState): StepResult => {
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
|
||||
reason: {
|
||||
normalized: mapFinishReason(event.delta?.stop_reason),
|
||||
raw: event.delta?.stop_reason ?? undefined,
|
||||
reason: state.pendingFinish?.reason ?? {
|
||||
normalized: "unknown",
|
||||
raw: undefined,
|
||||
},
|
||||
usage,
|
||||
providerMetadata: event.delta?.stop_sequence
|
||||
? anthropicMetadata({ stopSequence: event.delta.stop_sequence })
|
||||
: undefined,
|
||||
usage: state.usage,
|
||||
providerMetadata: state.pendingFinish?.providerMetadata,
|
||||
})
|
||||
return [{ ...state, lifecycle, usage }, events]
|
||||
return [{ ...state, lifecycle }, events]
|
||||
}
|
||||
|
||||
// Prefix `error.type` so overloads, rate limits, and quota errors are visible
|
||||
@@ -938,6 +992,7 @@ const step = (state: ParserState, event: AnthropicEvent) => {
|
||||
if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
|
||||
if (event.type === "content_block_stop") return onContentBlockStop(state, event)
|
||||
if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
|
||||
if (event.type === "message_stop") return Effect.succeed(onMessageStop(state))
|
||||
if (event.type === "error") return onError(event)
|
||||
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||
}
|
||||
@@ -958,7 +1013,11 @@ export const protocol = Protocol.make({
|
||||
},
|
||||
stream: {
|
||||
event: Protocol.jsonEvent(AnthropicEvent),
|
||||
initial: () => ({ tools: ToolStream.empty<number>(), lifecycle: Lifecycle.initial() }),
|
||||
initial: () => ({
|
||||
tools: ToolStream.empty<number>(),
|
||||
reasoningSignatures: {},
|
||||
lifecycle: Lifecycle.initial(),
|
||||
}),
|
||||
step,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Route } from "../route/client"
|
||||
import { Auth } from "../route/auth"
|
||||
import { Endpoint } from "../route/endpoint"
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
type TextPart,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
type ToolContent,
|
||||
} from "../schema"
|
||||
import { JsonObject, optionalArray, ProviderShared } from "./shared"
|
||||
import { GeminiToolSchema } from "./utils/gemini-tool-schema"
|
||||
@@ -289,7 +289,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
})
|
||||
continue
|
||||
}
|
||||
const content: ReadonlyArray<ToolContent> = part.result.value
|
||||
const content: ReadonlyArray<Tool.Content> = part.result.value
|
||||
const text = content.filter((item) => item.type === "text").map((item) => item.text)
|
||||
const media: GeminiInlineDataPart[] = []
|
||||
for (const item of content) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { Content } from "@opencode-ai/schema/tool"
|
||||
import { HttpTransport } from "../route/transport"
|
||||
import { Protocol } from "../route/protocol"
|
||||
import {
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
type TextPart,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
type ToolContent,
|
||||
type ToolResultPart,
|
||||
} from "../schema"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
@@ -55,6 +55,9 @@ const OpenResponsesOutputText = Schema.Struct({
|
||||
text: Schema.String,
|
||||
})
|
||||
|
||||
export const MessagePhase = Schema.Literals(["commentary", "final_answer"])
|
||||
type MessagePhase = Schema.Schema.Type<typeof MessagePhase>
|
||||
|
||||
const OpenResponsesReasoningSummaryText = Schema.Struct({
|
||||
type: Schema.tag("summary_text"),
|
||||
text: Schema.String,
|
||||
@@ -86,10 +89,14 @@ const OpenResponsesFunctionCallOutput = Schema.Union([
|
||||
Schema.Array(OpenResponsesFunctionCallOutputContent),
|
||||
])
|
||||
|
||||
const OpenResponsesInputItem = Schema.Union([
|
||||
export const InputItem = Schema.Union([
|
||||
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
|
||||
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
|
||||
Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenResponsesOutputText) }),
|
||||
Schema.Struct({
|
||||
role: Schema.tag("assistant"),
|
||||
content: Schema.Array(OpenResponsesOutputText),
|
||||
phase: Schema.optionalKey(MessagePhase),
|
||||
}),
|
||||
OpenResponsesReasoningItem,
|
||||
OpenResponsesItemReference,
|
||||
Schema.Struct({
|
||||
@@ -104,7 +111,14 @@ const OpenResponsesInputItem = Schema.Union([
|
||||
output: OpenResponsesFunctionCallOutput,
|
||||
}),
|
||||
])
|
||||
type OpenResponsesInputItem = Schema.Schema.Type<typeof OpenResponsesInputItem>
|
||||
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
|
||||
type LoweredInputItem =
|
||||
| OpenResponsesInputItem
|
||||
| {
|
||||
readonly role: "assistant"
|
||||
readonly content: ReadonlyArray<{ readonly type: "output_text"; readonly text: string }>
|
||||
readonly phase?: MessagePhase | null
|
||||
}
|
||||
|
||||
// Mutable counterpart of the schema reasoning item so `lowerMessages` can fold
|
||||
// multiple streamed summary parts into the same item before flushing.
|
||||
@@ -135,7 +149,7 @@ export const ToolChoice = Schema.Union([
|
||||
// transports in sync without a destructure-and-strip dance.
|
||||
export const coreFields = {
|
||||
model: Schema.String,
|
||||
input: Schema.Array(OpenResponsesInputItem),
|
||||
input: Schema.Array(InputItem),
|
||||
instructions: Schema.optional(Schema.String),
|
||||
tools: optionalArray(Tool),
|
||||
tool_choice: Schema.optional(ToolChoice),
|
||||
@@ -167,7 +181,12 @@ export type OpenResponsesBody = Schema.Schema.Type<typeof OpenResponsesBody>
|
||||
|
||||
const OpenResponsesUsage = Schema.Struct({
|
||||
input_tokens: Schema.optional(Schema.Number),
|
||||
input_tokens_details: optionalNull(Schema.Struct({ cached_tokens: Schema.optional(Schema.Number) })),
|
||||
input_tokens_details: optionalNull(
|
||||
Schema.Struct({
|
||||
cached_tokens: Schema.optional(Schema.Number),
|
||||
cache_write_tokens: Schema.optional(Schema.Number),
|
||||
}),
|
||||
),
|
||||
output_tokens: Schema.optional(Schema.Number),
|
||||
output_tokens_details: optionalNull(Schema.Struct({ reasoning_tokens: Schema.optional(Schema.Number) })),
|
||||
total_tokens: Schema.optional(Schema.Number),
|
||||
@@ -201,6 +220,7 @@ export const Event = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.String,
|
||||
delta: Schema.optional(Schema.String),
|
||||
text: Schema.optional(Schema.String),
|
||||
item_id: Schema.optional(Schema.String),
|
||||
summary_index: Schema.optional(Schema.Number),
|
||||
item: Schema.optional(StreamItem),
|
||||
@@ -233,6 +253,7 @@ export interface Extension {
|
||||
readonly media: ProviderShared.ValidatedMedia
|
||||
readonly request: LLMRequest
|
||||
}) => MediaInput | undefined
|
||||
readonly messagePhase?: (value: unknown) => MessagePhase | null | undefined
|
||||
}
|
||||
|
||||
const BASE: Extension = { id: ADAPTER, name: NAME }
|
||||
@@ -244,6 +265,9 @@ export interface ParserState {
|
||||
readonly tools: ToolStream.State<string>
|
||||
readonly hasFunctionCall: boolean
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly messageItems: ReadonlySet<string>
|
||||
readonly messagePhase: (value: unknown) => MessagePhase | null | undefined
|
||||
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
|
||||
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
||||
readonly store: boolean | undefined
|
||||
}
|
||||
@@ -347,7 +371,7 @@ const lowerUserContent = Effect.fn("OpenResponses.lowerUserContent")(function* (
|
||||
// Tool results may carry structured text, images, and files. Keep media as provider-native
|
||||
// content instead of JSON-stringifying base64 into a prompt string.
|
||||
const lowerToolResultContentItem = Effect.fn("OpenResponses.lowerToolResultContentItem")(function* (
|
||||
item: ToolContent,
|
||||
item: Content,
|
||||
request: LLMRequest,
|
||||
extension: Extension,
|
||||
) {
|
||||
@@ -368,14 +392,14 @@ const lowerToolResultOutput = Effect.fn("OpenResponses.lowerToolResultOutput")(f
|
||||
// compatibility with existing cassettes and provider expectations.
|
||||
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
|
||||
// Preserve the narrowed array element type when compiled through a consumer package.
|
||||
const content: ReadonlyArray<ToolContent> = part.result.value
|
||||
const content: ReadonlyArray<Content> = part.result.value
|
||||
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension))
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
|
||||
const system: OpenResponsesInputItem[] =
|
||||
const system: LoweredInputItem[] =
|
||||
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
||||
const input: OpenResponsesInputItem[] = [...system]
|
||||
const input: LoweredInputItem[] = [...system]
|
||||
const store = OpenResponsesOptions.resolve(request).store
|
||||
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
|
||||
|
||||
@@ -407,7 +431,27 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
const hostedToolReferences = new Set<string>()
|
||||
const flushText = () => {
|
||||
if (content.length === 0) return
|
||||
input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) })
|
||||
const groups = content.reduce<Array<{ phase: MessagePhase | null | undefined; parts: TextPart[] }>>(
|
||||
(groups, part) => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
const phase =
|
||||
ProviderShared.isRecord(metadata)
|
||||
? messagePhase(metadata.phase, extension)
|
||||
: undefined
|
||||
const group = groups.at(-1)
|
||||
if (group && group.phase === phase) group.parts.push(part)
|
||||
else groups.push({ phase, parts: [part] })
|
||||
return groups
|
||||
},
|
||||
[],
|
||||
)
|
||||
input.push(
|
||||
...groups.map((group) => ({
|
||||
role: "assistant" as const,
|
||||
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
|
||||
...(group.phase === undefined ? {} : { phase: group.phase }),
|
||||
})),
|
||||
)
|
||||
content.splice(0, content.length)
|
||||
}
|
||||
for (const part of message.content) {
|
||||
@@ -452,7 +496,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
if (store !== false && itemID && !hostedToolReferences.has(itemID))
|
||||
input.push({ type: "item_reference", id: itemID })
|
||||
if (store === false && part.result.type === "content") {
|
||||
const content: ReadonlyArray<ToolContent> = part.result.value
|
||||
const content: ReadonlyArray<Content> = part.result.value
|
||||
input.push({
|
||||
role: "user",
|
||||
content: yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension)),
|
||||
@@ -508,9 +552,9 @@ const lowerOptions = (request: LLMRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (
|
||||
export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWithExtension")(function* (
|
||||
request: LLMRequest,
|
||||
extension: Extension = BASE,
|
||||
extension: Extension,
|
||||
) {
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
@@ -536,23 +580,31 @@ export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (
|
||||
}
|
||||
})
|
||||
|
||||
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesBody))
|
||||
|
||||
export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (request: LLMRequest) {
|
||||
return yield* decodeBody(yield* fromRequestWithExtension(request, BASE))
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Stream Parsing
|
||||
// =============================================================================
|
||||
// Responses APIs report `input_tokens` (inclusive total) with a
|
||||
// `cached_tokens` subset, and `output_tokens` (inclusive total) with a
|
||||
// `reasoning_tokens` subset. Pass the totals through and derive the
|
||||
// cached-read and cache-write subsets, and `output_tokens` (inclusive total)
|
||||
// with a `reasoning_tokens` subset. Pass the totals through and derive the
|
||||
// non-cached breakdown.
|
||||
const mapUsage = (usage: OpenResponsesUsage | null | undefined, providerMetadataKey: string) => {
|
||||
if (!usage) return undefined
|
||||
const cached = usage.input_tokens_details?.cached_tokens
|
||||
const cacheWrite = usage.input_tokens_details?.cache_write_tokens
|
||||
const reasoning = usage.output_tokens_details?.reasoning_tokens
|
||||
const nonCached = ProviderShared.subtractTokens(usage.input_tokens, cached)
|
||||
const nonCached = ProviderShared.subtractTokens(usage.input_tokens, ProviderShared.sumTokens(cached, cacheWrite))
|
||||
return new Usage({
|
||||
inputTokens: usage.input_tokens,
|
||||
outputTokens: usage.output_tokens,
|
||||
nonCachedInputTokens: nonCached,
|
||||
cacheReadInputTokens: cached,
|
||||
cacheWriteInputTokens: cacheWrite,
|
||||
reasoningTokens: reasoning,
|
||||
totalTokens: ProviderShared.totalTokens(usage.input_tokens, usage.output_tokens, usage.total_tokens),
|
||||
providerMetadata: { [providerMetadataKey]: usage },
|
||||
@@ -588,24 +640,30 @@ const NO_EVENTS: StepResult["1"] = []
|
||||
const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"])
|
||||
export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type)
|
||||
|
||||
const onOutputTextDelta = (state: ParserState, event: Event): StepResult => {
|
||||
const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepResult => {
|
||||
if (!event.delta) return [state, NO_EVENTS]
|
||||
const events: LLMEvent[] = []
|
||||
const phase = state.messagePhases[id]
|
||||
const metadata = phase === undefined ? undefined : providerMetadata(state, { phase })
|
||||
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata)
|
||||
return [
|
||||
{ ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, event.item_id ?? "text-0", event.delta) },
|
||||
{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) },
|
||||
events,
|
||||
]
|
||||
}
|
||||
|
||||
const onOutputTextDone = (state: ParserState, event: Event): StepResult => {
|
||||
const onOutputTextDone = (state: ParserState, event: Event, id: string): StepResult => {
|
||||
if (state.messageItems.has(id)) {
|
||||
if (state.lifecycle.text.has(id) || event.text === undefined) return [state, NO_EVENTS]
|
||||
return onOutputTextDelta(state, { ...event, delta: event.text }, id)
|
||||
}
|
||||
const events: LLMEvent[] = []
|
||||
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, event.item_id ?? "text-0") }, events]
|
||||
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events]
|
||||
}
|
||||
|
||||
export const onReasoningDelta = (state: ParserState, event: Event): StepResult => {
|
||||
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
|
||||
if (!event.delta) return [state, NO_EVENTS]
|
||||
const events: LLMEvent[] = []
|
||||
const itemID = event.item_id ?? "reasoning-0"
|
||||
const id =
|
||||
event.summary_index !== undefined || state.reasoningItems[itemID] ? `${itemID}:${event.summary_index ?? 0}` : itemID
|
||||
return [
|
||||
@@ -636,6 +694,18 @@ const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }
|
||||
// best-effort, not guaranteed.
|
||||
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
const item = event.item
|
||||
if (item?.type === "message" && item.id)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
messageItems: new Set([...state.messageItems, item.id]),
|
||||
messagePhases: (() => {
|
||||
const phase = state.messagePhase(item.phase)
|
||||
return phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase }
|
||||
})(),
|
||||
},
|
||||
NO_EVENTS,
|
||||
]
|
||||
if (item && isReasoningItem(item)) {
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
@@ -792,7 +862,28 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
const item = event.item
|
||||
if (!item) return [state, NO_EVENTS] satisfies StepResult
|
||||
|
||||
if (item.type === "message" && item.id) return onOutputTextDone(state, { ...event, item_id: item.id })
|
||||
if (item.type === "message" && item.id) {
|
||||
const itemPhase = state.messagePhase(item.phase)
|
||||
const phase = itemPhase === undefined ? state.messagePhases[item.id] : itemPhase
|
||||
const events: LLMEvent[] = []
|
||||
const messageItems = new Set(state.messageItems)
|
||||
messageItems.delete(item.id)
|
||||
const { [item.id]: _phase, ...messagePhases } = state.messagePhases
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.textEnd(
|
||||
state.lifecycle,
|
||||
events,
|
||||
item.id,
|
||||
phase === undefined ? undefined : providerMetadata(state, { phase }),
|
||||
),
|
||||
messageItems,
|
||||
messagePhases,
|
||||
},
|
||||
events,
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
if (item.type === "function_call") {
|
||||
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
|
||||
@@ -892,19 +983,41 @@ const providerError = (state: ParserState, event: Event, fallback: string) => {
|
||||
}
|
||||
|
||||
export const step = (state: ParserState, event: Event) => {
|
||||
if (event.type === "response.output_text.delta") return Effect.succeed(onOutputTextDelta(state, event))
|
||||
if (event.type === "response.output_text.done") return Effect.succeed(onOutputTextDone(state, event))
|
||||
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta")
|
||||
return Effect.succeed(onReasoningDelta(state, event))
|
||||
if (event.type === "response.reasoning.done" || event.type === "response.reasoning_summary_text.done")
|
||||
if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") {
|
||||
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
return Effect.succeed(
|
||||
event.type === "response.output_text.delta"
|
||||
? onOutputTextDelta(state, event, event.item_id)
|
||||
: onOutputTextDone(state, event, event.item_id),
|
||||
)
|
||||
}
|
||||
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") {
|
||||
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
|
||||
}
|
||||
if (event.type === "response.reasoning.done" || event.type === "response.reasoning_summary_text.done") {
|
||||
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
return Effect.succeed(onReasoningDone(state, event))
|
||||
}
|
||||
if (event.type === "response.reasoning_summary_part.added")
|
||||
return Effect.succeed(onReasoningSummaryPartAdded(state, event))
|
||||
return event.item_id
|
||||
? Effect.succeed(onReasoningSummaryPartAdded(state, event))
|
||||
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.reasoning_summary_part.done")
|
||||
return Effect.succeed(onReasoningSummaryPartDone(state, event))
|
||||
if (event.type === "response.output_item.added") return Effect.succeed(onOutputItemAdded(state, event))
|
||||
return event.item_id
|
||||
? Effect.succeed(onReasoningSummaryPartDone(state, event))
|
||||
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.output_item.added") {
|
||||
if (event.item?.type === "message" && !event.item.id)
|
||||
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
|
||||
return Effect.succeed(onOutputItemAdded(state, event))
|
||||
}
|
||||
if (event.type === "response.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event)
|
||||
if (event.type === "response.output_item.done") return onOutputItemDone(state, event)
|
||||
if (event.type === "response.output_item.done") {
|
||||
if (event.item?.type === "message" && !event.item.id)
|
||||
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
|
||||
return onOutputItemDone(state, event)
|
||||
}
|
||||
if (event.type === "response.completed" || event.type === "response.incomplete")
|
||||
return Effect.succeed(onResponseFinish(state, event))
|
||||
if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`)
|
||||
@@ -926,10 +1039,18 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
|
||||
hasFunctionCall: false,
|
||||
tools: ToolStream.empty<string>(),
|
||||
lifecycle: Lifecycle.initial(),
|
||||
messageItems: new Set<string>(),
|
||||
messagePhase: (value) => messagePhase(value, extension),
|
||||
messagePhases: {},
|
||||
reasoningItems: {},
|
||||
store: OpenResponsesOptions.resolve(request).store,
|
||||
})
|
||||
|
||||
const messagePhase = (value: unknown, extension: Extension): MessagePhase | null | undefined => {
|
||||
if (value === "commentary" || value === "final_answer") return value
|
||||
return extension.messagePhase?.(value)
|
||||
}
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
id: ADAPTER,
|
||||
body: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Route } from "../route/client"
|
||||
import { Auth } from "../route/auth"
|
||||
import { Endpoint } from "../route/endpoint"
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
type TextPart,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
type ToolContent,
|
||||
} from "../schema"
|
||||
import { classifyProviderFailure } from "../provider-error"
|
||||
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
@@ -131,6 +131,7 @@ const OpenAIChatUsage = Schema.Struct({
|
||||
prompt_tokens_details: optionalNull(
|
||||
Schema.Struct({
|
||||
cached_tokens: Schema.optional(Schema.Number),
|
||||
cache_write_tokens: Schema.optional(Schema.Number),
|
||||
}),
|
||||
),
|
||||
completion_tokens_details: optionalNull(
|
||||
@@ -334,7 +335,7 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (m
|
||||
messages.push({ role: "tool", tool_call_id: part.id, content: ProviderShared.toolResultText(part) })
|
||||
continue
|
||||
}
|
||||
const content: ReadonlyArray<ToolContent> = part.result.value
|
||||
const content: ReadonlyArray<Tool.Content> = part.result.value
|
||||
const text = content.filter((item) => item.type === "text").map((item) => item.text)
|
||||
messages.push({ role: "tool", tool_call_id: part.id, content: text.join("\n") })
|
||||
const files = content.filter((item) => item.type === "file")
|
||||
@@ -453,20 +454,22 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
||||
}
|
||||
|
||||
// OpenAI Chat reports `prompt_tokens` (inclusive total) with a
|
||||
// `cached_tokens` subset, and `completion_tokens` (inclusive total) with
|
||||
// a `reasoning_tokens` subset. We pass the inclusive totals through and
|
||||
// derive the non-cached breakdown so the `LLM.Usage` contract is
|
||||
// cached-read and cache-write subsets, and `completion_tokens` (inclusive
|
||||
// total) with a `reasoning_tokens` subset. We pass the inclusive totals
|
||||
// through and derive the non-cached breakdown so the `LLM.Usage` contract is
|
||||
// satisfied on both sides.
|
||||
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const cached = usage.prompt_tokens_details?.cached_tokens
|
||||
const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens
|
||||
const reasoning = usage.completion_tokens_details?.reasoning_tokens
|
||||
const nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, cached)
|
||||
const nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, ProviderShared.sumTokens(cached, cacheWrite))
|
||||
return new Usage({
|
||||
inputTokens: usage.prompt_tokens,
|
||||
outputTokens: usage.completion_tokens,
|
||||
nonCachedInputTokens: nonCached,
|
||||
cacheReadInputTokens: cached,
|
||||
cacheWriteInputTokens: cacheWrite,
|
||||
reasoningTokens: reasoning,
|
||||
totalTokens: ProviderShared.totalTokens(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens),
|
||||
providerMetadata: { openai: usage },
|
||||
|
||||
@@ -35,8 +35,18 @@ const OpenAIResponsesToolChoice = Schema.Union([
|
||||
Schema.Struct({ type: Schema.tag("image_generation") }),
|
||||
])
|
||||
|
||||
const OpenAIResponsesInputItem = Schema.Union([
|
||||
Schema.Struct({
|
||||
role: Schema.tag("assistant"),
|
||||
content: Schema.Array(Schema.Struct({ type: Schema.tag("output_text"), text: Schema.String })),
|
||||
phase: Schema.optionalKey(Schema.NullOr(OpenResponses.MessagePhase)),
|
||||
}),
|
||||
OpenResponses.InputItem,
|
||||
])
|
||||
|
||||
const OpenAIResponsesCoreFields = {
|
||||
...OpenResponses.coreFields,
|
||||
input: Schema.Array(OpenAIResponsesInputItem),
|
||||
tools: optionalArray(OpenAIResponsesTools),
|
||||
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
|
||||
}
|
||||
@@ -60,6 +70,7 @@ const encodeWebSocketMessage = Schema.encodeSync(Schema.fromJsonString(OpenAIRes
|
||||
const extension = {
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
messagePhase: (value: unknown) => (value === null ? null : undefined),
|
||||
lowerMedia: ({ part, media, request }) => {
|
||||
if (request.model.provider !== "xai" || media.mime !== "application/pdf") return undefined
|
||||
return {
|
||||
@@ -102,7 +113,7 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
|
||||
})
|
||||
|
||||
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
|
||||
const body = yield* OpenResponses.fromRequest(
|
||||
const body = yield* OpenResponses.fromRequestWithExtension(
|
||||
LLMRequest.update(request, { tools: [], toolChoice: undefined }),
|
||||
extension,
|
||||
)
|
||||
@@ -208,9 +219,13 @@ const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function*
|
||||
|
||||
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
|
||||
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
|
||||
return Effect.succeed(OpenResponses.onReasoningDelta(state, event))
|
||||
return event.item_id
|
||||
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
|
||||
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.reasoning_text.done" || event.type === "response.reasoning_summary.done")
|
||||
return Effect.succeed(OpenResponses.onReasoningDone(state, event))
|
||||
return event.item_id
|
||||
? Effect.succeed(OpenResponses.onReasoningDone(state, event))
|
||||
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.output_item.done" && event.item && isHostedToolItem(event.item))
|
||||
return onHostedToolDone(state, event.item)
|
||||
return OpenResponses.step(state, event)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Buffer } from "node:buffer"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import * as Sse from "effect/unstable/encoding/Sse"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
@@ -9,7 +10,6 @@ import {
|
||||
type ContentPart,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
type ToolFileContent,
|
||||
type TextPart,
|
||||
type ToolResultPart,
|
||||
} from "../schema"
|
||||
@@ -206,7 +206,7 @@ export const validateMedia = Effect.fn("ProviderShared.validateMedia")(function*
|
||||
return { mime, base64, dataUrl: `data:${mime};base64,${base64}`, bytes } satisfies ValidatedMedia
|
||||
})
|
||||
|
||||
export const validateToolFile = (route: string, part: ToolFileContent, supportedMimes: ReadonlySet<string>) =>
|
||||
export const validateToolFile = (route: string, part: Tool.FileContent, supportedMimes: ReadonlySet<string>) =>
|
||||
validateMedia(route, { type: "media", mediaType: part.mime, data: part.uri, filename: part.name }, supportedMimes)
|
||||
|
||||
export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "")
|
||||
|
||||
@@ -14,16 +14,19 @@ export const stepStart = (state: State, events: LLMEvent[]): State => {
|
||||
return { ...state, stepStarted: true }
|
||||
}
|
||||
|
||||
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
|
||||
export const textStart = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
|
||||
if (state.text.has(id)) return state
|
||||
const stepped = stepStart(state, events)
|
||||
if (stepped.text.has(id)) {
|
||||
events.push(LLMEvent.textDelta({ id, text }))
|
||||
return stepped
|
||||
}
|
||||
events.push(LLMEvent.textStart({ id }), LLMEvent.textDelta({ id, text }))
|
||||
events.push(LLMEvent.textStart({ id, providerMetadata }))
|
||||
return { ...stepped, text: new Set([...stepped.text, id]) }
|
||||
}
|
||||
|
||||
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
|
||||
const started = textStart(state, events, id)
|
||||
events.push(LLMEvent.textDelta({ id, text }))
|
||||
return started
|
||||
}
|
||||
|
||||
export const reasoningStart = (
|
||||
state: State,
|
||||
events: LLMEvent[],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Schema } from "effect"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
|
||||
|
||||
export const ProviderFailureClassification = Schema.Literal("context-overflow")
|
||||
@@ -152,8 +153,4 @@ export class LLMError extends Schema.TaggedErrorClass<LLMError>()("LLM.Error", {
|
||||
* Anything thrown or yielded by a handler that is not a `ToolFailure` is
|
||||
* treated as a defect and fails the stream.
|
||||
*/
|
||||
export class ToolFailure extends Schema.TaggedErrorClass<ToolFailure>()("LLM.ToolFailure", {
|
||||
message: Schema.String,
|
||||
error: Schema.optional(Schema.Defect()),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}) {}
|
||||
export class ToolFailure extends Tool.Error {}
|
||||
|
||||
@@ -40,9 +40,9 @@ import { ProviderFailureClassification } from "./errors"
|
||||
* - Anthropic and Bedrock report the input breakdown natively: Anthropic's
|
||||
* `input_tokens` and Bedrock's `inputTokens` are non-cached only. Their
|
||||
* mappers sum the breakdown to derive the inclusive `inputTokens`.
|
||||
* Anthropic does *not* break extended-thinking out of `output_tokens`, so
|
||||
* `reasoningTokens` is `undefined` and `outputTokens` carries the
|
||||
* combined total — a documented limitation of the Anthropic API.
|
||||
* Anthropic's `outputTokens` includes extended thinking. Newer responses
|
||||
* expose that subset as `output_tokens_details.thinking_tokens`, which maps
|
||||
* to `reasoningTokens`; older responses leave it undefined.
|
||||
*
|
||||
* `providerMetadata` always carries the provider's raw usage payload —
|
||||
* keyed by provider name (`{ openai: ... }`, `{ anthropic: ... }`, etc.)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Schema } from "effect"
|
||||
import { ToolContent, ToolFileContent, ToolTextContent } from "@opencode-ai/schema/llm"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { JsonSchema, MessageRole, ProviderMetadata } from "./ids"
|
||||
import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, ModelSchema, ProviderOptions } from "./options"
|
||||
import { isRecord } from "../utils/record"
|
||||
@@ -40,8 +40,6 @@ export const MediaPart = Schema.Struct({
|
||||
}).annotate({ identifier: "LLM.Content.Media" })
|
||||
export type MediaPart = Schema.Schema.Type<typeof MediaPart>
|
||||
|
||||
export { ToolContent, ToolFileContent, ToolTextContent }
|
||||
|
||||
const isToolResultValue = (value: unknown): value is ToolResultValue =>
|
||||
isRecord(value) &&
|
||||
(value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") &&
|
||||
@@ -63,7 +61,7 @@ export const ToolResultValue = Object.assign(
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("content"),
|
||||
value: Schema.Array(ToolContent),
|
||||
value: Schema.Array(Tool.Content),
|
||||
}),
|
||||
]).annotate({ identifier: "LLM.ToolResult" }),
|
||||
{
|
||||
@@ -79,16 +77,16 @@ export type ToolResultValue = Schema.Schema.Type<typeof ToolResultValue>
|
||||
|
||||
export interface ToolOutput {
|
||||
readonly structured: unknown
|
||||
readonly content: ReadonlyArray<ToolContent>
|
||||
readonly content: ReadonlyArray<Tool.Content>
|
||||
}
|
||||
|
||||
export const ToolOutput = Object.assign(
|
||||
Schema.Struct({
|
||||
structured: Schema.Unknown,
|
||||
content: Schema.Array(ToolContent),
|
||||
content: Schema.Array(Tool.Content),
|
||||
}).annotate({ identifier: "LLM.ToolOutput" }),
|
||||
{
|
||||
make: (structured: unknown, content: ReadonlyArray<ToolContent> = []): ToolOutput => ({ structured, content }),
|
||||
make: (structured: unknown, content: ReadonlyArray<Tool.Content> = []): ToolOutput => ({ structured, content }),
|
||||
fromResultValue: (result: ToolResultValue): ToolOutput | undefined => {
|
||||
switch (result.type) {
|
||||
case "json":
|
||||
|
||||
@@ -251,11 +251,11 @@ export class CacheHint extends Schema.Class<CacheHint>("LLM.CacheHint")({
|
||||
// Auto-placement policy for prompt caching. The protocol-neutral lowering step
|
||||
// reads this and injects `CacheHint`s at the configured boundaries; the
|
||||
// per-protocol body builders then translate those hints into wire markers as
|
||||
// usual. `"auto"` is the recommended default for agent loops — it places one
|
||||
// breakpoint at the last tool definition, one at the last system part, and one
|
||||
// at the latest user message. The combination of provider invalidation
|
||||
// hierarchy (tools → system → messages) and Anthropic/Bedrock's 20-block
|
||||
// lookback means three trailing breakpoints reliably cover the static prefix.
|
||||
// usual. `"auto"` is the recommended default for agent loops — it places
|
||||
// breakpoints at the last tool definition, the first and last distinct system
|
||||
// parts, and the conversation tail. The rolling message breakpoint keeps a
|
||||
// prior cache entry within Anthropic/Bedrock's 20-block lookback during long
|
||||
// tool loops.
|
||||
//
|
||||
// Pass `"none"` to opt out entirely (the legacy behavior). Pass the granular
|
||||
// object form to override individual choices.
|
||||
|
||||
@@ -28,7 +28,7 @@ export const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect<Dispat
|
||||
|
||||
return decodeAndExecute(tool, call).pipe(
|
||||
Effect.map((value) => result(call, value)),
|
||||
Effect.catchTag("LLM.ToolFailure", (failure) =>
|
||||
Effect.catchTag("Tool.Error", (failure) =>
|
||||
Effect.succeed(result(call, { type: "error", value: failure.message }, failure.error)),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Effect, JsonSchema, Schema } from "effect"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import type {
|
||||
ToolCallPart,
|
||||
ToolContent,
|
||||
ToolDefinition as ToolDefinitionClass,
|
||||
ToolOutput as ToolOutputType,
|
||||
} from "./schema"
|
||||
@@ -31,7 +31,7 @@ export interface ToolModelOutputInput<Parameters, Output> {
|
||||
|
||||
export type ToolToModelOutput<Parameters extends ToolSchema<any>, Success extends ToolSchema<any>> = (
|
||||
input: ToolModelOutputInput<Schema.Schema.Type<Parameters>, Success["Encoded"]>,
|
||||
) => ReadonlyArray<ToolContent>
|
||||
) => ReadonlyArray<Tool.Content>
|
||||
|
||||
/**
|
||||
* A type-safe LLM tool. Each tool bundles its own description, parameter
|
||||
@@ -95,7 +95,7 @@ type DynamicToolConfig = {
|
||||
readonly jsonSchema: JsonSchema.JsonSchema
|
||||
readonly outputSchema?: JsonSchema.JsonSchema
|
||||
readonly execute?: (params: unknown, context?: ToolExecuteContext) => Effect.Effect<unknown, ToolFailure>
|
||||
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<ToolContent>
|
||||
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<Tool.Content>
|
||||
readonly toStructuredOutput?: (output: unknown) => unknown
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ export function make(config: {
|
||||
readonly jsonSchema: JsonSchema.JsonSchema
|
||||
readonly outputSchema?: JsonSchema.JsonSchema
|
||||
readonly execute: (params: unknown, context?: ToolExecuteContext) => Effect.Effect<unknown, ToolFailure>
|
||||
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<ToolContent>
|
||||
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<Tool.Content>
|
||||
readonly toStructuredOutput?: (output: unknown) => unknown
|
||||
}): AnyExecutableTool
|
||||
export function make(config: {
|
||||
@@ -159,7 +159,7 @@ export function make(config: {
|
||||
readonly jsonSchema: JsonSchema.JsonSchema
|
||||
readonly outputSchema?: JsonSchema.JsonSchema
|
||||
readonly execute?: undefined
|
||||
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<ToolContent>
|
||||
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<Tool.Content>
|
||||
readonly toStructuredOutput?: (output: unknown) => unknown
|
||||
}): AnyTool
|
||||
export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool {
|
||||
@@ -236,7 +236,7 @@ const toJsonSchema = (schema: Schema.Top): JsonSchema.JsonSchema => {
|
||||
}
|
||||
|
||||
const project = (
|
||||
toModelOutput: ((input: ToolModelOutputInput<any, any>) => ReadonlyArray<ToolContent>) | undefined,
|
||||
toModelOutput: ((input: ToolModelOutputInput<any, any>) => ReadonlyArray<Tool.Content>) | undefined,
|
||||
toStructuredOutput: ((output: unknown) => unknown) | undefined,
|
||||
parameters: unknown,
|
||||
callID: ToolCallPart["id"],
|
||||
|
||||
@@ -39,8 +39,8 @@ describe("applyCachePolicy", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
// No explicit cache field → auto policy fires → last system part + latest
|
||||
// user message both get cache_control markers.
|
||||
// A single system block is both the first and last boundary, so the auto
|
||||
// policy deduplicates it and still marks the conversation tail.
|
||||
expect(prepared.body).toMatchObject({
|
||||
system: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }],
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }] }],
|
||||
@@ -48,12 +48,15 @@ describe("applyCachePolicy", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("'auto' marks the last tool, last system part, and latest user message on Anthropic", () =>
|
||||
it.effect("'auto' marks the last tool, first and last system parts, and final message boundary on Anthropic", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: anthropicModel,
|
||||
system: "Sys A",
|
||||
system: [
|
||||
{ type: "text", text: "Base agent" },
|
||||
{ type: "text", text: "Project instructions" },
|
||||
],
|
||||
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
|
||||
messages: [
|
||||
Message.user("first user"),
|
||||
@@ -66,7 +69,10 @@ describe("applyCachePolicy", () => {
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
tools: [{ name: "t1", cache_control: { type: "ephemeral" } }],
|
||||
system: [{ type: "text", text: "Sys A", cache_control: { type: "ephemeral" } }],
|
||||
system: [
|
||||
{ type: "text", text: "Base agent", cache_control: { type: "ephemeral" } },
|
||||
{ type: "text", text: "Project instructions", cache_control: { type: "ephemeral" } },
|
||||
],
|
||||
messages: [
|
||||
{ role: "user", content: [{ type: "text", text: "first user" }] },
|
||||
{ role: "assistant", content: [{ type: "text", text: "assistant reply" }] },
|
||||
@@ -120,7 +126,10 @@ describe("applyCachePolicy", () => {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: bedrockModel,
|
||||
system: "Sys",
|
||||
system: [
|
||||
{ type: "text", text: "Base agent" },
|
||||
{ type: "text", text: "Project instructions" },
|
||||
],
|
||||
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
|
||||
messages: [Message.user("first user"), Message.assistant("reply"), Message.user("latest user")],
|
||||
cache: "auto",
|
||||
@@ -131,7 +140,12 @@ describe("applyCachePolicy", () => {
|
||||
toolConfig: {
|
||||
tools: [{ toolSpec: { name: "t1" } }, { cachePoint: { type: "default" } }],
|
||||
},
|
||||
system: [{ text: "Sys" }, { cachePoint: { type: "default" } }],
|
||||
system: [
|
||||
{ text: "Base agent" },
|
||||
{ cachePoint: { type: "default" } },
|
||||
{ text: "Project instructions" },
|
||||
{ cachePoint: { type: "default" } },
|
||||
],
|
||||
messages: [
|
||||
{ role: "user", content: [{ text: "first user" }] },
|
||||
{ role: "assistant", content: [{ text: "reply" }] },
|
||||
@@ -193,9 +207,55 @@ describe("applyCachePolicy", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const body = prepared.body as { system: Array<{ text: string; cache_control?: unknown }> }
|
||||
const body = prepared.body as {
|
||||
system: Array<{ text: string; cache_control?: unknown }>
|
||||
messages: Array<{ content: Array<{ cache_control?: unknown }> }>
|
||||
}
|
||||
expect(body.system[0]?.cache_control).toEqual({ type: "ephemeral", ttl: "1h" })
|
||||
expect(body.system[1]?.cache_control).toEqual({ type: "ephemeral" })
|
||||
expect(body.messages[0]?.content[0]?.cache_control).toEqual({ type: "ephemeral" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("auto policy stays within the four-breakpoint cap when preserving manual hints", () =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model: anthropicModel,
|
||||
system: [
|
||||
{ type: "text", text: "Base agent" },
|
||||
{
|
||||
type: "text",
|
||||
text: "Manual context",
|
||||
cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }),
|
||||
},
|
||||
{ type: "text", text: "Project instructions" },
|
||||
],
|
||||
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
|
||||
prompt: "hi",
|
||||
cache: "auto",
|
||||
})
|
||||
const applied = applyCachePolicy(request)
|
||||
expect(applied.tools[0]?.cache).toBeDefined()
|
||||
expect(applied.system.map((part) => part.cache !== undefined)).toEqual([true, true, true])
|
||||
const tail = applied.messages[0]!.content[0]!
|
||||
expect("cache" in tail ? tail.cache : undefined).toBeUndefined()
|
||||
expect(applyCachePolicy(applied)).toBe(applied)
|
||||
|
||||
const prepared = yield* LLMClient.prepare(request)
|
||||
|
||||
const body = prepared.body as {
|
||||
tools: Array<{ cache_control?: unknown }>
|
||||
system: Array<{ cache_control?: unknown }>
|
||||
messages: Array<{ content: Array<{ cache_control?: unknown }> }>
|
||||
}
|
||||
const marked = [
|
||||
...body.tools.map((tool) => tool.cache_control),
|
||||
...body.system.map((part) => part.cache_control),
|
||||
...body.messages.flatMap((message) => message.content.map((part) => part.cache_control)),
|
||||
].filter((cache) => cache !== undefined)
|
||||
expect(marked).toHaveLength(4)
|
||||
expect(body.system[1]?.cache_control).toEqual({ type: "ephemeral", ttl: "1h" })
|
||||
expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
+54
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CacheHint, LLM } from "../../src"
|
||||
import { CacheHint, LLM, LLMRequest, Message, ToolCallPart, ToolDefinition } from "../../src"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import * as Anthropic from "../../src/providers/anthropic"
|
||||
import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios"
|
||||
@@ -24,6 +24,39 @@ const cacheRequest = LLM.request({
|
||||
generation: { maxTokens: 16, temperature: 0 },
|
||||
})
|
||||
|
||||
const lookup = ToolDefinition.make({
|
||||
name: "lookup",
|
||||
description: "Look up a fixture value.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { index: { type: "number" } },
|
||||
required: ["index"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
})
|
||||
const longToolTurn = [
|
||||
Message.user("Run the fixture lookups."),
|
||||
...Array.from({ length: 11 }, (_, index) => {
|
||||
const id = `lookup_${index}`
|
||||
return [
|
||||
Message.assistant(ToolCallPart.make({ id, name: lookup.name, input: { index } })),
|
||||
Message.tool({
|
||||
id,
|
||||
name: lookup.name,
|
||||
result: `Fixture result ${index}. `.repeat(80),
|
||||
}),
|
||||
]
|
||||
}).flat(),
|
||||
]
|
||||
const longToolTurnRequest = LLM.request({
|
||||
id: "recorded_anthropic_cache_long_tool_turn",
|
||||
model,
|
||||
system: LARGE_CACHEABLE_SYSTEM,
|
||||
messages: longToolTurn,
|
||||
tools: [lookup],
|
||||
generation: { maxTokens: 16, temperature: 0 },
|
||||
})
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "anthropic-messages-cache",
|
||||
provider: "anthropic",
|
||||
@@ -50,4 +83,28 @@ describe("Anthropic Messages cache recorded", () => {
|
||||
expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0)
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect.with("keeps a long tool turn inside the cache lookback", { tags: ["cache", "tool"] }, () =>
|
||||
Effect.gen(function* () {
|
||||
const first = yield* LLMClient.generate(longToolTurnRequest)
|
||||
const firstRead = first.usage?.cacheReadInputTokens ?? 0
|
||||
const firstWrite = first.usage?.cacheWriteInputTokens ?? 0
|
||||
const firstCached = firstRead + firstWrite
|
||||
// The prefix may already be warm when recording, so either a read or a
|
||||
// write establishes that Anthropic recognized the cache boundary.
|
||||
expect(firstCached).toBeGreaterThan(0)
|
||||
|
||||
const second = yield* LLMClient.generate(
|
||||
LLMRequest.update(longToolTurnRequest, {
|
||||
messages: [
|
||||
...longToolTurn,
|
||||
Message.assistant("The fixture lookups are complete."),
|
||||
Message.user("Reply exactly: OK"),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(firstCached)
|
||||
expect(second.usage?.cacheWriteInputTokens ?? 0).toBeLessThan(firstCached)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -506,6 +506,7 @@ describe("Anthropic Messages route", () => {
|
||||
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
|
||||
providerMetadata: { anthropic: { signature: "sig_1" } },
|
||||
})
|
||||
expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toBeUndefined()
|
||||
expect(response.message.content).toEqual([
|
||||
{ type: "text", text: "Hello!" },
|
||||
{ type: "reasoning", text: "thinking", providerMetadata: { anthropic: { signature: "sig_1" } } },
|
||||
@@ -518,6 +519,199 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("requires message_stop before completing a streamed message", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello" } },
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidProviderOutput",
|
||||
message: "Provider stream ended without a terminal finish event",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps thinking tokens and preserves unknown Anthropic usage fields", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "message_start",
|
||||
message: {
|
||||
usage: {
|
||||
input_tokens: 5,
|
||||
cache_read_input_tokens: 2,
|
||||
service_tier: "standard",
|
||||
cache_creation: { ephemeral_5m_input_tokens: 1 },
|
||||
server_tool_use: { web_search_requests: 1, start_counter: 2 },
|
||||
output_tokens_details: { thinking_tokens: 3, start_detail: "preserved" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "end_turn" },
|
||||
usage: {
|
||||
output_tokens: 8,
|
||||
server_tool_use: { web_search_requests: 2, terminal_counter: 3 },
|
||||
output_tokens_details: { terminal_detail: "preserved" },
|
||||
future_terminal: { requests: 4 },
|
||||
},
|
||||
},
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.usage).toMatchObject({
|
||||
inputTokens: 7,
|
||||
outputTokens: 8,
|
||||
reasoningTokens: 3,
|
||||
totalTokens: 15,
|
||||
providerMetadata: {
|
||||
anthropic: {
|
||||
input_tokens: 5,
|
||||
cache_read_input_tokens: 2,
|
||||
service_tier: "standard",
|
||||
cache_creation: { ephemeral_5m_input_tokens: 1 },
|
||||
server_tool_use: { web_search_requests: 2, start_counter: 2, terminal_counter: 3 },
|
||||
output_tokens: 8,
|
||||
output_tokens_details: {
|
||||
thinking_tokens: 3,
|
||||
start_detail: "preserved",
|
||||
terminal_detail: "preserved",
|
||||
},
|
||||
future_terminal: { requests: 4 },
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("round-trips omitted thinking carried only by a signature delta", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "thinking", thinking: "", signature: "" },
|
||||
},
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "sig_1" } },
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content).toEqual([
|
||||
{ type: "reasoning", text: "", providerMetadata: { anthropic: { signature: "sig_1" } } },
|
||||
])
|
||||
|
||||
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
LLM.request({ model, messages: [response.message], cache: "none" }),
|
||||
)
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "assistant", content: [{ type: "thinking", thinking: "", signature: "sig_1" }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains a thinking signature supplied in content_block_start", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "thinking", thinking: "", signature: "sig_1" },
|
||||
},
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content).toEqual([
|
||||
{ type: "reasoning", text: "", providerMetadata: { anthropic: { signature: "sig_1" } } },
|
||||
])
|
||||
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
|
||||
providerMetadata: { anthropic: { signature: "sig_1" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains complete tool input from content_block_start", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "tool_use", id: "call_1", name: "lookup", input: { query: "weather" } },
|
||||
},
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } },
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.toolCalls).toMatchObject([
|
||||
{ id: "call_1", name: "lookup", input: { query: "weather" } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains empty text blocks", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content).toEqual([{ type: "text", text: "" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses redacted thinking into empty reasoning with redactedData metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
@@ -629,6 +823,7 @@ describe("Anthropic Messages route", () => {
|
||||
delta: { stop_reason: "model_context_window_exceeded" },
|
||||
usage: { output_tokens: 1 },
|
||||
},
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -646,6 +841,7 @@ describe("Anthropic Messages route", () => {
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{ type: "message_delta", delta: { stop_reason: "pause_turn" }, usage: { output_tokens: 1 } },
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -664,6 +860,7 @@ describe("Anthropic Messages route", () => {
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: ':"weather"}' } },
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } },
|
||||
{ type: "message_stop" },
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
@@ -849,6 +1046,7 @@ describe("Anthropic Messages route", () => {
|
||||
{ type: "content_block_delta", index: 2, delta: { type: "text_delta", text: "Found it." } },
|
||||
{ type: "content_block_stop", index: 2 },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 8 } },
|
||||
{ type: "message_stop" },
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
@@ -912,6 +1110,7 @@ describe("Anthropic Messages route", () => {
|
||||
},
|
||||
{ type: "content_block_stop", index: 1 },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
|
||||
{ type: "message_stop" },
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
|
||||
@@ -939,6 +939,7 @@ describe("Bedrock Converse route", () => {
|
||||
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
cache: "none",
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: { path: "report.pdf" } })]),
|
||||
Message.tool({
|
||||
|
||||
@@ -550,7 +550,7 @@ describe("OpenAI Chat route", () => {
|
||||
prompt_tokens: 5,
|
||||
completion_tokens: 2,
|
||||
total_tokens: 7,
|
||||
prompt_tokens_details: { cached_tokens: 1 },
|
||||
prompt_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 },
|
||||
completion_tokens_details: { reasoning_tokens: 0 },
|
||||
}),
|
||||
)
|
||||
@@ -558,8 +558,9 @@ describe("OpenAI Chat route", () => {
|
||||
const usage = new Usage({
|
||||
inputTokens: 5,
|
||||
outputTokens: 2,
|
||||
nonCachedInputTokens: 4,
|
||||
nonCachedInputTokens: 2,
|
||||
cacheReadInputTokens: 1,
|
||||
cacheWriteInputTokens: 2,
|
||||
reasoningTokens: 0,
|
||||
totalTokens: 7,
|
||||
providerMetadata: {
|
||||
@@ -567,7 +568,7 @@ describe("OpenAI Chat route", () => {
|
||||
prompt_tokens: 5,
|
||||
completion_tokens: 2,
|
||||
total_tokens: 7,
|
||||
prompt_tokens_details: { cached_tokens: 1 },
|
||||
prompt_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 },
|
||||
completion_tokens_details: { reasoning_tokens: 0 },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMEvent } from "../../src"
|
||||
import { LLM, LLMEvent, Message } from "../../src"
|
||||
import { configure } from "../../src/providers/openai-compatible-responses"
|
||||
import { OpenAI } from "../../src/providers"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses"
|
||||
@@ -70,6 +70,31 @@ describe("Open Responses-compatible route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits OpenAI-only nullable phases from the Open Responses baseline", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
}).model("example-model")
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant({
|
||||
type: "text",
|
||||
text: "Unclassified.",
|
||||
providerMetadata: { openresponses: { phase: null } },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
input: [{ role: "assistant", content: [{ type: "output_text", text: "Unclassified." }] }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reads standard options from the Open Responses namespace", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
|
||||
@@ -832,7 +832,7 @@ describe("OpenAI Responses route", () => {
|
||||
input_tokens: 5,
|
||||
output_tokens: 2,
|
||||
total_tokens: 7,
|
||||
input_tokens_details: { cached_tokens: 1 },
|
||||
input_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 },
|
||||
output_tokens_details: { reasoning_tokens: 0 },
|
||||
},
|
||||
},
|
||||
@@ -842,8 +842,9 @@ describe("OpenAI Responses route", () => {
|
||||
const usage = new Usage({
|
||||
inputTokens: 5,
|
||||
outputTokens: 2,
|
||||
nonCachedInputTokens: 4,
|
||||
nonCachedInputTokens: 2,
|
||||
cacheReadInputTokens: 1,
|
||||
cacheWriteInputTokens: 2,
|
||||
reasoningTokens: 0,
|
||||
totalTokens: 7,
|
||||
providerMetadata: {
|
||||
@@ -851,7 +852,7 @@ describe("OpenAI Responses route", () => {
|
||||
input_tokens: 5,
|
||||
output_tokens: 2,
|
||||
total_tokens: 7,
|
||||
input_tokens_details: { cached_tokens: 1 },
|
||||
input_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 },
|
||||
output_tokens_details: { reasoning_tokens: 0 },
|
||||
},
|
||||
},
|
||||
@@ -881,6 +882,121 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves and replays assistant message phases", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "message", id: "msg_commentary" },
|
||||
},
|
||||
{ type: "response.output_text.delta", item_id: "msg_commentary", delta: "Checking." },
|
||||
{ type: "response.output_text.done", item_id: "msg_commentary" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_commentary", phase: "commentary" },
|
||||
},
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "message", id: "msg_final", phase: "final_answer" },
|
||||
},
|
||||
{ type: "response.output_text.done", item_id: "msg_final", text: "Finished." },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_final", phase: "final_answer" },
|
||||
},
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_null", phase: null } },
|
||||
{ type: "response.output_text.delta", item_id: "msg_null", delta: "Unclassified." },
|
||||
{ type: "response.output_item.done", item: { type: "message", id: "msg_null", phase: null } },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: "Checking.",
|
||||
providerMetadata: { openai: { phase: "commentary" } },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Finished.",
|
||||
providerMetadata: { openai: { phase: "final_answer" } },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Unclassified.",
|
||||
providerMetadata: { openai: { phase: null } },
|
||||
},
|
||||
])
|
||||
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({ model, messages: [response.message] }),
|
||||
)
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Checking." }],
|
||||
phase: "commentary",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Finished." }],
|
||||
phase: "final_answer",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Unclassified." }],
|
||||
phase: null,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects output text events without the spec-required item id", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_text.delta", delta: "orphaned" },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.message).toContain("response.output_text.delta is missing item_id")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects reasoning events without the spec-required item id", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = [
|
||||
{ type: "response.reasoning_summary_part.added", summary_index: 0 },
|
||||
{ type: "response.reasoning_summary_part.done", summary_index: 0 },
|
||||
{ type: "response.reasoning_text.done" },
|
||||
]
|
||||
|
||||
for (const event of events) {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(sseEvents(event, { type: "response.completed", response: { id: "resp_1" } })),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.message).toContain(`${event.type} is missing item_id`)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps incomplete response reasons", () =>
|
||||
Effect.gen(function* () {
|
||||
const generate = (incompleteDetails: object) =>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Content } from "@opencode-ai/schema/tool"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import {
|
||||
GenerationOptions,
|
||||
@@ -7,7 +8,6 @@ import {
|
||||
LLMRequest,
|
||||
LLMResponse,
|
||||
ToolChoice,
|
||||
ToolContent,
|
||||
ToolOutput,
|
||||
toDefinitions,
|
||||
} from "../src"
|
||||
@@ -279,7 +279,7 @@ describe("LLMClient tools", () => {
|
||||
|
||||
it.effect("models canonical tool files with URIs", () =>
|
||||
Effect.sync(() => {
|
||||
const decode = Schema.decodeUnknownSync(ToolContent)
|
||||
const decode = Schema.decodeUnknownSync(Content)
|
||||
|
||||
expect(decode({ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" })).toEqual({
|
||||
type: "file",
|
||||
@@ -539,6 +539,7 @@ describe("LLMClient tools", () => {
|
||||
},
|
||||
{ type: "content_block_stop", index: 1 },
|
||||
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 5 } },
|
||||
{ type: "message_stop" },
|
||||
)
|
||||
: sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
@@ -546,6 +547,7 @@ describe("LLMClient tools", () => {
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Done." } },
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
@@ -801,6 +803,7 @@ describe("LLMClient tools", () => {
|
||||
{ type: "content_block_delta", index: 2, delta: { type: "text_delta", text: "Done." } },
|
||||
{ type: "content_block_stop", index: 2 },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 8 } },
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
# V1 API Migration Checklist
|
||||
|
||||
The app is currently hybrid. In this document, V1 refers to the legacy unprefixed server APIs used by `@opencode-ai/sdk/v2`, despite the SDK package name.
|
||||
|
||||
## Events
|
||||
|
||||
- [x] Replace `GET /global/event` with `GET /api/event`.
|
||||
- `src/context/server-sdk.tsx`
|
||||
- [x] Reduce current granular session and message events into the existing app projections.
|
||||
- `src/context/server-session-v2-reducer.ts`
|
||||
- `src/context/server-session.ts`
|
||||
- [ ] Remove transitional session event dependencies: `session.created`, `session.updated`, `session.diff`, `session.status`, `session.idle`, and `session.error`.
|
||||
- `src/context/global-sync/event-reducer.ts`
|
||||
- `src/context/server-session.ts`
|
||||
- `src/context/notification.tsx`
|
||||
- `src/pages/session/usage-exceeded-dialogs.tsx`
|
||||
- [ ] Remove legacy message event compatibility: `message.updated`, `message.removed`, `message.part.updated`, `message.part.removed`, and `message.part.delta`.
|
||||
- `src/context/global-sync/event-reducer.ts`
|
||||
- `src/context/server-session.ts`
|
||||
- [x] Adapt current permission and question events to the existing request model.
|
||||
- `src/context/global-sync/event-reducer.ts`
|
||||
- `src/context/permission.tsx`
|
||||
- [x] Consume current file watcher events.
|
||||
- `src/context/file.tsx`
|
||||
- [x] Consume current VCS events.
|
||||
- `src/context/global-sync/event-reducer.ts`
|
||||
- `src/pages/session.tsx`
|
||||
- [x] Consume current `pty.exited` events.
|
||||
- `src/context/terminal.tsx`
|
||||
- [ ] Migrate LSP and reference events.
|
||||
- `src/context/global-sync/event-reducer.ts`
|
||||
|
||||
## Sessions
|
||||
|
||||
- [x] Replace `GET /session/status` with one server-scoped `GET /api/session/active` snapshot plus V2 execution events.
|
||||
- `src/context/server-sync.tsx`
|
||||
- [x] Migrate session listing from `GET /session`.
|
||||
- `src/context/server-sync.tsx`
|
||||
- `src/context/directory-sync.ts`
|
||||
- `src/pages/layout.tsx`
|
||||
- [x] Migrate the remaining direct session read from `GET /session/:sessionID`.
|
||||
- `src/components/titlebar.tsx`
|
||||
- [x] Migrate session updates from `PATCH /session/:sessionID`.
|
||||
- `src/context/directory-sync.ts`
|
||||
- `src/context/layout.tsx`
|
||||
- `src/pages/home.tsx`
|
||||
- `src/pages/layout.tsx`
|
||||
- `src/pages/session/timeline/message-timeline.tsx`
|
||||
- `src/components/titlebar-tab-nav.tsx`
|
||||
- Renames use `POST /api/session/:sessionID/rename`; archival uses `POST /api/session/:sessionID/archive`.
|
||||
- [x] Migrate session deletion from `DELETE /session/:sessionID`.
|
||||
- `src/pages/session/timeline/message-timeline.tsx`
|
||||
- [x] Remove session diff loading from `GET /session/:sessionID/diff`.
|
||||
- Historical Session diffs remain unavailable until the current API defines their snapshot semantics.
|
||||
- [x] Migrate abort from `POST /session/:sessionID/abort`.
|
||||
- `src/components/prompt-input/submit.ts`
|
||||
- `src/pages/session/use-session-commands.tsx`
|
||||
- `src/pages/session.tsx`
|
||||
- [x] Migrate revert and unrevert from `POST /session/:sessionID/revert` and `POST /session/:sessionID/unrevert`.
|
||||
- `src/pages/session/use-session-commands.tsx`
|
||||
- `src/pages/session.tsx`
|
||||
- [x] Replace `POST /session/:sessionID/summarize` with the current compact API.
|
||||
- `src/pages/session/use-session-commands.tsx`
|
||||
- [x] Migrate slash commands from `POST /session/:sessionID/command`.
|
||||
- `src/components/prompt-input/submit.ts`
|
||||
- [x] Migrate shell execution from `POST /session/:sessionID/shell`.
|
||||
- `src/components/prompt-input/submit.ts`
|
||||
- [x] Migrate session fork from `POST /session/:sessionID/fork`.
|
||||
- `src/components/dialog-fork.tsx`
|
||||
- [ ] Migrate sharing from `POST /session/:sessionID/share` and `DELETE /session/:sessionID/share`.
|
||||
- `src/pages/session/use-session-commands.tsx`
|
||||
- `src/pages/session/timeline/message-timeline.tsx`
|
||||
- Blocked: the current API has no sharing contract or implementation.
|
||||
|
||||
## Session Compatibility Fallbacks
|
||||
|
||||
These calls are retained as fallback adapters. The current production path supplies the current session and message APIs.
|
||||
|
||||
- [ ] Remove fallback `GET /session/:sessionID` after compatibility support is unnecessary.
|
||||
- `src/context/server-session.ts`
|
||||
- [ ] Remove fallback `GET /session/:sessionID/message` after compatibility support is unnecessary.
|
||||
- `src/context/server-session.ts`
|
||||
- [ ] Remove fallback `GET /session/:sessionID/message/:messageID` after compatibility support is unnecessary.
|
||||
- `src/context/server-session.ts`
|
||||
|
||||
## Filesystem
|
||||
|
||||
- [ ] Migrate file listing from `GET /file`.
|
||||
- `src/context/file.tsx`
|
||||
- [ ] Migrate file reads from `GET /file/content`.
|
||||
- `src/context/file.tsx`
|
||||
- `src/pages/session/review-tab.tsx`
|
||||
- `src/pages/session/v2/review-panel-v2.tsx`
|
||||
- [x] Migrate path discovery from `GET /path` to `GET /api/path`.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
- `src/components/dialog-select-directory.tsx`
|
||||
- `src/components/dialog-select-directory-v2.tsx`
|
||||
|
||||
## Projects And Worktrees
|
||||
|
||||
- [x] Migrate project listing from `GET /project` to `GET /api/project`.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
- [x] Migrate the current project lookup from `GET /project/current` to `GET /api/project/current`.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
- [ ] Migrate Git initialization from `POST /project/git/init`.
|
||||
- `src/pages/session.tsx`
|
||||
- [x] Migrate project updates from `PATCH /project/:projectID` to `PATCH /api/project/:projectID`.
|
||||
- `src/context/layout.tsx`
|
||||
- `src/components/edit-project.ts`
|
||||
- `src/pages/layout.tsx`
|
||||
- [ ] Migrate experimental worktree listing, creation, removal, and reset from `/experimental/worktree`.
|
||||
- `src/pages/layout.tsx`
|
||||
- `src/components/prompt-input/submit.ts`
|
||||
- Listing now uses `GET /api/project/:projectID/directories`; create, removal, and reset remain.
|
||||
- [ ] Migrate instance disposal from `POST /instance/dispose`.
|
||||
- `src/pages/layout.tsx`
|
||||
|
||||
## VCS
|
||||
|
||||
- [x] Migrate repository information from `GET /vcs` to `GET /api/vcs`.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
- [x] Migrate diffs from `GET /vcs/diff` to `GET /api/vcs/diff`.
|
||||
- `src/pages/session.tsx`
|
||||
- [x] Migrate status from `GET /vcs/status` to `GET /api/vcs/status`.
|
||||
- `src/pages/layout.tsx`
|
||||
|
||||
## Configuration And Authentication
|
||||
|
||||
- [ ] Migrate global configuration reads from `GET /global/config`.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
- [ ] Migrate directory configuration reads from `GET /config`.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
- [ ] Migrate global configuration updates from `PATCH /global/config`.
|
||||
- `src/context/server-sync.tsx`
|
||||
- [x] Migrate provider authentication method discovery from `GET /provider/auth` to `GET /api/integration/:integrationID`.
|
||||
- `src/components/dialog-connect-provider.tsx`
|
||||
- [x] Migrate built-in provider OAuth authorization and callbacks to `/api/integration/:integrationID/connect/oauth/*`.
|
||||
- `src/components/dialog-connect-provider.tsx`
|
||||
- [ ] Migrate remaining credentials from `PUT /auth/:providerID` and `DELETE /auth/:providerID`.
|
||||
- Built-in provider key connections now use `POST /api/integration/:integrationID/connect/key`.
|
||||
- `src/components/dialog-connect-provider.tsx`
|
||||
- `src/components/dialog-custom-provider.tsx`
|
||||
- `src/components/settings-providers.tsx`
|
||||
- `src/components/settings-v2/providers.tsx`
|
||||
- [ ] Migrate global disposal from `POST /global/dispose`.
|
||||
- `src/components/dialog-connect-provider.tsx`
|
||||
- `src/components/settings-providers.tsx`
|
||||
- `src/components/settings-v2/providers.tsx`
|
||||
|
||||
## Permissions And Questions
|
||||
|
||||
- [x] Migrate permission listing from `GET /permission` to `GET /api/permission/request`.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
- `src/context/permission.tsx`
|
||||
- [x] Migrate permission responses from `/session/:sessionID/permissions/:permissionID`.
|
||||
- `src/context/permission.tsx`
|
||||
- `src/pages/session/composer/session-composer-state.ts`
|
||||
- [x] Migrate question listing from `GET /question` to `GET /api/question/request`.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
- [x] Migrate question replies and rejections from `/question/:requestID/*` to `/api/session/:sessionID/question/:requestID/*`.
|
||||
- `src/pages/session/composer/session-question-dock.tsx`
|
||||
|
||||
## Commands, MCP, LSP, And References
|
||||
|
||||
- [x] Migrate command listing from `GET /command` to `GET /api/command`.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
- `src/context/server-sync.tsx`
|
||||
- [x] Migrate MCP listing, connection, and disconnection from `/mcp` to `/api/mcp`.
|
||||
- `src/context/server-sync.tsx`
|
||||
- [ ] Replace legacy MCP authentication with the Integration OAuth workflow.
|
||||
- `src/context/server-sync.tsx`
|
||||
- [x] Migrate experimental resource listing from `GET /experimental/resource` to `GET /api/mcp/resource`.
|
||||
- `src/context/server-sync.tsx`
|
||||
- [ ] Migrate LSP status from `GET /lsp`.
|
||||
- `src/context/server-sync.tsx`
|
||||
- [x] Move `GET /api/reference` off the legacy generated SDK transport.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
|
||||
## Search
|
||||
|
||||
- [x] Migrate global session search from `GET /experimental/session` to `GET /api/session`.
|
||||
- `src/components/command-palette.ts`
|
||||
- `src/components/dialog-command-palette-v2.tsx`
|
||||
|
||||
## PTY And Terminal
|
||||
|
||||
- [x] Migrate PTY creation, reads, updates, and deletion from `/pty` to `/api/pty`.
|
||||
- `src/context/terminal.tsx`
|
||||
- `src/components/terminal.tsx`
|
||||
- [x] Migrate shell listing from `GET /pty/shells` to `GET /api/pty/shells`.
|
||||
- `src/components/settings-general.tsx`
|
||||
- `src/components/settings-v2/general.tsx`
|
||||
- [x] Migrate connection tokens from `POST /pty/:ptyID/connect-token` to `POST /api/pty/:ptyID/connect-token`.
|
||||
- `src/components/terminal.tsx`
|
||||
- [x] Migrate the direct WebSocket connection from `/pty/:ptyID/connect` to `/api/pty/:ptyID/connect`.
|
||||
- `src/components/terminal.tsx`
|
||||
|
||||
## Legacy Types And Adapters
|
||||
|
||||
These are not V1 network requests, but they keep the UI coupled to V1 data contracts.
|
||||
|
||||
- [ ] Replace the current-session-to-legacy-session adapter.
|
||||
- `src/utils/session.ts`
|
||||
- [ ] Replace the current-message-to-legacy-message-and-part adapter.
|
||||
- `src/utils/session-message.ts`
|
||||
- [ ] Replace current agent, provider, and model adapters to legacy SDK structures.
|
||||
- `src/context/global-sync/utils.ts`
|
||||
- [ ] Replace legacy `Session`, `Message`, `Part`, `PermissionRequest`, `QuestionRequest`, `Project`, `FileNode`, `FileDiffInfo`, and `Event` types throughout app state and rendering.
|
||||
- [ ] Remove the `@opencode-ai/sdk` runtime dependency after all legacy calls and types are gone.
|
||||
- `package.json`
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
- [ ] Replace V1 endpoint mocks with current API mocks.
|
||||
- `e2e/utils/mock-server.ts`
|
||||
- [x] Replace `/global/event` and `/event` interception with current event transport handling.
|
||||
- `e2e/utils/sse-transport.ts`
|
||||
- [ ] Replace `SessionV1` and legacy SDK fixtures in timeline performance tests.
|
||||
- `e2e/performance/timeline-stability/fixture.ts`
|
||||
- [ ] Remove remaining legacy SDK type fixtures from unit and browser tests.
|
||||
@@ -20,7 +20,7 @@ const profiles = [
|
||||
{ name: "edit", tool: "edit", input: { filePath: "src/edit.ts" } },
|
||||
{
|
||||
name: "multi patch",
|
||||
tool: "patch",
|
||||
tool: "apply_patch",
|
||||
input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] },
|
||||
},
|
||||
] as const
|
||||
|
||||
@@ -25,7 +25,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[
|
||||
toolPart(patchID, "patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }),
|
||||
toolPart(patchID, "apply_patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }),
|
||||
textPart(followingID, "Following incremental patch"),
|
||||
],
|
||||
{ completed: false },
|
||||
@@ -49,7 +49,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
|
||||
partUpdated(
|
||||
toolPart(
|
||||
patchID,
|
||||
"patch",
|
||||
"apply_patch",
|
||||
"running",
|
||||
{ files: [first.filePath, second.filePath] },
|
||||
{ metadata: { files: [first, second] } },
|
||||
@@ -61,7 +61,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
|
||||
partUpdated(
|
||||
toolPart(
|
||||
patchID,
|
||||
"patch",
|
||||
"apply_patch",
|
||||
"completed",
|
||||
{ files: [first.filePath, second.filePath, third.filePath] },
|
||||
{ metadata: { files: [first, second, third] } },
|
||||
|
||||
@@ -33,9 +33,11 @@ test.describe("timeline tool state stability", () => {
|
||||
}
|
||||
const names = { webfetch: "webfetch", websearch: "websearch", task: "task", skill: "skill", custom: "mcp_probe" }
|
||||
const questionID = "prt_state_question"
|
||||
const todoID = "prt_state_todo"
|
||||
const initial = [
|
||||
...ids.map((id) => toolPart(`prt_state_${id}`, names[id], "pending", inputs[id])),
|
||||
toolPart(questionID, "question", "pending", questionInput()),
|
||||
toolPart(todoID, "todowrite", "pending", { todos: [{ content: "Hidden", status: "pending" }] }),
|
||||
textPart("prt_state_following", "Following lightweight tools"),
|
||||
]
|
||||
const childID = "ses_timeline_child"
|
||||
@@ -47,6 +49,7 @@ test.describe("timeline tool state stability", () => {
|
||||
await timeline.send(status("busy"), 120)
|
||||
for (const id of ids) await timeline.waitForPart(`prt_state_${id}`)
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
|
||||
|
||||
const regionIDs = [
|
||||
"prt_state_webfetch",
|
||||
@@ -102,6 +105,7 @@ test.describe("timeline tool state stability", () => {
|
||||
]),
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText("Keep it stable")
|
||||
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
|
||||
await expect(
|
||||
page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
|
||||
).toBeVisible()
|
||||
|
||||
@@ -41,7 +41,12 @@ const assistants = Array.from({ length: 14 }, (_, index) => {
|
||||
const messages = [user, ...assistants]
|
||||
const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
|
||||
const lastID = userID
|
||||
const lastPartID = assistants.at(-1)!.parts.at(-1)!.id
|
||||
const lastAssistant = assistants.at(-1)!
|
||||
const lastPart = lastAssistant.parts.at(-1)!
|
||||
const lastPartID =
|
||||
lastPart.type === "tool"
|
||||
? lastPart.id
|
||||
: `${lastAssistant.info.id}:${lastPart.type}:${lastAssistant.parts.filter((part) => part.type === lastPart.type).length - 1}`
|
||||
|
||||
benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
|
||||
benchmark.setTimeout(180_000)
|
||||
@@ -107,9 +112,25 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
|
||||
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
|
||||
},
|
||||
})
|
||||
await page.route(`**/session/${fixture.targetID}`, (route) =>
|
||||
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(target) }),
|
||||
)
|
||||
await page.route(`**/session/${fixture.targetID}`, (route) => {
|
||||
const current = new URL(route.request().url()).pathname.startsWith("/api/")
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
current
|
||||
? {
|
||||
data: {
|
||||
...target,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
location: { directory: target.directory },
|
||||
},
|
||||
}
|
||||
: target,
|
||||
),
|
||||
})
|
||||
})
|
||||
await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
@@ -144,8 +165,8 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
|
||||
parent: requests.filter((request) => request.type === "parent").length,
|
||||
}
|
||||
if (mode === "candidate") {
|
||||
expect(requestCounts.parent).toBe(1)
|
||||
expect(historyGates).toBe(1)
|
||||
expect(requestCounts.parent).toBe(0)
|
||||
expect(historyGates).toBe(0)
|
||||
}
|
||||
return { metrics, requestCounts, historyGateCount: historyGates }
|
||||
}
|
||||
|
||||
@@ -295,7 +295,7 @@ function performanceTurn(index: number) {
|
||||
messageID: assistantID,
|
||||
type: "tool",
|
||||
callID: `call_0000_${suffix}_patch`,
|
||||
tool: "patch",
|
||||
tool: "apply_patch",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { patchText: realisticPatch(index) },
|
||||
|
||||
@@ -131,7 +131,7 @@ function toolPart(
|
||||
): MessagePart {
|
||||
const metadata =
|
||||
metadataOverride ??
|
||||
(tool === "patch"
|
||||
(tool === "apply_patch"
|
||||
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
|
||||
: tool === "edit" || tool === "write"
|
||||
? {
|
||||
@@ -219,7 +219,7 @@ function turn(index: number): Message[] {
|
||||
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
|
||||
: []),
|
||||
...(index % 8 === 0
|
||||
? [toolPart(index, 8, "patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
||||
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
||||
: []),
|
||||
...(index % 7 === 0
|
||||
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
|
||||
@@ -269,6 +269,7 @@ const childMessages = Array.from({ length: 4 }, (_, index) => [
|
||||
]).flat()
|
||||
|
||||
function renderable(part: MessagePart) {
|
||||
if (part.type === "tool" && part.tool === "todowrite") return false
|
||||
if (part.type === "text") return !!part.text.trim()
|
||||
if (part.type === "reasoning") return !!part.text.trim()
|
||||
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
|
||||
const serverA = "http://127.0.0.1:4096"
|
||||
const serverB = "http://127.0.0.1:4097"
|
||||
@@ -33,7 +34,7 @@ test("closing the active server's last tab opens the remaining server tab", asyn
|
||||
await tabA.locator('[data-slot="tab-close"] button').click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||
await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/session/${sessionB.id}`))).toBe(true)
|
||||
await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/api/session/${sessionB.id}`))).toBe(true)
|
||||
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
||||
const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
|
||||
expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true)
|
||||
@@ -84,17 +85,21 @@ async function mockServers(page: Page, requests: string[]) {
|
||||
const current = url.origin === serverA ? sessionA : sessionB
|
||||
const directory = url.searchParams.get("directory")
|
||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
|
||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
||||
if (url.pathname === "/session") return json(route, [current])
|
||||
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
||||
return sse(route)
|
||||
if (url.pathname === "/global/health") return json(route, {}, 404)
|
||||
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
||||
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
||||
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
|
||||
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
|
||||
return json(route, {})
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||
if (url.pathname === "/provider")
|
||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||
@@ -116,7 +121,20 @@ async function mockServers(page: Page, requests: string[]) {
|
||||
directory: current.directory,
|
||||
home: current.directory,
|
||||
})
|
||||
if (url.pathname === "/api/path")
|
||||
return json(route, {
|
||||
state: current.directory,
|
||||
config: current.directory,
|
||||
worktree: current.directory,
|
||||
directory: current.directory,
|
||||
home: current.directory,
|
||||
})
|
||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, {
|
||||
location: { directory: current.directory },
|
||||
data: { branch: "main", defaultBranch: "main" },
|
||||
})
|
||||
return json(route, {})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/OpenFileExpand"
|
||||
const projectID = "proj_open_file_expand"
|
||||
const sessionID = "ses_open_file_expand"
|
||||
const title = "Open file expand"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
test("expands a folder whose path has a trailing Windows separator", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "open-file-expand",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: sessionID,
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
vcsDiff: [],
|
||||
fileList: (path) => {
|
||||
if (path === "frontend\\" || path === "frontend") {
|
||||
return [
|
||||
{
|
||||
name: "app.ts",
|
||||
path: "frontend\\app.ts",
|
||||
absolute: `${directory}/frontend/app.ts`,
|
||||
type: "file" as const,
|
||||
ignored: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
if (path) return []
|
||||
return [
|
||||
{
|
||||
name: "frontend",
|
||||
path: "frontend\\",
|
||||
absolute: `${directory}/frontend`,
|
||||
type: "directory" as const,
|
||||
ignored: false,
|
||||
},
|
||||
{
|
||||
name: "README.md",
|
||||
path: "README.md",
|
||||
absolute: `${directory}/README.md`,
|
||||
type: "file" as const,
|
||||
ignored: false,
|
||||
},
|
||||
]
|
||||
},
|
||||
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessionID }) => {
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({ general: { newLayoutDesigns: true, shouldDisplayTabsToast: false } }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:layout",
|
||||
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:review-panel-v2",
|
||||
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||
)
|
||||
},
|
||||
{ directory, server, sessionID },
|
||||
)
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
const panel = page.locator("#review-panel")
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||
|
||||
const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]')
|
||||
await expect(sidebar).toBeVisible()
|
||||
|
||||
const frontendRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend"]')
|
||||
await expect(frontendRow).toBeVisible()
|
||||
await expect(frontendRow).toHaveAttribute("aria-expanded", "false")
|
||||
await frontendRow.click()
|
||||
await expect(frontendRow).toHaveAttribute("aria-expanded", "true")
|
||||
|
||||
const appRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend/app.ts"]')
|
||||
await expect(appRow).toBeVisible()
|
||||
await appRow.click()
|
||||
await expect(panel.getByRole("tab", { name: "app.ts" })).toHaveAttribute("data-selected", "")
|
||||
await expect(panel.getByText("contents:frontend/app.ts", { exact: true })).toBeVisible()
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
|
||||
const serverA = "http://127.0.0.1:4096"
|
||||
const serverB = "http://127.0.0.1:4097"
|
||||
@@ -17,7 +18,7 @@ test("session settings use the remote server context", async ({ page }) => {
|
||||
|
||||
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
|
||||
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
||||
await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,")
|
||||
await page.keyboard.press("Control+,")
|
||||
|
||||
const dialog = page.locator(".settings-v2-dialog")
|
||||
const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]')
|
||||
@@ -58,7 +59,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
||||
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
|
||||
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
|
||||
await expect(page.getByText(sessionA.title).first()).toBeVisible()
|
||||
await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,")
|
||||
await page.keyboard.press("Control+,")
|
||||
const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]')
|
||||
await autoAccept.locator('[data-slot="switch-control"]').click()
|
||||
await expect(autoAccept.getByRole("switch")).toBeChecked()
|
||||
@@ -180,10 +181,36 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
||||
return json(route, true)
|
||||
}
|
||||
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
|
||||
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
||||
return sse(route)
|
||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
||||
if (url.pathname === "/session/status") return json(route, {})
|
||||
if (url.pathname === "/session") return json(route, sessions)
|
||||
if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent")
|
||||
return json(route, { data: [] })
|
||||
if (url.pathname === "/api/model/default") return json(route, { data: null })
|
||||
if (["/api/command", "/api/reference", "/api/permission/request", "/api/question/request"].includes(url.pathname))
|
||||
return json(route, { location: { directory }, data: [] })
|
||||
if (url.pathname === "/api/mcp") return json(route, { location: { directory }, data: [] })
|
||||
if (url.pathname === "/api/mcp/resource")
|
||||
return json(route, { location: { directory }, data: { resources: [], templates: [] } })
|
||||
if (url.pathname === "/api/project") {
|
||||
return json(route, [
|
||||
{
|
||||
id: remote ? sessionB.projectID : "project-server-a",
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
},
|
||||
])
|
||||
}
|
||||
if (url.pathname === "/api/project/current")
|
||||
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory })
|
||||
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||
const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`)
|
||||
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
||||
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
|
||||
return json(route, { data: [], cursor: {} })
|
||||
const current = sessions.find((session) => url.pathname === `/session/${session.id}`)
|
||||
if (current) return json(route, current)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
@@ -216,7 +243,12 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
||||
directory,
|
||||
home: directory,
|
||||
})
|
||||
if (url.pathname === "/api/path")
|
||||
return json(route, { state: directory, config: directory, worktree: directory, directory, home: directory })
|
||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
|
||||
if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] })
|
||||
return json(route, {})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
|
||||
const serverA = "http://127.0.0.1:4096"
|
||||
const serverB = "http://127.0.0.1:4097"
|
||||
@@ -57,15 +58,19 @@ async function mockServers(page: Page) {
|
||||
const current = url.origin === serverA ? sessionA : sessionB
|
||||
const directory = url.searchParams.get("directory")
|
||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
|
||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
||||
if (url.pathname === "/session/status")
|
||||
return json(route, url.origin === serverB ? { [sessionB.id]: { type: "busy" } } : {})
|
||||
if (url.pathname === "/session") return json(route, [current])
|
||||
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
||||
return sse(route, url.pathname === "/api/event")
|
||||
if (url.pathname === "/global/health") return json(route, {}, 404)
|
||||
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
||||
if (url.pathname === "/api/session/active")
|
||||
return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
|
||||
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
||||
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
||||
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
|
||||
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||
@@ -90,7 +95,20 @@ async function mockServers(page: Page) {
|
||||
directory: current.directory,
|
||||
home: current.directory,
|
||||
})
|
||||
if (url.pathname === "/api/path")
|
||||
return json(route, {
|
||||
state: current.directory,
|
||||
config: current.directory,
|
||||
worktree: current.directory,
|
||||
directory: current.directory,
|
||||
home: current.directory,
|
||||
})
|
||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, {
|
||||
location: { directory: current.directory },
|
||||
data: { branch: "main", defaultBranch: "main" },
|
||||
})
|
||||
return json(route, {})
|
||||
})
|
||||
}
|
||||
@@ -104,6 +122,10 @@ function json(route: Route, body: unknown, status = 200) {
|
||||
})
|
||||
}
|
||||
|
||||
function sse(route: Route) {
|
||||
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
|
||||
function sse(route: Route, current: boolean) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: current ? 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n' : ": ok\n\n",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ test("stages a submitted line comment in the prompt context", async ({ page }) =
|
||||
async function openReview(page: Page) {
|
||||
await page.setViewportSize({ width: 700, height: 900 })
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_review_line_comment_regression",
|
||||
@@ -143,9 +144,9 @@ async function openReview(page: Page) {
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/vcs/diff")
|
||||
const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/vcs/diff")
|
||||
await page.getByRole("tab", { name: "Changes" }).click()
|
||||
expect(await (await diffResponse).json()).toHaveLength(1)
|
||||
expect((await (await diffResponse).json()).data).toHaveLength(1)
|
||||
|
||||
const review = page.locator('[data-component="session-review"]')
|
||||
await expectAppVisible(review)
|
||||
|
||||
@@ -133,7 +133,7 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
|
||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||
await expect(sidebarToggle).toBeDisabled()
|
||||
await panel.getByRole("tab", { name: /Review/ }).click()
|
||||
await panel.locator("#session-side-panel-review-tab").click()
|
||||
await expect(sidebarToggle).toBeEnabled()
|
||||
await panel.getByRole("tab", { name: "Open file" }).click()
|
||||
await page.keyboard.press("Control+w")
|
||||
|
||||
@@ -46,7 +46,7 @@ test("restores review mode and selected file per session", async ({ page }) => {
|
||||
|
||||
async function selectMode(page: Page, current: string, next: string) {
|
||||
await page.getByRole("button", { name: current }).click()
|
||||
await page.getByRole("option", { name: next }).click()
|
||||
await page.getByRole("option", { name: next }).dispatchEvent("click")
|
||||
}
|
||||
|
||||
async function selectFile(page: Page, file: string) {
|
||||
@@ -65,6 +65,7 @@ async function switchSession(page: Page, title: string) {
|
||||
|
||||
async function setup(page: Page) {
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v1",
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
|
||||
@@ -20,10 +20,12 @@ const branchDiffs = [
|
||||
test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => {
|
||||
test.setTimeout(120_000)
|
||||
const events: Array<{ directory: string; payload: Record<string, unknown> }> = []
|
||||
const sessionStatus = { [sessionID]: { type: "idle" as "busy" | "idle" } }
|
||||
let detailVersion = 1
|
||||
let detailFailures = 1
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v1",
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
@@ -55,7 +57,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
sessionStatus: { [sessionID]: { type: "idle" } },
|
||||
sessionStatus: () => sessionStatus,
|
||||
pageMessages: () => ({ items: [] }),
|
||||
events: () => events.splice(0, 1),
|
||||
eventRetry: 16,
|
||||
@@ -64,7 +66,10 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ branch: "review-pane-performance", default_branch: "dev" }),
|
||||
body: JSON.stringify({
|
||||
branch: "review-pane-performance",
|
||||
default_branch: "dev",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await page.route("**/vcs/diff**", (route) => {
|
||||
@@ -86,15 +91,51 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
),
|
||||
})
|
||||
})
|
||||
await page.route("**/pty", (route) =>
|
||||
await page.route("**/pty*", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: "pty_review_terminal", title: "Terminal 1" }),
|
||||
body: JSON.stringify({
|
||||
location: { directory, project: { id: projectID, directory } },
|
||||
data: {
|
||||
id: "pty_review_terminal",
|
||||
title: "Terminal 1",
|
||||
command: "cmd.exe",
|
||||
args: [],
|
||||
cwd: directory,
|
||||
status: "running",
|
||||
pid: 1,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await page.route("**/pty/pty_review_terminal", (route) =>
|
||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
||||
await page.route("**/pty/pty_review_terminal*", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
location: { directory, project: { id: projectID, directory } },
|
||||
data: {
|
||||
id: "pty_review_terminal",
|
||||
title: "Terminal 1",
|
||||
command: "cmd.exe",
|
||||
args: [],
|
||||
cwd: directory,
|
||||
status: "running",
|
||||
pid: 1,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await page.route("**/pty/pty_review_terminal/connect-token*", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
location: { directory, project: { id: projectID, directory } },
|
||||
data: { ticket: "e2e-ticket", expires_in: 60 },
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined)
|
||||
await page.addInitScript(() => {
|
||||
@@ -143,6 +184,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]')
|
||||
await expect(preview).toContainText("after-1")
|
||||
detailVersion = 2
|
||||
sessionStatus[sessionID] = { type: "busy" }
|
||||
events.push(statusEvent("busy"))
|
||||
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||
const refreshedDiff = page.waitForRequest((request) => {
|
||||
@@ -152,6 +194,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
||||
)
|
||||
})
|
||||
sessionStatus[sessionID] = { type: "idle" }
|
||||
events.push(statusEvent("idle"))
|
||||
await refreshedDiff
|
||||
await expect(preview).toContainText("after-2")
|
||||
|
||||
@@ -16,8 +16,8 @@ test("shows loaded sessions before the directory path request resolves", async (
|
||||
const pathBlocked = new Promise<void>((resolve) => {
|
||||
releasePath = resolve
|
||||
})
|
||||
await page.route("**/path?*", async (route) => {
|
||||
if (!new URL(route.request().url()).searchParams.has("directory")) return route.fallback()
|
||||
await page.route("**/api/path?*", async (route) => {
|
||||
if (!new URL(route.request().url()).searchParams.has("location[directory]")) return route.fallback()
|
||||
await pathBlocked
|
||||
return route.fallback()
|
||||
})
|
||||
|
||||
@@ -42,7 +42,8 @@ test("shows a pending question dock", async ({ page }) => {
|
||||
const rejectRequests: string[] = []
|
||||
page.on("request", (request) => {
|
||||
if (request.method() !== "POST") return
|
||||
if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url())
|
||||
if (new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reject`)
|
||||
rejectRequests.push(request.url())
|
||||
})
|
||||
|
||||
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
|
||||
@@ -64,7 +65,9 @@ test("shows a pending question dock", async ({ page }) => {
|
||||
|
||||
await question.getByRole("radio", { name: /Minimal/ }).click()
|
||||
const reply = page.waitForRequest(
|
||||
(request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply",
|
||||
(request) =>
|
||||
request.method() === "POST" &&
|
||||
new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reply`,
|
||||
)
|
||||
await question.getByRole("button", { name: "Submit" }).click()
|
||||
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
|
||||
@@ -97,8 +100,8 @@ test("shows a pending permission dock", async ({ page }) => {
|
||||
const reply = page.waitForRequest((request) => request.method() === "POST")
|
||||
await permission.getByRole("button", { name: "Allow once" }).click()
|
||||
const request = await reply
|
||||
expect(new URL(request.url()).pathname).toBe(`/session/${sessionID}/permissions/permission-request`)
|
||||
expect(request.postDataJSON()).toEqual({ response: "once" })
|
||||
expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`)
|
||||
expect(request.postDataJSON()).toEqual({ reply: "once" })
|
||||
})
|
||||
|
||||
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
|
||||
@@ -170,6 +173,7 @@ async function mockServer(
|
||||
},
|
||||
) {
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
|
||||
@@ -24,7 +24,7 @@ test("renders a completed single-file patch", async ({ page }) => {
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
id,
|
||||
"patch",
|
||||
"apply_patch",
|
||||
"completed",
|
||||
{ files: ["src/a.ts"] },
|
||||
{
|
||||
|
||||
@@ -35,7 +35,7 @@ test("preserves nested patch file state through outer collapse and reopen", asyn
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
patchID,
|
||||
"patch",
|
||||
"apply_patch",
|
||||
"completed",
|
||||
{ files: files.map((file) => file.filePath) },
|
||||
{ metadata: { files } },
|
||||
|
||||
@@ -35,6 +35,7 @@ test.describe("session timeline projection", () => {
|
||||
editPart("prt_edit"),
|
||||
toolPart("prt_write", "write", "completed", { filePath: "src/new.ts", content: "export const stable = true\n" }),
|
||||
patchPart("prt_patch"),
|
||||
toolPart("prt_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }),
|
||||
toolPart(
|
||||
"prt_question",
|
||||
"question",
|
||||
@@ -64,6 +65,7 @@ test.describe("session timeline projection", () => {
|
||||
]) {
|
||||
await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible()
|
||||
}
|
||||
await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {
|
||||
@@ -247,7 +249,7 @@ function editPart(id: string) {
|
||||
function patchPart(id: string) {
|
||||
return toolPart(
|
||||
id,
|
||||
"patch",
|
||||
"apply_patch",
|
||||
"completed",
|
||||
{ files: ["src/a.ts", "src/b.ts"] },
|
||||
{
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("renders every tool error outcome without leaking hidden tools", async ({ page }) => {
|
||||
const ordinary = ["bash", "edit", "write", "patch", "webfetch", "websearch", "task", "skill", "mcp_probe"]
|
||||
const ordinary = ["bash", "edit", "write", "apply_patch", "webfetch", "websearch", "task", "skill", "mcp_probe"]
|
||||
const parts = ordinary.map((tool, index) =>
|
||||
toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }),
|
||||
)
|
||||
@@ -17,11 +17,13 @@ test("renders every tool error outcome without leaking hidden tools", async ({ p
|
||||
error: "The user dismissed this question",
|
||||
}),
|
||||
toolPart("prt_question_error", "question", "error", questionInput(), { error: "Question transport failed" }),
|
||||
toolPart("prt_todo_error", "todowrite", "error", { todos: [] }, { error: "Hidden todo failure" }),
|
||||
)
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(ordinary.length + 1)
|
||||
await expect(page.getByText(/dismissed/i)).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-part-id="prt_todo_error"]')).toHaveCount(0)
|
||||
for (let index = 0; index < ordinary.length; index++) {
|
||||
await expect(page.locator(`[data-timeline-part-id="prt_error_${index}"]`)).toBeVisible()
|
||||
}
|
||||
@@ -88,7 +90,7 @@ function questionInput() {
|
||||
function errorInput(tool: string) {
|
||||
if (tool === "bash") return { command: "exit 1" }
|
||||
if (["edit", "write"].includes(tool)) return { filePath: "src/error.ts", content: "" }
|
||||
if (tool === "patch") return { files: ["src/error.ts"] }
|
||||
if (tool === "apply_patch") return { files: ["src/error.ts"] }
|
||||
if (tool === "webfetch") return { url: "https://example.com" }
|
||||
if (tool === "websearch") return { query: "failure" }
|
||||
if (tool === "task") return { description: "Fail task", subagent_type: "explore" }
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/TodoDockNavigation"
|
||||
const projectID = "proj_todo_dock_navigation"
|
||||
const sourceID = "ses_todo_dock_source"
|
||||
const otherID = "ses_todo_dock_other"
|
||||
const sourceTitle = "Todo dock animation"
|
||||
const otherTitle = "Separate session"
|
||||
|
||||
const activeTodos = [
|
||||
{ id: "todo-1", content: "Receive todos in the active session", status: "completed", priority: "high" },
|
||||
{ id: "todo-2", content: "Keep the dock visible across tabs", status: "completed", priority: "high" },
|
||||
{ id: "todo-3", content: "Close after the final todo", status: "in_progress", priority: "high" },
|
||||
]
|
||||
|
||||
type EventPayload = {
|
||||
directory: string
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" })
|
||||
|
||||
test("animates todo lifecycle without replaying it across session tabs", async ({ page }) => {
|
||||
test.setTimeout(90_000)
|
||||
const events: EventPayload[] = []
|
||||
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
|
||||
const sessionStatus: Record<string, { type: "busy" | "idle" }> = {}
|
||||
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "todo-dock-navigation",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: {
|
||||
"claude-opus-4-6": {
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
limit: { context: 200_000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||
},
|
||||
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
|
||||
sessionStatus: { [sourceID]: { type: "busy" } },
|
||||
pageMessages: () => ({ items: [] }),
|
||||
events: () => events.splice(0, 1),
|
||||
eventRetry: 16,
|
||||
sessionStatus: () => sessionStatus,
|
||||
todos: (sessionID) => todos[sessionID] ?? [],
|
||||
})
|
||||
await configurePage(page)
|
||||
|
||||
await page.goto(sessionHref(sourceID))
|
||||
await expectSessionTitle(page, sourceTitle)
|
||||
const dock = page.locator('[data-component="session-todo-dock"]')
|
||||
await expect(dock).toHaveCount(0)
|
||||
|
||||
sessionStatus[sourceID] = { type: "busy" }
|
||||
events.push(statusEvent(sourceID, "busy"))
|
||||
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||
|
||||
await page.waitForTimeout(700)
|
||||
const opening = sampleDock(page, 1_000)
|
||||
todos[sourceID] = activeTodos
|
||||
events.push(todoEvent(sourceID, activeTodos))
|
||||
await expect(dock).toBeVisible()
|
||||
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
|
||||
expect((await opening).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
|
||||
|
||||
await switchSession(page, otherID, otherTitle)
|
||||
await expect(dock).toHaveCount(0)
|
||||
|
||||
const returningOpen = sampleDock(page, 700)
|
||||
await switchSession(page, sourceID, sourceTitle)
|
||||
const openSamples = (await returningOpen).filter((sample) => sample.present)
|
||||
expect(openSamples.length).toBeGreaterThan(0)
|
||||
expect(openSamples[0]!.opacity).toBeGreaterThan(0.98)
|
||||
expect(openSamples[0]!.height).toBeGreaterThan(70)
|
||||
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
|
||||
|
||||
const completedTodos = activeTodos.map((todo) => ({ ...todo, status: "completed" }))
|
||||
const closing = sampleDock(page, 1_000)
|
||||
todos[sourceID] = completedTodos
|
||||
events.push(todoEvent(sourceID, completedTodos))
|
||||
await expect(dock).toHaveCount(0)
|
||||
expect((await closing).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
|
||||
todos[sourceID] = []
|
||||
events.push(todoEvent(sourceID, []))
|
||||
|
||||
await switchSession(page, otherID, otherTitle)
|
||||
const returningEmpty = sampleDock(page, 700)
|
||||
await switchSession(page, sourceID, sourceTitle)
|
||||
await expect(dock).toHaveCount(0)
|
||||
expect((await returningEmpty).every((sample) => !sample.present)).toBe(true)
|
||||
})
|
||||
|
||||
function session(id: string, title: string, created: number) {
|
||||
return {
|
||||
id,
|
||||
slug: id,
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created, updated: created },
|
||||
}
|
||||
}
|
||||
|
||||
function statusEvent(sessionID: string, type: "busy" | "idle"): EventPayload {
|
||||
return {
|
||||
directory,
|
||||
payload: { type: "session.status", properties: { sessionID, status: { type } } },
|
||||
}
|
||||
}
|
||||
|
||||
function todoEvent(sessionID: string, next: typeof activeTodos): EventPayload {
|
||||
return {
|
||||
directory,
|
||||
payload: { type: "todo.updated", properties: { sessionID, todos: next } },
|
||||
}
|
||||
}
|
||||
|
||||
async function configurePage(page: Page) {
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
await page.addInitScript(
|
||||
({ directory, dirBase64, server, sessionIDs }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
|
||||
)
|
||||
},
|
||||
{ directory, dirBase64: base64Encode(directory), server, sessionIDs: [sourceID, otherID] },
|
||||
)
|
||||
}
|
||||
|
||||
function sessionHref(sessionID: string) {
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||
}
|
||||
|
||||
async function switchSession(page: Page, sessionID: string, title: string) {
|
||||
const href = sessionHref(sessionID)
|
||||
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
|
||||
await expect(tab).toBeVisible()
|
||||
await tab.click()
|
||||
await expectSessionTitle(page, title)
|
||||
}
|
||||
|
||||
function sampleDock(page: Page, duration: number) {
|
||||
return page.evaluate(async (duration) => {
|
||||
const samples: { present: boolean; height: number; opacity: number }[] = []
|
||||
const start = performance.now()
|
||||
while (performance.now() - start < duration) {
|
||||
const dock = document.querySelector<HTMLElement>('[data-component="session-todo-dock"]')
|
||||
const clip = dock?.parentElement?.parentElement
|
||||
const label = dock?.querySelector<HTMLElement>('[data-action="session-todo-toggle"] span[aria-label]')
|
||||
samples.push({
|
||||
present: !!dock,
|
||||
height: clip?.getBoundingClientRect().height ?? 0,
|
||||
opacity: label ? Number.parseFloat(getComputedStyle(label).opacity) : 0,
|
||||
})
|
||||
await new Promise(requestAnimationFrame)
|
||||
}
|
||||
return samples
|
||||
}, duration)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/SubagentNavigation"
|
||||
@@ -72,16 +72,19 @@ async function setup(page: Page, events?: () => EventPayload[]) {
|
||||
events,
|
||||
eventRetry: events ? 16 : undefined,
|
||||
})
|
||||
// The child session resolves via /session/:id but is absent from the /session list,
|
||||
// The child session resolves by ID but is absent from the session list,
|
||||
// matching a subagent session that has not been loaded into the list cache yet.
|
||||
await page.route(
|
||||
(url) => url.pathname === "/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
|
||||
(url) => url.pathname === "/api/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: JSON.stringify([session(parentID, parentTitle, 1700000000000)]),
|
||||
body: JSON.stringify({
|
||||
data: [currentSession(session(parentID, parentTitle, 1700000000000))],
|
||||
cursor: {},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await configurePage(page)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
|
||||
const server = "http://127.0.0.1:4096"
|
||||
const sessionA = session("ses_tab_a", "Tab A session")
|
||||
const sessionB = session("ses_tab_b", "Tab B session")
|
||||
const sessionC = session("ses_tab_c", "Tab C session")
|
||||
const unresolvedSessionID = "ses_tab_unresolved"
|
||||
|
||||
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
|
||||
await mockServer(page)
|
||||
@@ -39,6 +42,34 @@ test("pressing mouse down on a tab navigates before mouse up", async ({ page })
|
||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||
})
|
||||
|
||||
test("keyboard navigation follows the visible tab order", async ({ page }) => {
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
({ server, sessionA, unresolved, sessionC }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
{ type: "session", server, sessionId: sessionA },
|
||||
{ type: "session", server, sessionId: unresolved },
|
||||
{ type: "session", server, sessionId: sessionC },
|
||||
]),
|
||||
)
|
||||
},
|
||||
{ server, sessionA: sessionA.id, unresolved: unresolvedSessionID, sessionC: sessionC.id },
|
||||
)
|
||||
|
||||
const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
|
||||
const hrefC = `/server/${base64Encode(server)}/session/${sessionC.id}`
|
||||
await page.goto(hrefA)
|
||||
await expect(page.locator("[data-titlebar-tab-slot]:visible")).toHaveCount(2)
|
||||
await expect(page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefC}"])`)).toBeVisible()
|
||||
|
||||
await page.keyboard.press("Control+Alt+ArrowRight")
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`${hrefC.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||
})
|
||||
|
||||
function session(id: string, title: string) {
|
||||
return {
|
||||
id,
|
||||
@@ -52,22 +83,29 @@ function session(id: string, title: string) {
|
||||
}
|
||||
|
||||
async function mockServer(page: Page) {
|
||||
const sessions = [sessionA, sessionB]
|
||||
const sessions = [sessionA, sessionB, sessionC]
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.origin !== server) return route.fallback()
|
||||
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
|
||||
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
|
||||
return new Promise(() => {})
|
||||
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
||||
return sse(route)
|
||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
||||
if (url.pathname === "/session") return json(route, sessions)
|
||||
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||
const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
|
||||
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
||||
if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`))
|
||||
return json(route, { data: [], cursor: {} })
|
||||
const byId = sessions.find((item) => url.pathname === `/session/${item.id}`)
|
||||
if (byId) return json(route, byId)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
|
||||
return json(route, {})
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||
if (url.pathname === "/provider")
|
||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||
@@ -89,7 +127,20 @@ async function mockServer(page: Page) {
|
||||
directory: sessionA.directory,
|
||||
home: sessionA.directory,
|
||||
})
|
||||
if (url.pathname === "/api/path")
|
||||
return json(route, {
|
||||
state: sessionA.directory,
|
||||
config: sessionA.directory,
|
||||
worktree: sessionA.directory,
|
||||
directory: sessionA.directory,
|
||||
home: sessionA.directory,
|
||||
})
|
||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, {
|
||||
location: { directory: sessionA.directory },
|
||||
data: { branch: "main", defaultBranch: "main" },
|
||||
})
|
||||
return json(route, {})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
@@ -46,25 +47,30 @@ test.beforeEach(async ({ page }) => {
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route("**/pty", (route) =>
|
||||
await page.route("**/api/pty*", (route) => {
|
||||
expect(new URL(route.request().url()).searchParams.get("location[directory]")).toBe(directory)
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
|
||||
})
|
||||
})
|
||||
await page.route(`**/api/pty/${ptyID}*`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
|
||||
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
|
||||
}),
|
||||
)
|
||||
await page.route(`**/pty/${ptyID}`, (route) =>
|
||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
||||
)
|
||||
await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
|
||||
await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: JSON.stringify({ ticket: "e2e-ticket" }),
|
||||
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
|
||||
}),
|
||||
)
|
||||
await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), () => undefined)
|
||||
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), () => undefined)
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
})
|
||||
@@ -95,12 +101,12 @@ test("keeps composer focus when a cached terminal finishes mounting", async ({ p
|
||||
const ghostty = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
const created = { count: 0 }
|
||||
await page.route("**/pty", (route) => {
|
||||
await page.route("**/api/pty*", (route) => {
|
||||
created.count += 1
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
|
||||
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
|
||||
})
|
||||
})
|
||||
await page.route(/ghostty-web/, async (route) => {
|
||||
@@ -155,27 +161,31 @@ test("keeps newer composer focus while an explicit terminal open finishes", asyn
|
||||
|
||||
test("focuses a terminal created from the new-terminal button", async ({ page }) => {
|
||||
const created = { count: 0 }
|
||||
await page.route("**/pty", (route) => {
|
||||
await page.route("**/api/pty*", (route) => {
|
||||
created.count += 1
|
||||
const next = created.count === 1 ? { id: ptyID, title: "Terminal 1" } : { id: newPtyID, title: "Terminal 2" }
|
||||
const next = created.count === 1 ? ptyInfo(ptyID, "Terminal 1") : ptyInfo(newPtyID, "Terminal 2")
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(next),
|
||||
body: JSON.stringify({ location: ptyLocation(), data: next }),
|
||||
})
|
||||
})
|
||||
await page.route(`**/pty/${newPtyID}`, (route) =>
|
||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
||||
await page.route(`**/api/pty/${newPtyID}*`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(newPtyID, "Terminal 2") }),
|
||||
}),
|
||||
)
|
||||
await page.route(`**/pty/${newPtyID}/connect-token*`, (route) =>
|
||||
await page.route(`**/api/pty/${newPtyID}/connect-token*`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: JSON.stringify({ ticket: "e2e-ticket" }),
|
||||
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
|
||||
}),
|
||||
)
|
||||
await page.routeWebSocket(new RegExp(`/pty/${newPtyID}/connect`), () => undefined)
|
||||
await page.routeWebSocket(new RegExp(`/api/pty/${newPtyID}/connect`), () => undefined)
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Terminal composer focus")
|
||||
@@ -207,3 +217,11 @@ function seedCachedTerminal(page: Page) {
|
||||
{ terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID },
|
||||
)
|
||||
}
|
||||
|
||||
function ptyLocation() {
|
||||
return { directory, project: { id: projectID, directory } }
|
||||
}
|
||||
|
||||
function ptyInfo(id: string, title: string) {
|
||||
return { id, title, command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 }
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ const title = "Hidden terminal regression"
|
||||
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
@@ -43,17 +44,53 @@ test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route("**/pty", (route) =>
|
||||
await page.route("**/api/pty*", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: "pty_hidden_terminal", title: "Terminal 1" }),
|
||||
body: JSON.stringify({
|
||||
location: { directory, project: { id: projectID, directory } },
|
||||
data: {
|
||||
id: "pty_hidden_terminal",
|
||||
title: "Terminal 1",
|
||||
command: "cmd.exe",
|
||||
args: [],
|
||||
cwd: directory,
|
||||
status: "running",
|
||||
pid: 1,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await page.route("**/pty/pty_hidden_terminal", (route) =>
|
||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
||||
await page.route("**/api/pty/pty_hidden_terminal*", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
location: { directory, project: { id: projectID, directory } },
|
||||
data: {
|
||||
id: "pty_hidden_terminal",
|
||||
title: "Terminal 1",
|
||||
command: "cmd.exe",
|
||||
args: [],
|
||||
cwd: directory,
|
||||
status: "running",
|
||||
pid: 1,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await page.routeWebSocket("**/pty/pty_hidden_terminal/connect", () => undefined)
|
||||
await page.route("**/api/pty/pty_hidden_terminal/connect-token*", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
location: { directory, project: { id: projectID, directory } },
|
||||
data: { ticket: "e2e-ticket", expires_in: 60 },
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await page.routeWebSocket("**/api/pty/pty_hidden_terminal/connect", () => undefined)
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
@@ -29,6 +29,10 @@ test("keeps the terminal session alive when switching session tabs in a workspac
|
||||
const terminal = page.locator('[data-component="terminal"]')
|
||||
await expect(terminal).toBeVisible()
|
||||
await expect.poll(() => connections.length).toBe(1)
|
||||
const connection = new URL(connections[0]!)
|
||||
expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
|
||||
expect(connection.searchParams.get("location[directory]")).toBe(directory)
|
||||
expect(connection.searchParams.get("ticket")).toBeNull()
|
||||
await writeProbe(page)
|
||||
|
||||
await switchTab(page, titleB)
|
||||
@@ -62,6 +66,7 @@ async function readProbe(page: Page) {
|
||||
|
||||
async function setup(page: Page) {
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
@@ -85,26 +90,33 @@ async function setup(page: Page) {
|
||||
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route("**/pty", (route) =>
|
||||
await page.route("**/api/pty*", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
|
||||
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }),
|
||||
}),
|
||||
)
|
||||
await page.route(`**/pty/${ptyID}`, (route) =>
|
||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
||||
)
|
||||
await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
|
||||
await page.route(`**/api/pty/${ptyID}*`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }),
|
||||
}),
|
||||
)
|
||||
await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) => {
|
||||
expect(route.request().headers()["x-opencode-ticket"]).toBe("1")
|
||||
const url = new URL(route.request().url())
|
||||
expect(url.searchParams.get("location[directory]")).toBe(directory)
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: JSON.stringify({ ticket: "e2e-ticket" }),
|
||||
}),
|
||||
)
|
||||
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
|
||||
})
|
||||
})
|
||||
const connections: string[] = []
|
||||
await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), (ws) => {
|
||||
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (ws) => {
|
||||
connections.push(ws.url())
|
||||
})
|
||||
|
||||
@@ -143,3 +155,11 @@ function session(id: string, title: string, created: number) {
|
||||
function sessionHref(sessionID: string) {
|
||||
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||
}
|
||||
|
||||
function ptyLocation() {
|
||||
return { directory, project: { id: projectID, directory } }
|
||||
}
|
||||
|
||||
function ptyInfo() {
|
||||
return { id: ptyID, title: "Terminal 1", command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 }
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ function toolPart(
|
||||
outputLength = 160,
|
||||
): MessagePart {
|
||||
const metadata =
|
||||
tool === "patch"
|
||||
tool === "apply_patch"
|
||||
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
|
||||
: tool === "edit" || tool === "write"
|
||||
? {
|
||||
@@ -199,7 +199,7 @@ function turn(index: number): Message[] {
|
||||
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
|
||||
: []),
|
||||
...(index % 8 === 0
|
||||
? [toolPart(index, 8, "patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
||||
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
||||
: []),
|
||||
...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []),
|
||||
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
|
||||
@@ -229,6 +229,7 @@ const sourceMessages = Array.from({ length: 12 }, (_, index) => [
|
||||
]).flat()
|
||||
|
||||
function renderable(part: MessagePart) {
|
||||
if (part.type === "tool" && part.tool === "todowrite") return false
|
||||
if (part.type === "text") return !!part.text.trim()
|
||||
if (part.type === "reasoning") return !!part.text.trim()
|
||||
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/NewProject"
|
||||
|
||||
test("creates a session in a new project, connects OpenCode Go, and selects its model", async ({ page }) => {
|
||||
let connectedGo = false
|
||||
let pendingGo = false
|
||||
const connections: Array<{ integrationID: string; body: unknown }> = []
|
||||
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_model_selection_flow",
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "NewProject",
|
||||
time: { created: 1_700_000_000_000, updated: 1_700_000_000_000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: () => ({
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: {
|
||||
"free-model": {
|
||||
id: "free-model",
|
||||
name: "Free Model",
|
||||
cost: { input: 0, output: 0 },
|
||||
limit: { context: 200_000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "opencode-go",
|
||||
name: "OpenCode Go",
|
||||
models: {
|
||||
"go-model-1": {
|
||||
id: "go-model-1",
|
||||
name: "Go Model 1",
|
||||
cost: { input: 1, output: 1 },
|
||||
limit: { context: 200_000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
connected: connectedGo ? ["opencode", "opencode-go"] : ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "free-model" },
|
||||
}),
|
||||
integrationMethods: { "opencode-go": [{ type: "api", label: "API key" }] },
|
||||
onConnectKey: (input) => {
|
||||
connections.push(input)
|
||||
if (input.integrationID === "opencode-go") pendingGo = true
|
||||
},
|
||||
onInstanceDispose: () => {
|
||||
if (pendingGo) connectedGo = true
|
||||
},
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
fileList: (path) =>
|
||||
path ? [] : [{ name: "NewProject", path: "NewProject", absolute: directory, type: "directory", ignored: false }],
|
||||
findFiles: () => ["NewProject"],
|
||||
})
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } }))
|
||||
})
|
||||
|
||||
await page.goto("/")
|
||||
const addProject = page.locator('[data-action="home-add-project-row"]')
|
||||
await expectAppVisible(addProject)
|
||||
await addProject.click()
|
||||
await page.locator("[data-directory-path]").click()
|
||||
|
||||
await page.locator('[data-action="home-new-session"]').click()
|
||||
await expectAppVisible(page.locator('[data-component="prompt-input-v2"]'))
|
||||
|
||||
const modelControl = page.locator('[data-action="prompt-model"]')
|
||||
await modelControl.click()
|
||||
await expect(page.locator('[data-section="free-models"]')).toContainText("Free models provided by OpenCode")
|
||||
|
||||
await page.locator('[data-provider-id="opencode-go"]').click()
|
||||
await page.locator('[data-input="provider-api-key"]').fill("mock-go-api-key")
|
||||
await page.locator('[data-action="provider-connect-submit"]').click()
|
||||
await expect(page.locator('[data-component="dialog-v2"]')).toHaveCount(0)
|
||||
expect(connections).toEqual([{ integrationID: "opencode-go", body: { type: "api", key: "mock-go-api-key" } }])
|
||||
|
||||
await expect(modelControl).toHaveAttribute("data-control-type", "popover")
|
||||
await modelControl.click()
|
||||
const goModel = page.locator('[data-option-key="opencode-go:go-model-1"]')
|
||||
await expect(goModel).toBeVisible()
|
||||
await goModel.click()
|
||||
|
||||
await expect(modelControl).toContainText("Go Model 1")
|
||||
})
|
||||
@@ -5,7 +5,10 @@ const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mc
|
||||
|
||||
export interface MockServerConfig {
|
||||
protocol?: "v1" | "v2"
|
||||
provider: unknown
|
||||
provider: unknown | (() => unknown)
|
||||
integrationMethods?: Record<string, unknown[]>
|
||||
onConnectKey?: (input: { integrationID: string; body: unknown }) => void
|
||||
onInstanceDispose?: () => void
|
||||
directory: string
|
||||
project: unknown
|
||||
sessions: ({ id: string } & Record<string, unknown>)[]
|
||||
@@ -18,19 +21,19 @@ export interface MockServerConfig {
|
||||
onMessage?: (input: { sessionID: string; messageID: string }) => void
|
||||
events?: () => unknown[]
|
||||
eventRetry?: number
|
||||
todos?: (sessionID: string) => unknown[]
|
||||
permissions?: unknown[] | (() => unknown[])
|
||||
questions?: unknown[] | (() => unknown[])
|
||||
fileList?: (path: string) => unknown | Promise<unknown>
|
||||
fileContent?: (path: string) => unknown | Promise<unknown>
|
||||
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
|
||||
sessionStatus?: unknown
|
||||
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
|
||||
}
|
||||
|
||||
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
const cursors = new Map<string, string>()
|
||||
let nextCursor = 0
|
||||
const staticRoutes: Record<string, unknown> = {
|
||||
"/provider": config.provider,
|
||||
"/path": {
|
||||
state: config.directory,
|
||||
config: config.directory,
|
||||
@@ -60,7 +63,12 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
route,
|
||||
path === "/api/event"
|
||||
? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])]
|
||||
: events,
|
||||
: [
|
||||
...(path === "/global/event"
|
||||
? [{ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } }]
|
||||
: []),
|
||||
...(events ?? []),
|
||||
],
|
||||
config.eventRetry,
|
||||
)
|
||||
}
|
||||
@@ -69,11 +77,27 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
if (path === "/api/health" && config.protocol === "v2")
|
||||
return json(route, { healthy: true, version: "2.0.0", pid: 1 })
|
||||
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
|
||||
if (path === "/provider")
|
||||
return json(route, typeof config.provider === "function" ? config.provider() : config.provider)
|
||||
if (path === "/provider/auth") return json(route, config.integrationMethods ?? {})
|
||||
const legacyAuth = path.match(/^\/auth\/([^/]+)$/)?.[1]
|
||||
if (legacyAuth && route.request().method() === "PUT") {
|
||||
config.onConnectKey?.({ integrationID: legacyAuth, body: route.request().postDataJSON() })
|
||||
return json(route, true)
|
||||
}
|
||||
if (path === "/instance/dispose" && route.request().method() === "POST") {
|
||||
config.onInstanceDispose?.()
|
||||
return json(route, true)
|
||||
}
|
||||
if (path === "/permission")
|
||||
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
||||
if (path === "/question")
|
||||
return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
|
||||
if (path === "/session/status") return json(route, config.sessionStatus ?? {})
|
||||
if (path === "/session/status")
|
||||
return json(
|
||||
route,
|
||||
typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {}),
|
||||
)
|
||||
if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
|
||||
if (path === "/file" && config.fileList)
|
||||
return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
|
||||
@@ -120,8 +144,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
location: location(config),
|
||||
data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] },
|
||||
})
|
||||
if (/^\/api\/integration\/[^/]+\/connect\/key$/.test(path) && route.request().method() === "POST")
|
||||
const integrationConnect = path.match(/^\/api\/integration\/([^/]+)\/connect\/key$/)?.[1]
|
||||
if (integrationConnect && route.request().method() === "POST") {
|
||||
config.onConnectKey?.({ integrationID: integrationConnect, body: route.request().postDataJSON() })
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (path === "/api/project") return json(route, [config.project])
|
||||
if (path === "/api/project/current")
|
||||
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
|
||||
@@ -199,6 +226,12 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
|
||||
return json(route, true)
|
||||
}
|
||||
if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST") {
|
||||
return json(route, true)
|
||||
}
|
||||
if (
|
||||
/^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
|
||||
route.request().method() === "POST"
|
||||
@@ -237,6 +270,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
return json(route, message)
|
||||
}
|
||||
|
||||
const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/)
|
||||
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
|
||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
|
||||
|
||||
const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
|
||||
|
||||
@@ -197,6 +197,14 @@ export async function installSseTransport<T>(
|
||||
controller.enqueue(
|
||||
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
|
||||
)
|
||||
if (url.pathname === "/global/event")
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
frame({
|
||||
payload: { id: `evt_mock_connected_${id}`, type: "server.connected", properties: {} },
|
||||
}),
|
||||
),
|
||||
)
|
||||
request.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.18.4",
|
||||
"version": "1.18.8",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -53,10 +53,10 @@
|
||||
"@dnd-kit/helpers": "0.5.0",
|
||||
"@dnd-kit/solid": "0.5.0",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13.tgz",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@pierre/trees": "1.0.0-beta.4",
|
||||
|
||||
@@ -67,7 +67,8 @@ import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref }
|
||||
import { createSessionLineage } from "@/pages/session/session-lineage"
|
||||
|
||||
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
|
||||
import { NewHome, LegacyHome } from "@/pages/home"
|
||||
import { NewHome } from "@/pages/home"
|
||||
import { LegacyHome } from "@/pages/home/legacy-home"
|
||||
|
||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||
|
||||
@@ -236,6 +237,30 @@ function UiI18nBridge(props: ParentProps) {
|
||||
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
|
||||
}
|
||||
|
||||
function LayoutCompatibility(props: ParentProps) {
|
||||
const global = useGlobal()
|
||||
const navigate = useNavigate()
|
||||
const server = useServer()
|
||||
const settings = useSettings()
|
||||
|
||||
createEffect(() => {
|
||||
if (settings.general.newLayoutDesigns()) return
|
||||
const current = server.current
|
||||
if (!current) return
|
||||
const protocol = global.ensureServerCtx(current).sdk.protocolKind()
|
||||
if (protocol !== "v2") return
|
||||
const next = global.servers.list().find((s) => {
|
||||
if (ServerConnection.key(s) === ServerConnection.key(current)) return false
|
||||
return global.ensureServerCtx(s).sdk.protocolKind() !== "v2"
|
||||
})
|
||||
if (!next) return
|
||||
navigate("/")
|
||||
queueMicrotask(() => server.setActive(ServerConnection.key(next)))
|
||||
})
|
||||
|
||||
return <>{props.children}</>
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__OPENCODE__?: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Project } from "@opencode-ai/sdk/v2/client"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { createMemo, onCleanup } from "solid-js"
|
||||
import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command"
|
||||
@@ -13,6 +14,7 @@ import { useTabs } from "@/context/tabs"
|
||||
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { normalizeSessionInfo } from "@/utils/session"
|
||||
|
||||
export type CommandPaletteEntry = {
|
||||
id: string
|
||||
@@ -144,8 +146,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
|
||||
server: ServerConnection.key(serverSDK.server),
|
||||
opened: serverCtx.projects.list,
|
||||
stored: () => serverCtx.sync.data.project,
|
||||
load: (search, signal) =>
|
||||
serverSDK.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }),
|
||||
load: (search, signal) => serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
|
||||
untitled: () => language.t("command.session.new"),
|
||||
category: () => language.t("command.category.session"),
|
||||
})
|
||||
@@ -219,7 +220,7 @@ export function createServerSessionEntries(props: {
|
||||
server: ServerConnection.Key
|
||||
opened: () => LocalProject[]
|
||||
stored: () => Project[]
|
||||
load: (search: string, signal: AbortSignal) => Promise<{ data?: GlobalSession[] }>
|
||||
load: (search: string, signal: AbortSignal) => Promise<{ data: SessionInfo[] }>
|
||||
untitled: () => string
|
||||
category: () => string
|
||||
}) {
|
||||
@@ -255,7 +256,8 @@ export function createServerSessionEntries(props: {
|
||||
return props
|
||||
.load(search, current.signal)
|
||||
.then((result) =>
|
||||
(result.data ?? [])
|
||||
result.data
|
||||
.map(normalizeSessionInfo)
|
||||
.filter((session) => !session.time.archived)
|
||||
.map((session) => {
|
||||
const project =
|
||||
@@ -264,7 +266,7 @@ export function createServerSessionEntries(props: {
|
||||
id: `session:${props.server}:${session.id}`,
|
||||
type: "session" as const,
|
||||
title: session.title || props.untitled(),
|
||||
description: project ? displayName(project) : session.project?.name || getFilename(session.directory),
|
||||
description: project ? displayName(project) : getFilename(session.directory),
|
||||
category: props.category(),
|
||||
directory: session.directory,
|
||||
sessionID: session.id,
|
||||
|
||||
@@ -79,8 +79,7 @@ export function DialogHomeCommandPaletteV2(props: {
|
||||
server: ServerConnection.key(props.server),
|
||||
opened: serverCtx.projects.list,
|
||||
stored: () => serverCtx.sync.data.project,
|
||||
load: (search, signal) =>
|
||||
serverCtx.sdk.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }),
|
||||
load: (search, signal) => serverCtx.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
|
||||
untitled: () => language.t("command.session.new"),
|
||||
category: () => language.t("command.category.session"),
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
|
||||
import type { IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
Switch,
|
||||
} from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { Link } from "@/components/link"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
@@ -35,8 +36,10 @@ import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { CustomProviderForm } from "./dialog-custom-provider"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
|
||||
const CUSTOM_ID = "_custom"
|
||||
type ConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
|
||||
|
||||
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
|
||||
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
||||
@@ -154,7 +157,7 @@ function ProviderPicker(props: {
|
||||
const settings = useSettings()
|
||||
if (settings.general.newLayoutDesigns())
|
||||
return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
|
||||
const providers = useProviders(props.directory)
|
||||
const providers = useProviders(() => props.directory?.())
|
||||
const language = useLanguage()
|
||||
const popularGroup = () => language.t("dialog.provider.group.popular")
|
||||
const otherGroup = () => language.t("dialog.provider.group.other")
|
||||
@@ -226,10 +229,8 @@ function ProviderPickerV2(props: {
|
||||
onSelect: (provider: string) => void
|
||||
onPrepare?: () => void
|
||||
}) {
|
||||
const providers = useProviders(props.directory)
|
||||
const providers = useProviders(() => props.directory?.())
|
||||
const language = useLanguage()
|
||||
const serverSync = useServerSync()
|
||||
const serverSDK = useServerSDK()
|
||||
const [store, setStore] = createStore({
|
||||
filter: "",
|
||||
active: undefined as string | undefined,
|
||||
@@ -266,19 +267,7 @@ function ProviderPickerV2(props: {
|
||||
|
||||
const connect = (provider: string) => {
|
||||
props.onPrepare?.()
|
||||
if (provider === CUSTOM_ID || serverSync().data.provider_auth[provider]) {
|
||||
props.onSelect(provider)
|
||||
return
|
||||
}
|
||||
if (store.connecting) return
|
||||
setStore("connecting", provider)
|
||||
void serverSDK()
|
||||
.client.provider.auth()
|
||||
.then((response) => {
|
||||
serverSync().set("provider_auth", response.data ?? {})
|
||||
props.onSelect(provider)
|
||||
})
|
||||
.catch(() => props.onSelect(provider))
|
||||
props.onSelect(provider)
|
||||
}
|
||||
|
||||
const move = (event: KeyboardEvent, direction: number) => {
|
||||
@@ -395,10 +384,16 @@ function ProviderConnection(props: {
|
||||
const dialog = useDialog()
|
||||
const serverSync = useServerSync()
|
||||
const serverSDK = useServerSDK()
|
||||
const params = useParams()
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const newLayout = settings.general.newLayoutDesigns
|
||||
const providers = useProviders(props.directory)
|
||||
const providers = useProviders(() => props.directory?.())
|
||||
const directory = () => props.directory?.() ?? decode64(params.dir)
|
||||
const location = () => {
|
||||
const value = directory()
|
||||
return value ? { directory: value } : undefined
|
||||
}
|
||||
|
||||
const alive = { value: true }
|
||||
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||
@@ -413,38 +408,34 @@ function ProviderConnection(props: {
|
||||
const provider = createMemo(
|
||||
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
|
||||
)
|
||||
const fallback = createMemo<ProviderAuthMethod[]>(() => [
|
||||
const fallback = createMemo<ConnectMethod[]>(() => [
|
||||
{
|
||||
type: "api" as const,
|
||||
type: "key" as const,
|
||||
label: language.t("provider.connect.method.apiKey"),
|
||||
},
|
||||
])
|
||||
const [auth] = createResource(
|
||||
() => props.provider,
|
||||
async () => {
|
||||
const cached = serverSync().data.provider_auth[props.provider]
|
||||
if (cached) return cached
|
||||
const res = await serverSDK().client.provider.auth()
|
||||
if (!alive.value) return fallback()
|
||||
serverSync().set("provider_auth", res.data ?? {})
|
||||
return res.data?.[props.provider] ?? fallback()
|
||||
},
|
||||
const [integration] = createResource(
|
||||
() => ({ provider: props.provider, directory: directory() }),
|
||||
(input) =>
|
||||
serverSDK()
|
||||
.api.integration.get({
|
||||
integrationID: input.provider,
|
||||
location: input.directory ? { directory: input.directory } : undefined,
|
||||
})
|
||||
.then((result) => result.data),
|
||||
)
|
||||
const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
|
||||
const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
|
||||
const cachedMethods = serverSync().data.provider_auth[props.provider]
|
||||
const directMethod =
|
||||
cachedMethods?.length === 1 && cachedMethods[0].type === "api" && !cachedMethods[0].prompts?.length ? 0 : undefined
|
||||
const loading = createMemo(() => integration.loading)
|
||||
const methods = createMemo<ConnectMethod[]>(() => {
|
||||
const values = integration.latest?.methods.filter(
|
||||
(method): method is ConnectMethod => method.type === "key" || method.type === "oauth",
|
||||
)
|
||||
return values?.length ? values : fallback()
|
||||
})
|
||||
const [store, setStore] = createStore({
|
||||
methodIndex: directMethod as undefined | number,
|
||||
authorization: undefined as undefined | ProviderAuthAuthorization,
|
||||
methodIndex: undefined as undefined | number,
|
||||
authorization: undefined as undefined | IntegrationOauthConnectOutput["data"],
|
||||
promptInputs: undefined as undefined | Record<string, string>,
|
||||
state: (directMethod === undefined ? "pending" : undefined) as
|
||||
| undefined
|
||||
| "pending"
|
||||
| "complete"
|
||||
| "error"
|
||||
| "prompt",
|
||||
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
|
||||
error: undefined as string | undefined,
|
||||
})
|
||||
|
||||
@@ -454,7 +445,7 @@ function ProviderConnection(props: {
|
||||
| { type: "auth.prompt" }
|
||||
| { type: "auth.inputs"; inputs: Record<string, string> }
|
||||
| { type: "auth.pending" }
|
||||
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
|
||||
| { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] }
|
||||
| { type: "auth.error"; error: string }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
@@ -508,7 +499,7 @@ function ProviderConnection(props: {
|
||||
|
||||
const methodLabel = (value?: { type?: string; label?: string }) => {
|
||||
if (!value) return ""
|
||||
if (value.type === "api") return language.t("provider.connect.method.apiKey")
|
||||
if (value.type === "key") return language.t("provider.connect.method.apiKey")
|
||||
return value.label ?? ""
|
||||
}
|
||||
|
||||
@@ -518,7 +509,7 @@ function ProviderConnection(props: {
|
||||
const hint = suffix?.[1]
|
||||
return {
|
||||
label: suffix ? label.slice(0, -suffix[0].length) : label,
|
||||
hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "api" ? "Browser" : undefined,
|
||||
hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "key" ? "Browser" : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,46 +540,22 @@ function ProviderConnection(props: {
|
||||
const method = methods()[index]
|
||||
dispatch({ type: "method.select", index })
|
||||
|
||||
if (method.type === "api" && method.prompts?.length) {
|
||||
if (!inputs) {
|
||||
dispatch({ type: "auth.prompt" })
|
||||
return
|
||||
}
|
||||
dispatch({ type: "auth.inputs", inputs })
|
||||
return
|
||||
}
|
||||
|
||||
if (method.type === "oauth") {
|
||||
if (method.prompts?.length && !inputs) {
|
||||
dispatch({ type: "auth.prompt" })
|
||||
return
|
||||
}
|
||||
dispatch({ type: "auth.pending" })
|
||||
const start = Date.now()
|
||||
await serverSDK()
|
||||
.client.provider.oauth.authorize(
|
||||
{
|
||||
providerID: props.provider,
|
||||
method: index,
|
||||
inputs,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
.api.integration.oauth.connect({
|
||||
integrationID: props.provider,
|
||||
methodID: method.id,
|
||||
inputs: inputs ?? {},
|
||||
location: location(),
|
||||
})
|
||||
.then((x) => {
|
||||
if (!alive.value) return
|
||||
const elapsed = Date.now() - start
|
||||
const delay = 1000 - elapsed
|
||||
|
||||
if (delay > 0) {
|
||||
if (timer.current !== undefined) clearTimeout(timer.current)
|
||||
timer.current = setTimeout(() => {
|
||||
timer.current = undefined
|
||||
if (!alive.value) return
|
||||
dispatch({ type: "auth.complete", authorization: x.data! })
|
||||
}, delay)
|
||||
return
|
||||
}
|
||||
dispatch({ type: "auth.complete", authorization: x.data! })
|
||||
dispatch({ type: "auth.complete", authorization: x.data })
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!alive.value) return
|
||||
@@ -603,9 +570,9 @@ function ProviderConnection(props: {
|
||||
index: 0,
|
||||
})
|
||||
|
||||
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
|
||||
const prompts = createMemo(() => {
|
||||
const value = method()
|
||||
return value?.prompts ?? []
|
||||
return value?.type === "oauth" ? (value.prompts ?? []) : []
|
||||
})
|
||||
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
|
||||
if (!prompt.when) return true
|
||||
@@ -636,10 +603,6 @@ function ProviderConnection(props: {
|
||||
setFormStore("index", next)
|
||||
return
|
||||
}
|
||||
if (method()?.type === "api") {
|
||||
dispatch({ type: "auth.inputs", inputs: value })
|
||||
return
|
||||
}
|
||||
await selectMethod(store.methodIndex, value)
|
||||
}
|
||||
|
||||
@@ -741,7 +704,9 @@ function ProviderConnection(props: {
|
||||
})
|
||||
|
||||
async function complete() {
|
||||
await serverSDK().client.global.dispose()
|
||||
await serverSync()
|
||||
.refreshProviders()
|
||||
.catch(() => undefined)
|
||||
dialog.close()
|
||||
showToast({
|
||||
variant: "success",
|
||||
@@ -805,7 +770,7 @@ function ProviderConnection(props: {
|
||||
listRef = ref
|
||||
}}
|
||||
items={methods}
|
||||
key={(m) => m?.label}
|
||||
key={(m) => m?.label ?? m?.type}
|
||||
onSelect={async (selected, index) => {
|
||||
if (!selected) return
|
||||
void selectMethod(index)
|
||||
@@ -851,13 +816,10 @@ function ProviderConnection(props: {
|
||||
}
|
||||
|
||||
setFormStore("error", undefined)
|
||||
await serverSDK().client.auth.set({
|
||||
providerID: props.provider,
|
||||
auth: {
|
||||
type: "api",
|
||||
key: apiKey,
|
||||
...(store.promptInputs ? { metadata: store.promptInputs } : {}),
|
||||
},
|
||||
await serverSDK().api.integration.connect.key({
|
||||
integrationID: props.provider,
|
||||
location: location(),
|
||||
key: apiKey,
|
||||
})
|
||||
await complete()
|
||||
}
|
||||
@@ -891,6 +853,7 @@ function ProviderConnection(props: {
|
||||
ref={apiKey}
|
||||
class="!w-full"
|
||||
name="apiKey"
|
||||
data-input="provider-api-key"
|
||||
placeholder={language.t("provider.connect.apiKey.placeholder")}
|
||||
value={formStore.value}
|
||||
invalid={formStore.error !== undefined}
|
||||
@@ -907,7 +870,7 @@ function ProviderConnection(props: {
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<ButtonV2 type="submit" variant="contrast">
|
||||
<ButtonV2 type="submit" variant="contrast" data-action="provider-connect-submit">
|
||||
{language.t("common.continue")}
|
||||
</ButtonV2>
|
||||
</form>
|
||||
@@ -984,12 +947,13 @@ function ProviderConnection(props: {
|
||||
|
||||
setFormStore("error", undefined)
|
||||
const result = await serverSDK()
|
||||
.client.provider.oauth.callback({
|
||||
providerID: props.provider,
|
||||
method: store.methodIndex,
|
||||
.api.integration.oauth.complete({
|
||||
integrationID: props.provider,
|
||||
attemptID: store.authorization!.attemptID,
|
||||
location: location(),
|
||||
code,
|
||||
})
|
||||
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
|
||||
.then(() => ({ ok: true as const }))
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
if (result.ok) {
|
||||
await complete()
|
||||
@@ -1076,25 +1040,37 @@ function ProviderConnection(props: {
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
void (async () => {
|
||||
const poll = async () => {
|
||||
const authorization = store.authorization
|
||||
if (!authorization || !alive.value) return
|
||||
const result = await serverSDK()
|
||||
.client.provider.oauth.callback({
|
||||
providerID: props.provider,
|
||||
method: store.methodIndex,
|
||||
.api.integration.oauth.status({
|
||||
integrationID: props.provider,
|
||||
attemptID: authorization.attemptID,
|
||||
location: location(),
|
||||
})
|
||||
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
|
||||
.then((value) => ({ ok: true as const, status: value.data }))
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
|
||||
if (!alive.value) return
|
||||
|
||||
if (!result.ok) {
|
||||
const message = formatError(result.error, language.t("common.requestFailed"))
|
||||
dispatch({ type: "auth.error", error: message })
|
||||
dispatch({ type: "auth.error", error: formatError(result.error, language.t("common.requestFailed")) })
|
||||
return
|
||||
}
|
||||
|
||||
await complete()
|
||||
})()
|
||||
if (result.status.status === "complete") {
|
||||
await complete()
|
||||
return
|
||||
}
|
||||
if (result.status.status === "failed") {
|
||||
dispatch({ type: "auth.error", error: result.status.message })
|
||||
return
|
||||
}
|
||||
if (result.status.status === "expired") {
|
||||
dispatch({ type: "auth.error", error: language.t("common.requestFailed") })
|
||||
return
|
||||
}
|
||||
timer.current = setTimeout(poll, 1_000)
|
||||
}
|
||||
void poll()
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -1178,15 +1154,15 @@ function ProviderConnection(props: {
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={method()?.type === "api"}>
|
||||
<Match when={method()?.type === "key"}>
|
||||
<ApiAuthView />
|
||||
</Match>
|
||||
<Match when={method()?.type === "oauth"}>
|
||||
<Switch>
|
||||
<Match when={store.authorization?.method === "code"}>
|
||||
<Match when={store.authorization?.mode === "code"}>
|
||||
<OAuthCodeView />
|
||||
</Match>
|
||||
<Match when={store.authorization?.method === "auto"}>
|
||||
<Match when={store.authorization?.mode === "auto"}>
|
||||
<OAuthAutoView />
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
@@ -131,6 +131,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
||||
|
||||
const saveMutation = useMutation(() => ({
|
||||
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
||||
if ((await serverSDK().protocol) !== "v1") throw new Error("Custom providers are unavailable on this server")
|
||||
const disabledProviders = serverSync().data.config.disabled_providers ?? []
|
||||
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
||||
|
||||
@@ -177,7 +178,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
||||
return (
|
||||
<div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
|
||||
<div class="px-2.5 flex gap-4 items-center">
|
||||
<ProviderIcon id="session.synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||
<div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -69,15 +69,11 @@ export const DialogFork: Component = () => {
|
||||
const dir = base64Encode(sdk().directory)
|
||||
|
||||
sdk()
|
||||
.client.session.fork({ sessionID, messageID: item.id })
|
||||
.api.session.fork({ sessionID, messageID: item.id })
|
||||
.then((forked) => {
|
||||
if (!forked.data) {
|
||||
showToast({ title: language.t("common.requestFailed") })
|
||||
return
|
||||
}
|
||||
dialog.close()
|
||||
prompt.set(restored, undefined, { dir, id: forked.data.id })
|
||||
navigate(`/${dir}/session/${forked.data.id}`)
|
||||
prompt.set(restored, undefined, { dir, id: forked.id })
|
||||
navigate(`/${dir}/session/${forked.id}`)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createEffect, createMemo, createResource, createSignal, For, onCleanup,
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import type { Path } from "@opencode-ai/sdk/v2/client"
|
||||
import {
|
||||
absoluteTreePath,
|
||||
activeTreeNavigation,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
} from "./directory-picker-domain"
|
||||
import "./dialog-select-directory-v2.css"
|
||||
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
|
||||
interface DialogSelectDirectoryV2Props {
|
||||
title?: string
|
||||
@@ -67,11 +69,13 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
||||
const [fallbackPath] = createResource(
|
||||
() => (missingBase() ? true : undefined),
|
||||
() =>
|
||||
sdk.client.path
|
||||
async (): Promise<Path | undefined> => {
|
||||
if ((await sdk.protocol) !== "v1") return
|
||||
return sdk.client.path
|
||||
.get()
|
||||
.then((result) => result.data)
|
||||
.catch(() => undefined),
|
||||
.catch(() => undefined)
|
||||
},
|
||||
{ initialValue: undefined },
|
||||
)
|
||||
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
||||
@@ -85,18 +89,26 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
)
|
||||
const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
|
||||
const [suggestions] = createResource(input, async (value) => {
|
||||
const typed = cleanPickerInput(value).replace(/\/+$/, "")
|
||||
const cleaned = cleanPickerInput(value)
|
||||
const typed = cleaned.replace(/\/+$/, "")
|
||||
const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
|
||||
if (!typed || typed === current) return { query: value, items: [] }
|
||||
if (!cleaned || (root() && typed === current)) return { query: value, items: [] }
|
||||
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
|
||||
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
|
||||
const files = await sdk.client.find
|
||||
.files({ directory: root(), query: pickerFileSearchQuery(root(), value, home()), type: "file", limit: 20 })
|
||||
.then((result) => result.data ?? [])
|
||||
const base = pickerRoot(cleaned) || root() || start()
|
||||
if (!base) return { query: value, items: directories.slice(0, 5) }
|
||||
const files = await sdk.api.file
|
||||
.find({
|
||||
location: { directory: base },
|
||||
query: pickerFileSearchQuery(base, value, home()),
|
||||
type: "file",
|
||||
limit: 20,
|
||||
})
|
||||
.then((result) => result.data)
|
||||
.catch(() => [])
|
||||
const results = [
|
||||
...directories,
|
||||
...files.map((path) => ({ absolute: absoluteTreePath(root(), path), type: "file" as const })),
|
||||
...files.map((entry) => ({ absolute: absoluteTreePath(base, entry.path), type: "file" as const })),
|
||||
]
|
||||
return {
|
||||
query: value,
|
||||
@@ -115,9 +127,14 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
existing ??
|
||||
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
||||
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
||||
return sdk.client.file
|
||||
.list({ directory: absolute, path: "" })
|
||||
.then((result) => result.data ?? [])
|
||||
return sdk.api.file
|
||||
.list({ location: { directory: absolute } })
|
||||
.then((result) =>
|
||||
result.data.map((entry) => ({
|
||||
name: getFilename(entry.path.replace(/[\\/]+$/, "")),
|
||||
type: entry.type,
|
||||
})),
|
||||
)
|
||||
.catch(() => undefined)
|
||||
})
|
||||
listings.set(key, request)
|
||||
@@ -312,6 +329,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
{(suggestion, index) => (
|
||||
<button
|
||||
id={`directory-picker-v2-suggestion-${index()}`}
|
||||
data-directory-path={suggestion.absolute}
|
||||
role="option"
|
||||
aria-selected={index() === activeSuggestion()}
|
||||
data-active={index() === activeSuggestion() ? "" : undefined}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
|
||||
import type { Path } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
interface DialogSelectDirectoryProps {
|
||||
title?: string
|
||||
@@ -59,10 +60,11 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
||||
const [fallbackPath] = createResource(
|
||||
() => (missingBase() ? true : undefined),
|
||||
async () => {
|
||||
async (): Promise<Path | undefined> => {
|
||||
if ((await sdk.protocol) !== "v1") return
|
||||
return sdk.client.path
|
||||
.get()
|
||||
.then((x) => x.data)
|
||||
.then((result) => result.data)
|
||||
.catch(() => undefined)
|
||||
},
|
||||
{ initialValue: undefined },
|
||||
@@ -162,7 +164,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
const path = displayPickerPath(item.absolute, filter(), home())
|
||||
if (path === "~") {
|
||||
return (
|
||||
<div class="w-full flex items-center justify-between rounded-md">
|
||||
<div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md">
|
||||
<div class="flex items-center gap-x-3 grow min-w-0">
|
||||
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
|
||||
<div class="flex items-center text-14-regular min-w-0">
|
||||
@@ -174,7 +176,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div class="w-full flex items-center justify-between rounded-md">
|
||||
<div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md">
|
||||
<div class="flex items-center gap-x-3 grow min-w-0">
|
||||
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
|
||||
<div class="flex items-center text-14-regular min-w-0">
|
||||
|
||||
@@ -43,7 +43,7 @@ export const DialogSelectMcp: Component = () => {
|
||||
filterKeys={["name", "status"]}
|
||||
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
||||
onSelect={(x) => {
|
||||
if (!x || toggle.isPending) return
|
||||
if (!x || x.status === "pending" || toggle.isPending) return
|
||||
toggle.mutate(x.name)
|
||||
}}
|
||||
>
|
||||
@@ -76,7 +76,7 @@ export const DialogSelectMcp: Component = () => {
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<Switch
|
||||
checked={enabled()}
|
||||
disabled={toggle.isPending && toggle.variables === i.name}
|
||||
disabled={status() === "pending" || (toggle.isPending && toggle.variables === i.name)}
|
||||
onChange={() => {
|
||||
if (toggle.isPending) return
|
||||
toggle.mutate(i.name)
|
||||
|
||||
@@ -76,7 +76,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
||||
</DialogHeader>
|
||||
<DialogBody class="max-h-[calc(100vh_-_68px)] min-h-0 flex-none gap-0 overflow-y-auto px-2 pb-2">
|
||||
<div ref={listEl} class="flex min-h-0 flex-col">
|
||||
<div class="flex w-full flex-col items-start pb-3">
|
||||
<div data-section="free-models" class="flex w-full flex-col items-start pb-3">
|
||||
<div class="flex h-8 w-full flex-none select-none flex-row items-center px-3 pb-2">
|
||||
<div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
|
||||
{language.t("dialog.model.unpaid.freeModels.title")}
|
||||
@@ -134,6 +134,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
||||
{(provider) => (
|
||||
<button
|
||||
type="button"
|
||||
data-provider-id={provider.id}
|
||||
class="flex min-h-11 w-full scroll-my-3.5 flex-row items-start gap-2 rounded-md bg-v2-background-bg-base px-3 py-2.5 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-background-bg-layer-01 focus:bg-v2-background-bg-layer-01 focus:outline-none"
|
||||
classList={{
|
||||
"border-[0.5px] border-transparent shadow-[var(--v2-elevation-raised)]":
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
import { Popover as Kobalte } from "@kobalte/core/popover"
|
||||
import {
|
||||
Component,
|
||||
ComponentProps,
|
||||
createEffect,
|
||||
createMemo,
|
||||
For,
|
||||
JSX,
|
||||
onCleanup,
|
||||
Show,
|
||||
ValidComponent,
|
||||
} from "solid-js"
|
||||
import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
@@ -29,6 +19,7 @@ import { ModelTooltip } from "./model-tooltip"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
|
||||
import { createMenuDismissController } from "@/utils/menu-dismiss-controller"
|
||||
import { createEventListener } from "@solid-primitives/event-listener"
|
||||
import { matchesModelSearch } from "./dialog-select-model-search"
|
||||
|
||||
@@ -122,14 +113,13 @@ const ModelList: Component<{
|
||||
}
|
||||
|
||||
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
|
||||
type ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => JSX.Element
|
||||
type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
|
||||
|
||||
export function ModelSelectorPopover(props: {
|
||||
provider?: string
|
||||
model?: ModelState
|
||||
children?: JSX.Element
|
||||
triggerAs?: ValidComponent
|
||||
triggerProps?: ModelSelectorTriggerProps
|
||||
trigger: ModelSelectorTrigger
|
||||
onClose?: (cause: "escape" | "select") => void
|
||||
}) {
|
||||
const [store, setStore] = createStore<{
|
||||
@@ -174,9 +164,7 @@ export function ModelSelectorPopover(props: {
|
||||
placement="top-start"
|
||||
gutter={4}
|
||||
>
|
||||
<Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
||||
{props.children}
|
||||
</Kobalte.Trigger>
|
||||
<Kobalte.Trigger as={props.trigger} />
|
||||
<Kobalte.Portal>
|
||||
<Kobalte.Content
|
||||
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
||||
@@ -237,66 +225,101 @@ export function ModelSelectorPopover(props: {
|
||||
export function ModelSelectorPopoverV2(props: {
|
||||
provider?: string
|
||||
model?: ModelState
|
||||
children?: JSX.Element
|
||||
triggerAs?: ValidComponent
|
||||
triggerProps?: ModelSelectorTriggerProps
|
||||
trigger: ModelSelectorTrigger
|
||||
onClose?: () => void
|
||||
}) {
|
||||
const model = props.model ?? useLocal().model
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const [store, setStore] = createStore({ open: false, search: "", active: "" })
|
||||
let searchRef: HTMLInputElement | undefined
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
let restoreTrigger = true
|
||||
const controller = createModelSelectorController({
|
||||
model: props.model,
|
||||
provider: () => props.provider,
|
||||
onSelect: () => props.onClose?.(),
|
||||
})
|
||||
|
||||
return (
|
||||
<ModelSelectorPopoverV2View
|
||||
trigger={props.trigger}
|
||||
models={controller.models}
|
||||
groups={controller.groups}
|
||||
current={controller.current}
|
||||
select={controller.select}
|
||||
onManage={() => {
|
||||
void import("./dialog-manage-models").then((module) => {
|
||||
void dialog.show(() => <module.DialogManageModelsV2 />)
|
||||
})
|
||||
}}
|
||||
onClose={() => props.onClose?.()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function createModelSelectorController(input: {
|
||||
provider: () => string | undefined
|
||||
model?: ModelState
|
||||
onSelect: () => void
|
||||
}) {
|
||||
const model = input.model ?? useLocal().model
|
||||
const allModels = createMemo(() =>
|
||||
model
|
||||
.list()
|
||||
.filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id }))
|
||||
.filter((item) => (props.provider ? item.provider.id === props.provider : true)),
|
||||
.filter((item) => (input.provider() ? item.provider.id === input.provider() : true)),
|
||||
)
|
||||
const models = createMemo(() => {
|
||||
const search = store.search.trim()
|
||||
const filtered = search
|
||||
? allModels().filter((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
|
||||
: allModels()
|
||||
|
||||
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
|
||||
})
|
||||
const groups = createMemo(() => {
|
||||
const byProvider = new Map<string, ModelItem[]>()
|
||||
for (const item of models()) {
|
||||
byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
|
||||
}
|
||||
return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
|
||||
})
|
||||
const keys = () => [...models().map(modelKey), manageKey]
|
||||
const current = () => {
|
||||
const value = model.current()
|
||||
return value ? `${value.provider.id}:${value.id}` : undefined
|
||||
return {
|
||||
models: (search: string) => {
|
||||
const query = search.trim()
|
||||
const filtered = query
|
||||
? allModels().filter((item) => matchesModelSearch(query, [item.name, item.id, item.provider.name]))
|
||||
: allModels()
|
||||
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
|
||||
},
|
||||
groups: (models: ModelItem[]) => {
|
||||
const byProvider = new Map<string, ModelItem[]>()
|
||||
for (const item of models) {
|
||||
byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
|
||||
}
|
||||
return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
|
||||
},
|
||||
current: () => {
|
||||
const value = model.current()
|
||||
return value ? modelKey(value) : undefined
|
||||
},
|
||||
select: (item: ModelItem) => {
|
||||
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
|
||||
input.onSelect()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function ModelSelectorPopoverV2View(props: {
|
||||
trigger: ModelSelectorTrigger
|
||||
models: (search: string) => ModelItem[]
|
||||
groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
|
||||
current: () => string | undefined
|
||||
select: (item: ModelItem) => void
|
||||
onManage: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const [store, setStore] = createStore({ open: false, search: "", active: "" })
|
||||
let searchRef: HTMLInputElement | undefined
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
const dismiss = createMenuDismissController(() => contentRef)
|
||||
|
||||
const models = createMemo(() => props.models(store.search))
|
||||
const groups = createMemo(() => props.groups(models()))
|
||||
const keys = () => [...models().map(modelKey), manageKey]
|
||||
const initialActive = () => {
|
||||
const selected = current()
|
||||
const selected = props.current()
|
||||
const options = keys()
|
||||
if (selected && options.includes(selected)) return selected
|
||||
return options[0] ?? ""
|
||||
}
|
||||
const activeItem = () =>
|
||||
store.active ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined
|
||||
const afterClose = (callback: () => void) => {
|
||||
const complete = () => {
|
||||
if (contentRef?.isConnected) {
|
||||
requestAnimationFrame(complete)
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(() => requestAnimationFrame(callback))
|
||||
}
|
||||
requestAnimationFrame(complete)
|
||||
}
|
||||
const setOpen = (open: boolean) => {
|
||||
if (open) {
|
||||
restoreTrigger = true
|
||||
dismiss.allowTriggerRestore()
|
||||
setStore({ open: true, active: initialActive() })
|
||||
setTimeout(() =>
|
||||
requestAnimationFrame(() => {
|
||||
@@ -308,23 +331,15 @@ export function ModelSelectorPopoverV2(props: {
|
||||
}
|
||||
setStore({ open: false, search: "", active: "" })
|
||||
}
|
||||
const select = (item: ModelItem) => {
|
||||
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
|
||||
props.onClose?.()
|
||||
}
|
||||
const selectModel = (item: ModelItem) => {
|
||||
restoreTrigger = false
|
||||
dismiss.preventTriggerRestore()
|
||||
setOpen(false)
|
||||
afterClose(() => select(item))
|
||||
dismiss.afterClose(() => props.select(item))
|
||||
}
|
||||
const manage = () => {
|
||||
restoreTrigger = false
|
||||
dismiss.preventTriggerRestore()
|
||||
setOpen(false)
|
||||
afterClose(() => {
|
||||
void import("./dialog-manage-models").then((x) => {
|
||||
dialog.show(() => <x.DialogManageModelsV2 />)
|
||||
})
|
||||
})
|
||||
dismiss.afterClose(props.onManage)
|
||||
}
|
||||
const selectActive = () => {
|
||||
const item = models().find((item) => modelKey(item) === store.active)
|
||||
@@ -343,10 +358,7 @@ export function ModelSelectorPopoverV2(props: {
|
||||
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
|
||||
}
|
||||
const setSearch = (value: string) => {
|
||||
const search = value.trim()
|
||||
const first = [...allModels()]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.find((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
|
||||
const first = props.models(value)[0]
|
||||
setStore({ search: value, active: first ? modelKey(first) : manageKey })
|
||||
}
|
||||
|
||||
@@ -362,18 +374,14 @@ export function ModelSelectorPopoverV2(props: {
|
||||
|
||||
return (
|
||||
<MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
|
||||
<MenuV2.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
||||
{props.children}
|
||||
</MenuV2.Trigger>
|
||||
<MenuV2.Trigger as={props.trigger} />
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content
|
||||
ref={(el: HTMLDivElement) => (contentRef = el)}
|
||||
ref={(element: HTMLDivElement) => (contentRef = element)}
|
||||
class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
||||
onPointerDownOutside={() => (restoreTrigger = false)}
|
||||
onFocusOutside={() => (restoreTrigger = false)}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!restoreTrigger) event.preventDefault()
|
||||
}}
|
||||
onPointerDownOutside={dismiss.preventTriggerRestore}
|
||||
onFocusOutside={dismiss.preventTriggerRestore}
|
||||
onCloseAutoFocus={dismiss.onCloseAutoFocus}
|
||||
>
|
||||
<div class="flex flex-col p-0.5">
|
||||
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
||||
@@ -393,9 +401,9 @@ export function ModelSelectorPopoverV2(props: {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
restoreTrigger = false
|
||||
dismiss.preventTriggerRestore()
|
||||
setOpen(false)
|
||||
afterClose(() => props.onClose?.())
|
||||
dismiss.afterClose(props.onClose)
|
||||
return
|
||||
}
|
||||
if (event.altKey || event.metaKey) return
|
||||
@@ -445,7 +453,7 @@ export function ModelSelectorPopoverV2(props: {
|
||||
<MenuV2.GroupLabel class="gap-2 px-3">
|
||||
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
|
||||
</MenuV2.GroupLabel>
|
||||
<MenuV2.RadioGroup value={current()}>
|
||||
<MenuV2.RadioGroup value={props.current()}>
|
||||
<For each={group.items}>
|
||||
{(item) => (
|
||||
<TooltipV2
|
||||
@@ -465,7 +473,7 @@ export function ModelSelectorPopoverV2(props: {
|
||||
<MenuV2.RadioItem
|
||||
value={modelKey(item)}
|
||||
data-option-key={modelKey(item)}
|
||||
data-selected-model={current() === modelKey(item) ? true : undefined}
|
||||
data-selected-model={props.current() === modelKey(item) ? true : undefined}
|
||||
class="scroll-my-6 w-full"
|
||||
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
|
||||
onMouseEnter={() => {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useGlobal } from "@/context/global"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
import { detectServerProtocol } from "@/utils/server-protocol"
|
||||
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
@@ -263,6 +264,13 @@ export function useServerManagementController(options: { onSelect?: () => void;
|
||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
if (
|
||||
!settings.general.newLayoutDesigns() &&
|
||||
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
|
||||
) {
|
||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
|
||||
resetAdd()
|
||||
if (options.navigateOnAdd === false) {
|
||||
@@ -307,6 +315,13 @@ export function useServerManagementController(options: { onSelect?: () => void;
|
||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
if (
|
||||
!settings.general.newLayoutDesigns() &&
|
||||
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
|
||||
) {
|
||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
if (normalized === input.original.http.url) {
|
||||
server.add(conn)
|
||||
} else {
|
||||
@@ -344,7 +359,10 @@ export function useServerManagementController(options: { onSelect?: () => void;
|
||||
)
|
||||
|
||||
const sortedItems = createMemo(() => {
|
||||
const list = items()
|
||||
const raw = items()
|
||||
const list = settings.general.newLayoutDesigns()
|
||||
? raw
|
||||
: raw.filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2")
|
||||
if (!list.length) return list
|
||||
const active = current()
|
||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||
@@ -544,6 +562,7 @@ export function useServerManagementController(options: { onSelect?: () => void;
|
||||
startEdit,
|
||||
resetForm,
|
||||
submitForm,
|
||||
canRemove: server.canRemove,
|
||||
handleRemove,
|
||||
handleFormChange: () => (isAddMode() ? handleAddChange : handleEditChange),
|
||||
handleFormNameChange: () => (isAddMode() ? handleAddNameChange : handleEditNameChange),
|
||||
@@ -631,13 +650,15 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => props.controller.handleRemove(ServerConnection.key(i))}
|
||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<Show when={props.controller.canRemove(key)}>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => props.controller.handleRemove(ServerConnection.key(i))}
|
||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -133,10 +133,10 @@ test("scopes file autocomplete to the current browser root", () => {
|
||||
test("resolves directory autocomplete from the current browser root", async () => {
|
||||
const directories: string[] = []
|
||||
const sdk = {
|
||||
client: {
|
||||
find: {
|
||||
files: (input: { directory: string }) => {
|
||||
directories.push(input.directory)
|
||||
api: {
|
||||
file: {
|
||||
find: (input: { location?: { directory?: string } }) => {
|
||||
directories.push(input.location?.directory ?? "")
|
||||
return Promise.resolve({ data: [] })
|
||||
},
|
||||
},
|
||||
@@ -152,6 +152,29 @@ test("resolves directory autocomplete from the current browser root", async () =
|
||||
expect(directories).toEqual(["/repo", "/repo/src"])
|
||||
})
|
||||
|
||||
test("searches from an absolute root without a default base", async () => {
|
||||
const directories: string[] = []
|
||||
const sdk = {
|
||||
api: {
|
||||
file: {
|
||||
list: (input: { location?: { directory?: string } }) => {
|
||||
directories.push(input.location?.directory ?? "")
|
||||
return Promise.resolve({
|
||||
data: [
|
||||
{ path: "Users/", type: "directory" },
|
||||
{ path: "tmp/", type: "directory" },
|
||||
],
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
||||
const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined })
|
||||
|
||||
expect(await search("/")).toEqual(["/Users", "/tmp"])
|
||||
expect(directories).toEqual(["/"])
|
||||
})
|
||||
|
||||
test("identifies the next directory level to preload", () => {
|
||||
expect(
|
||||
preloadTreeDirectories("src/", [
|
||||
|
||||
@@ -326,15 +326,15 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
||||
let current = 0
|
||||
|
||||
const scoped = (value: string) => {
|
||||
const raw = normalizePickerDrive(value)
|
||||
const root = pickerRoot(raw)
|
||||
if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) }
|
||||
const base = args.base()
|
||||
if (!base) return
|
||||
const raw = normalizePickerDrive(value)
|
||||
if (!raw) return { directory: trimPickerPath(base), path: "" }
|
||||
const home = args.home()
|
||||
if (raw === "~") return { directory: trimPickerPath(home || base), path: "" }
|
||||
if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) }
|
||||
const root = pickerRoot(raw)
|
||||
if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) }
|
||||
return { directory: trimPickerPath(base), path: raw }
|
||||
}
|
||||
|
||||
@@ -342,14 +342,17 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
||||
const key = trimPickerPath(directory)
|
||||
const existing = cache.get(key)
|
||||
if (existing) return existing
|
||||
const request = args.sdk.client.file
|
||||
.list({ directory: key, path: "" })
|
||||
.then((result) => result.data ?? [])
|
||||
const request = args.sdk.api.file
|
||||
.list({ location: { directory: key } })
|
||||
.then((result) => result.data)
|
||||
.catch(() => [])
|
||||
.then((nodes) =>
|
||||
nodes
|
||||
.filter((node) => node.type === "directory")
|
||||
.map((node) => ({ name: node.name, absolute: trimPickerPath(normalizePickerDrive(node.absolute)) })),
|
||||
.map((node) => {
|
||||
const relative = trimPickerPath(normalizePickerDrive(node.path))
|
||||
return { name: getFilename(relative), absolute: joinPickerPath(key, relative) }
|
||||
}),
|
||||
)
|
||||
cache.set(key, request)
|
||||
return request
|
||||
@@ -371,9 +374,9 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
||||
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
|
||||
const query = normalizePickerDrive(input.path)
|
||||
if (!pathInput) {
|
||||
const results = await args.sdk.client.find
|
||||
.files({ directory: input.directory, query, type: "directory", limit: 50 })
|
||||
.then((result) => result.data ?? [])
|
||||
const results = await args.sdk.api.file
|
||||
.find({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
|
||||
.then((result) => result.data.map((entry) => entry.path))
|
||||
.catch(() => [])
|
||||
if (!active()) return []
|
||||
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { normalizeProjectInfo } from "@/context/global-sync/utils"
|
||||
import { createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useGlobal } from "@/context/global"
|
||||
@@ -70,13 +71,26 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
|
||||
const start = store.startup.trim()
|
||||
|
||||
if (props.project.id && props.project.id !== "global") {
|
||||
await serverCtx().sdk.client.project.update({
|
||||
projectID: props.project.id,
|
||||
directory: props.project.worktree,
|
||||
name,
|
||||
icon: { color: store.color || "", override: store.iconOverride || "" },
|
||||
commands: { start },
|
||||
})
|
||||
if ((await serverCtx().sdk.protocol) !== "v1") return
|
||||
const project = await serverCtx()
|
||||
.sdk.client.project.update({
|
||||
projectID: props.project.id,
|
||||
directory: props.project.worktree,
|
||||
name,
|
||||
icon: { color: store.color || "", override: store.iconOverride || "" },
|
||||
commands: { start },
|
||||
})
|
||||
.then((result) => result.data)
|
||||
if (!project) return
|
||||
// const project = await serverCtx().sdk.api.project.update({
|
||||
// projectID: props.project.id,
|
||||
// name,
|
||||
// icon: { color: store.color || "", override: store.iconOverride || "" },
|
||||
// commands: { start },
|
||||
// })
|
||||
serverCtx().sync.set("project", (items) =>
|
||||
items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)),
|
||||
)
|
||||
serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
|
||||
dialog.close()
|
||||
return
|
||||
|
||||
@@ -37,11 +37,9 @@ export type PromptInputV2ComposerProps = {
|
||||
class?: string
|
||||
controller: PromptInputV2ComposerController
|
||||
borderUnderlay?: boolean
|
||||
edit?: PromptInputProps["edit"]
|
||||
onEditLoaded?: PromptInputProps["onEditLoaded"]
|
||||
}
|
||||
|
||||
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "edit" | "onEditLoaded" | "submission">
|
||||
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission">
|
||||
export type PromptInputV2ComposerController = PromptInputV2Interaction & {
|
||||
readonly model: PromptInputProps["controls"]["model"]
|
||||
}
|
||||
@@ -51,9 +49,6 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
|
||||
useCommands(props)
|
||||
useEditHandler(props)
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-3">
|
||||
<PromptInputV2
|
||||
@@ -82,70 +77,6 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
|
||||
)
|
||||
}
|
||||
|
||||
const useEditHandler = (props: PromptInputV2ComposerProps) => {
|
||||
const prompt = usePrompt()
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.edit?.id,
|
||||
(id) => {
|
||||
const edit = props.edit
|
||||
if (!id || !edit) return
|
||||
prompt.context.items().forEach((item) => prompt.context.remove(item.key))
|
||||
edit.context.forEach((item) =>
|
||||
prompt.context.add({
|
||||
type: item.type,
|
||||
path: item.path,
|
||||
selection: item.selection,
|
||||
comment: item.comment,
|
||||
commentID: item.commentID,
|
||||
commentOrigin: item.commentOrigin,
|
||||
preview: item.preview,
|
||||
}),
|
||||
)
|
||||
props.controller.dispatch({ type: "mode.normal" })
|
||||
props.controller.resetHistory()
|
||||
prompt.set(edit.prompt, promptLength(edit.prompt))
|
||||
props.controller.restoreFocus()
|
||||
props.onEditLoaded?.()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const useCommands = (props: PromptInputV2ComposerProps) => {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
|
||||
command.register("prompt-input", () => [
|
||||
{
|
||||
id: "file.attach",
|
||||
title: language.t("prompt.action.attachFile"),
|
||||
category: language.t("command.category.file"),
|
||||
keybind: "mod+u",
|
||||
disabled: props.controller.state.mode !== "normal",
|
||||
onSelect: () => props.controller.attach(),
|
||||
},
|
||||
{
|
||||
id: "prompt.mode.shell",
|
||||
title: language.t("command.prompt.mode.shell"),
|
||||
category: language.t("command.category.session"),
|
||||
keybind: "mod+shift+x",
|
||||
disabled: props.controller.state.mode === "shell",
|
||||
onSelect: () => props.controller.dispatch({ type: "mode.shell" }),
|
||||
},
|
||||
{
|
||||
id: "prompt.mode.normal",
|
||||
title: language.t("command.prompt.mode.normal"),
|
||||
category: language.t("command.category.session"),
|
||||
keybind: "mod+shift+e",
|
||||
disabled: props.controller.state.mode === "normal",
|
||||
onSelect: () => props.controller.dispatch({ type: "mode.normal" }),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
@@ -310,7 +241,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
)
|
||||
const resources = createMemo(() =>
|
||||
Object.values(sync().data.mcp_resource).map((resource) => ({
|
||||
id: `resource:${resource.client}:${resource.uri}`,
|
||||
id: `resource:${resource.server}:${resource.uri}`,
|
||||
kind: "resource" as const,
|
||||
label: `@${resource.name}`,
|
||||
path: resource.uri,
|
||||
@@ -327,7 +258,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
source: {
|
||||
type: "resource" as const,
|
||||
text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 },
|
||||
clientName: resource.client,
|
||||
clientName: resource.server,
|
||||
uri: resource.uri,
|
||||
},
|
||||
},
|
||||
@@ -447,15 +378,16 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
},
|
||||
view: {
|
||||
placeholder: designPlaceholder,
|
||||
agent:
|
||||
props.controls.agents.visible && props.controls.agents.options.length > 0
|
||||
get agent() {
|
||||
return props.controls.agents.visible && props.controls.agents.options.length > 0
|
||||
? {
|
||||
options: () => props.controls.agents.options.map((name) => ({ id: name, label: name })),
|
||||
current: () => props.controls.agents.current,
|
||||
onSelect: props.controls.agents.select,
|
||||
onSelect: (value: string) => props.controls.agents.select(value),
|
||||
keybind: () => command.keybindParts("agent.cycle"),
|
||||
}
|
||||
: undefined,
|
||||
: undefined
|
||||
},
|
||||
variant: {
|
||||
options: () => variants().map((value) => ({ id: value, label: value })),
|
||||
current: () => props.controls.model.selection.variant.current() ?? "default",
|
||||
@@ -471,6 +403,62 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
},
|
||||
})
|
||||
Object.defineProperty(controller, "model", { get: () => props.controls.model })
|
||||
|
||||
command.register("prompt-input", () => [
|
||||
{
|
||||
id: "file.attach",
|
||||
title: language.t("prompt.action.attachFile"),
|
||||
category: language.t("command.category.file"),
|
||||
keybind: "mod+u",
|
||||
disabled: controller.state.mode !== "normal",
|
||||
onSelect: () => controller.attach(),
|
||||
},
|
||||
{
|
||||
id: "prompt.mode.shell",
|
||||
title: language.t("command.prompt.mode.shell"),
|
||||
category: language.t("command.category.session"),
|
||||
keybind: "mod+shift+x",
|
||||
disabled: controller.state.mode === "shell",
|
||||
onSelect: () => controller.dispatch({ type: "mode.shell" }),
|
||||
},
|
||||
{
|
||||
id: "prompt.mode.normal",
|
||||
title: language.t("command.prompt.mode.normal"),
|
||||
category: language.t("command.category.session"),
|
||||
keybind: "mod+shift+e",
|
||||
disabled: controller.state.mode === "normal",
|
||||
onSelect: () => controller.dispatch({ type: "mode.normal" }),
|
||||
},
|
||||
])
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.edit?.id,
|
||||
(id) => {
|
||||
const edit = props.edit
|
||||
if (!id || !edit) return
|
||||
prompt.context.items().forEach((item) => prompt.context.remove(item.key))
|
||||
edit.context.forEach((item) =>
|
||||
prompt.context.add({
|
||||
type: item.type,
|
||||
path: item.path,
|
||||
selection: item.selection,
|
||||
comment: item.comment,
|
||||
commentID: item.commentID,
|
||||
commentOrigin: item.commentOrigin,
|
||||
preview: item.preview,
|
||||
}),
|
||||
)
|
||||
controller.dispatch({ type: "mode.normal" })
|
||||
controller.resetHistory()
|
||||
prompt.set(edit.prompt, promptLength(edit.prompt))
|
||||
controller.restoreFocus()
|
||||
props.onEditLoaded?.()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
return controller as PromptInputV2ComposerController
|
||||
}
|
||||
|
||||
@@ -520,6 +508,7 @@ function PromptInputV2ModelControl(props: {
|
||||
fallback={
|
||||
<ButtonV2
|
||||
data-action="prompt-model"
|
||||
data-control-type="dialog"
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
|
||||
@@ -533,19 +522,22 @@ function PromptInputV2ModelControl(props: {
|
||||
>
|
||||
<ModelSelectorPopoverV2
|
||||
model={props.model}
|
||||
triggerAs={ButtonV2}
|
||||
triggerProps={{
|
||||
variant: "ghost-muted",
|
||||
size: "normal",
|
||||
style: { height: "28px" },
|
||||
class: "min-w-0 max-w-[220px] justify-start ![font-weight:440] group",
|
||||
classList: { "animate-in fade-in": shouldAnimate() },
|
||||
"data-action": "prompt-model",
|
||||
}}
|
||||
trigger={(triggerProps) => (
|
||||
<ButtonV2
|
||||
{...triggerProps}
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
style={{ height: "28px" }}
|
||||
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
|
||||
classList={{ "animate-in fade-in": shouldAnimate() }}
|
||||
data-action="prompt-model"
|
||||
data-control-type="popover"
|
||||
>
|
||||
{content()}
|
||||
</ButtonV2>
|
||||
)}
|
||||
onClose={props.onClose}
|
||||
>
|
||||
{content()}
|
||||
</ModelSelectorPopoverV2>
|
||||
/>
|
||||
</Show>
|
||||
</TooltipV2>
|
||||
</Show>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// @ts-nocheck
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Todo } from "@opencode-ai/sdk/v2"
|
||||
import { createPromptState } from "@/context/prompt"
|
||||
import { SessionComposerRegion, createSessionComposerRegionController } from "@/pages/session/composer"
|
||||
import { createPromptInputHistory, PromptInput } from "./prompt-input"
|
||||
|
||||
function createPromptInputStoryRuntime() {
|
||||
@@ -28,8 +30,16 @@ function PromptInputExample() {
|
||||
activeTab: undefined as string | undefined,
|
||||
reviewOpen: false,
|
||||
})
|
||||
const storyModel = {
|
||||
id: "claude-3-7-sonnet",
|
||||
name: "Claude 3.7 Sonnet",
|
||||
provider: { id: "anthropic", name: "Anthropic" },
|
||||
}
|
||||
const model = {
|
||||
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
|
||||
current: () => storyModel,
|
||||
list: () => [storyModel],
|
||||
visible: () => true,
|
||||
set: () => {},
|
||||
variant: {
|
||||
list: () => ["fast", "thinking"],
|
||||
current: () => controls.variant,
|
||||
@@ -65,7 +75,6 @@ function PromptInputExample() {
|
||||
open: () => setControls("reviewOpen", true),
|
||||
},
|
||||
},
|
||||
newLayoutDesigns: true,
|
||||
}
|
||||
const addReviewComment = () => {
|
||||
const comment = controls.comments + 1
|
||||
@@ -102,6 +111,93 @@ function PromptInputExample() {
|
||||
)
|
||||
}
|
||||
|
||||
const todos: Todo[] = [
|
||||
{ id: "todo-1", content: "Inspect the session composer animation", status: "completed" },
|
||||
{ id: "todo-2", content: "Keep the dock settled on initial render", status: "in_progress" },
|
||||
{ id: "todo-3", content: "Verify session navigation behavior", status: "pending" },
|
||||
]
|
||||
|
||||
function PromptInputWithOpenDock() {
|
||||
const input = createPromptInputStoryRuntime()
|
||||
const [controls, setControls] = createStore({
|
||||
agent: "build",
|
||||
activeTab: undefined as string | undefined,
|
||||
todoCollapsed: false,
|
||||
})
|
||||
const inputControls = {
|
||||
agents: {
|
||||
available: [],
|
||||
options: ["build"],
|
||||
get current() {
|
||||
return controls.agent
|
||||
},
|
||||
loading: false,
|
||||
visible: true,
|
||||
select: (agent?: string) => setControls("agent", agent ?? "build"),
|
||||
},
|
||||
model: {
|
||||
selection: {
|
||||
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
|
||||
variant: { list: () => [], current: () => undefined, set: () => {} },
|
||||
},
|
||||
paid: true,
|
||||
loading: false,
|
||||
},
|
||||
session: {
|
||||
id: "story-session",
|
||||
tabs: {
|
||||
active: () => controls.activeTab,
|
||||
all: () => [],
|
||||
open: () => {},
|
||||
setActive: (tab: string) => setControls("activeTab", tab),
|
||||
},
|
||||
reviewPanel: { opened: () => false, open: () => {} },
|
||||
},
|
||||
}
|
||||
const state = {
|
||||
blocked: () => false,
|
||||
questionRequest: () => undefined,
|
||||
permissionRequest: () => undefined,
|
||||
permissionResponding: () => false,
|
||||
decide: () => {},
|
||||
todos: () => todos,
|
||||
dock: () => true,
|
||||
closing: () => false,
|
||||
opening: () => false,
|
||||
}
|
||||
return (
|
||||
<SessionComposerRegion
|
||||
controller={createSessionComposerRegionController({
|
||||
state,
|
||||
sessionKey: () => "story-session",
|
||||
sessionID: () => "story-session",
|
||||
prompt: input.state,
|
||||
ready: () => true,
|
||||
centered: () => false,
|
||||
todo: {
|
||||
collapsed: () => controls.todoCollapsed,
|
||||
onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed),
|
||||
},
|
||||
followup: () => undefined,
|
||||
revert: () => undefined,
|
||||
onResponseSubmit: () => {},
|
||||
openParent: () => {},
|
||||
setPromptRef: () => {},
|
||||
setDockRef: () => {},
|
||||
})}
|
||||
promptInput={
|
||||
<PromptInput
|
||||
controls={inputControls}
|
||||
{...input}
|
||||
ref={() => {}}
|
||||
newSessionWorktree=""
|
||||
onNewSessionWorktreeReset={() => {}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "App/PromptInput",
|
||||
id: "app-prompt-input",
|
||||
@@ -116,3 +212,12 @@ export const Basic = {
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const DockAlreadyOpen = {
|
||||
render: () => (
|
||||
<div class="pt-10">
|
||||
<h1 class="mb-4">Prompt Input with open Todo dock</h1>
|
||||
<PromptInputWithOpenDock />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -591,7 +591,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
type: "resource",
|
||||
name: resource.name,
|
||||
uri: resource.uri,
|
||||
client: resource.client,
|
||||
client: resource.server,
|
||||
display: resource.name,
|
||||
description: resource.description,
|
||||
mime: resource.mimeType,
|
||||
@@ -709,7 +709,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
title: cmd.name,
|
||||
description: cmd.description,
|
||||
type: "custom" as const,
|
||||
source: cmd.source,
|
||||
// source: cmd.source,
|
||||
}))
|
||||
|
||||
return [...custom, ...builtin]
|
||||
@@ -1723,29 +1723,31 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
>
|
||||
<ModelSelectorPopover
|
||||
model={props.controls.model.selection}
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
size: "normal",
|
||||
style: control(),
|
||||
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
|
||||
"data-action": "prompt-model",
|
||||
}}
|
||||
trigger={(triggerProps) => (
|
||||
<Button
|
||||
{...triggerProps}
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
style={control()}
|
||||
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
|
||||
data-action="prompt-model"
|
||||
>
|
||||
<Show when={props.controls.model.selection.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
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">
|
||||
{props.controls.model.selection.current()?.name ??
|
||||
language.t("dialog.model.select.title")}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||
</Button>
|
||||
)}
|
||||
onClose={restoreFocus}
|
||||
>
|
||||
<Show when={props.controls.model.selection.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
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">
|
||||
{props.controls.model.selection.current()?.name ??
|
||||
language.t("dialog.model.select.title")}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||
</ModelSelectorPopover>
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,11 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||
|
||||
const createdClients: string[] = []
|
||||
const createdSessions: string[] = []
|
||||
const sessionCreateInputs: Array<{
|
||||
agent?: string
|
||||
model?: { id: string; providerID: string; variant?: string }
|
||||
location?: { directory: string }
|
||||
}> = []
|
||||
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
|
||||
const optimistic: Array<{
|
||||
directory?: string
|
||||
@@ -20,9 +25,14 @@ const optimistic: Array<{
|
||||
const optimisticSeeded: boolean[] = []
|
||||
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
||||
const promoted: Array<{ directory: string; sessionID: string }> = []
|
||||
const sentShell: string[] = []
|
||||
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
|
||||
const syncedDirectories: string[] = []
|
||||
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
|
||||
const sentPrompts: string[] = []
|
||||
const promptInputs: unknown[] = []
|
||||
const sentCommands: unknown[] = []
|
||||
const commands: Array<{ name: string }> = []
|
||||
let serverSessionSyncs = 0
|
||||
|
||||
let params: { id?: string } = {}
|
||||
let search: { draftId?: string } = {}
|
||||
@@ -31,7 +41,7 @@ let variant: string | undefined
|
||||
let permissionServer = "server-a"
|
||||
let createSessionGate: Promise<void> | undefined
|
||||
|
||||
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
const [promptStore, setPromptStore] = createStore<PromptStore>({
|
||||
prompt: promptValue,
|
||||
cursor: 0,
|
||||
@@ -63,23 +73,39 @@ const prompt = {
|
||||
const clientFor = (directory: string) => {
|
||||
createdClients.push(directory)
|
||||
return {
|
||||
session: {
|
||||
create: async () => {
|
||||
await createSessionGate
|
||||
createdSessions.push(directory)
|
||||
return {
|
||||
data: {
|
||||
api: {
|
||||
session: {
|
||||
create: async (input: (typeof sessionCreateInputs)[number]) => {
|
||||
await createSessionGate
|
||||
const location = input.location?.directory ?? directory
|
||||
createdSessions.push(location)
|
||||
sessionCreateInputs.push(input)
|
||||
return {
|
||||
id: `session-${createdSessions.length}`,
|
||||
projectID: "project",
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 1 },
|
||||
title: `New session ${createdSessions.length}`,
|
||||
},
|
||||
}
|
||||
location: { directory: location },
|
||||
}
|
||||
},
|
||||
prompt: async (input: unknown) => {
|
||||
sentPrompts.push(directory)
|
||||
promptInputs.push(input)
|
||||
return { data: undefined }
|
||||
},
|
||||
command: async (input: unknown) => {
|
||||
sentCommands.push(input)
|
||||
},
|
||||
shell: async (input: { sessionID: string; id?: string; command: string }) => {
|
||||
sentShell.push(input)
|
||||
},
|
||||
},
|
||||
shell: async () => {
|
||||
sentShell.push(directory)
|
||||
return { data: undefined }
|
||||
},
|
||||
prompt: async () => ({ data: undefined }),
|
||||
promptAsync: async () => ({ data: undefined }),
|
||||
},
|
||||
session: {
|
||||
command: async () => ({ data: undefined }),
|
||||
abort: async () => ({ data: undefined }),
|
||||
},
|
||||
@@ -172,6 +198,7 @@ beforeAll(async () => {
|
||||
scope: "local",
|
||||
directory: "/repo/main",
|
||||
client: rootClient,
|
||||
api: rootClient.api,
|
||||
url: "http://localhost:4096",
|
||||
createClient(opts: any) {
|
||||
return clientFor(opts.directory)
|
||||
@@ -183,7 +210,7 @@ beforeAll(async () => {
|
||||
|
||||
mock.module("@/context/sync", () => ({
|
||||
useSync: () => () => ({
|
||||
data: { command: [] },
|
||||
data: { command: commands },
|
||||
session: {
|
||||
optimistic: {
|
||||
add: (value: {
|
||||
@@ -210,6 +237,9 @@ beforeAll(async () => {
|
||||
session: {
|
||||
remember: () => undefined,
|
||||
set: () => undefined,
|
||||
sync: async () => {
|
||||
serverSessionSyncs++
|
||||
},
|
||||
},
|
||||
child: (directory: string) => {
|
||||
syncedDirectories.push(directory)
|
||||
@@ -251,11 +281,17 @@ beforeAll(async () => {
|
||||
beforeEach(() => {
|
||||
createdClients.length = 0
|
||||
createdSessions.length = 0
|
||||
sessionCreateInputs.length = 0
|
||||
enabledAutoAccept.length = 0
|
||||
optimistic.length = 0
|
||||
optimisticSeeded.length = 0
|
||||
promoted.length = 0
|
||||
promotedDrafts.length = 0
|
||||
sentPrompts.length = 0
|
||||
promptInputs.length = 0
|
||||
sentCommands.length = 0
|
||||
commands.length = 0
|
||||
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
params = {}
|
||||
search = {}
|
||||
sentShell.length = 0
|
||||
@@ -264,6 +300,7 @@ beforeEach(() => {
|
||||
variant = undefined
|
||||
permissionServer = "server-a"
|
||||
createSessionGate = undefined
|
||||
serverSessionSyncs = 0
|
||||
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
||||
})
|
||||
|
||||
@@ -297,8 +334,24 @@ describe("prompt submit worktree selection", () => {
|
||||
|
||||
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(sessionCreateInputs).toEqual([
|
||||
{
|
||||
agent: "agent",
|
||||
model: { id: "model", providerID: "provider", variant: undefined },
|
||||
location: { directory: "/repo/worktree-a" },
|
||||
},
|
||||
{
|
||||
agent: "agent",
|
||||
model: { id: "model", providerID: "provider", variant: undefined },
|
||||
location: { directory: "/repo/worktree-b" },
|
||||
},
|
||||
])
|
||||
expect(sentShell).toEqual([
|
||||
expect.objectContaining({ sessionID: "session-1", id: expect.stringMatching(/^evt_/), command: "ls" }),
|
||||
expect.objectContaining({ sessionID: "session-2", id: expect.stringMatching(/^evt_/), command: "ls" }),
|
||||
])
|
||||
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
||||
expect(serverSessionSyncs).toBe(0)
|
||||
expect(promoted).toEqual([
|
||||
{ directory: "/repo/worktree-a", sessionID: "session-1" },
|
||||
{ directory: "/repo/worktree-b", sessionID: "session-2" },
|
||||
@@ -419,6 +472,7 @@ describe("prompt submit worktree selection", () => {
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
await Bun.sleep(0)
|
||||
|
||||
expect(optimistic).toHaveLength(1)
|
||||
expect(optimistic[0]).toMatchObject({
|
||||
@@ -427,6 +481,56 @@ describe("prompt submit worktree selection", () => {
|
||||
model: { providerID: "provider", modelID: "model", variant: "high" },
|
||||
},
|
||||
})
|
||||
expect(sentPrompts).toEqual(["/repo/main"])
|
||||
expect(promptInputs[0]).toMatchObject({
|
||||
sessionID: "session-1",
|
||||
text: "ls",
|
||||
files: [],
|
||||
agents: [],
|
||||
})
|
||||
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
|
||||
expect((promptInputs[0] as { legacyParts?: { id: string; type: string; text?: string }[] }).legacyParts).toEqual([
|
||||
{ id: expect.stringMatching(/^prt_/), type: "text", text: "ls" },
|
||||
])
|
||||
})
|
||||
|
||||
test("submits slash commands through the current session API", async () => {
|
||||
params = { id: "session-1" }
|
||||
variant = "high"
|
||||
commands.push({ name: "review" })
|
||||
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
|
||||
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => ({ id: "session-1" }),
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
})
|
||||
|
||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||
|
||||
expect(sentCommands).toEqual([
|
||||
{
|
||||
sessionID: "session-1",
|
||||
id: expect.stringMatching(/^msg_/),
|
||||
command: "review",
|
||||
arguments: "staged changes",
|
||||
agent: "agent",
|
||||
model: { id: "model", providerID: "provider", variant: "high" },
|
||||
files: [],
|
||||
},
|
||||
])
|
||||
expect(serverSessionSyncs).toBe(0)
|
||||
})
|
||||
|
||||
test("uses an injected model selection", async () => {
|
||||
@@ -487,7 +591,8 @@ describe("prompt submit worktree selection", () => {
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
|
||||
expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }])
|
||||
expect(storedSessions["/repo/worktree-a"]).toHaveLength(1)
|
||||
expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" })
|
||||
expect(optimisticSeeded).toEqual([true])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,6 +20,8 @@ import { setCursorPosition } from "./editor-dom"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { ScopedKey } from "@/utils/server-scope"
|
||||
import { createPromptSubmissionState } from "./submission-state"
|
||||
import { normalizeSessionInfo } from "@/utils/session"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
|
||||
type PendingPrompt = {
|
||||
abort: AbortController
|
||||
@@ -39,7 +41,7 @@ export type FollowupDraft = {
|
||||
}
|
||||
|
||||
type FollowupSendInput = {
|
||||
client: DirectorySDK["client"]
|
||||
api: DirectorySDK["api"]["session"]
|
||||
serverSync: ServerSync
|
||||
sync: DirectorySync
|
||||
draft: FollowupDraft
|
||||
@@ -81,19 +83,21 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
return false
|
||||
}
|
||||
|
||||
await input.client.session.command({
|
||||
const messageID = Identifier.ascending("message")
|
||||
await input.api.command({
|
||||
sessionID: input.draft.sessionID,
|
||||
id: messageID,
|
||||
command: cmd,
|
||||
arguments: tail.join(" "),
|
||||
agent: input.draft.agent,
|
||||
model: `${input.draft.model.providerID}/${input.draft.model.modelID}`,
|
||||
variant: input.draft.variant,
|
||||
parts: images.map((attachment) => ({
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file" as const,
|
||||
mime: attachment.mime,
|
||||
url: attachment.dataUrl,
|
||||
filename: attachment.filename,
|
||||
model: {
|
||||
id: input.draft.model.modelID,
|
||||
providerID: input.draft.model.providerID,
|
||||
variant: input.draft.variant,
|
||||
},
|
||||
files: images.map((attachment) => ({
|
||||
uri: attachment.dataUrl,
|
||||
name: attachment.filename,
|
||||
})),
|
||||
})
|
||||
return true
|
||||
@@ -152,13 +156,37 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
return false
|
||||
}
|
||||
|
||||
await input.client.session.promptAsync({
|
||||
await input.api.prompt({
|
||||
sessionID: input.draft.sessionID,
|
||||
id: messageID,
|
||||
agent: input.draft.agent,
|
||||
model: input.draft.model,
|
||||
messageID,
|
||||
parts: requestParts,
|
||||
variant: input.draft.variant,
|
||||
legacyParts: requestParts,
|
||||
text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
|
||||
files: requestParts.flatMap((part) => {
|
||||
if (part.type !== "file") return []
|
||||
const text = part.source?.text
|
||||
return [
|
||||
{
|
||||
uri: part.url,
|
||||
name: part.filename,
|
||||
mention: text ? { start: text.start, end: text.end, text: text.value } : undefined,
|
||||
},
|
||||
]
|
||||
}),
|
||||
agents: requestParts.flatMap((part) =>
|
||||
part.type === "agent"
|
||||
? [
|
||||
{
|
||||
name: part.name,
|
||||
mention: part.source
|
||||
? { start: part.source.start, end: part.source.end, text: part.source.value }
|
||||
: undefined,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
@@ -210,6 +238,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
|
||||
|
||||
const errorMessage = (err: unknown) => {
|
||||
if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message
|
||||
if (err && typeof err === "object" && "data" in err) {
|
||||
const data = (err as { data?: { message?: string } }).data
|
||||
if (data?.message) return data.message
|
||||
@@ -222,6 +251,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return Promise.resolve()
|
||||
|
||||
serverSync().session.set("todo", sessionID, [])
|
||||
|
||||
input.onAbort?.()
|
||||
|
||||
const key = pendingKey(sessionID)
|
||||
@@ -233,9 +264,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return sdk()
|
||||
.client.session.abort({
|
||||
sessionID,
|
||||
})
|
||||
.api.session.interrupt({ sessionID })
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
@@ -362,9 +391,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
|
||||
let session = input.info()
|
||||
if (!session && isNewSession) {
|
||||
const created = await client.session
|
||||
.create()
|
||||
.then((x) => x.data ?? undefined)
|
||||
const created = await sdk()
|
||||
.api.session.create({
|
||||
agent: currentAgent.name,
|
||||
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
||||
location: { directory: sessionDirectory },
|
||||
})
|
||||
.then(normalizeSessionInfo)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||
@@ -448,12 +481,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
|
||||
if (mode === "shell") {
|
||||
clearInput()
|
||||
client.session
|
||||
.shell({
|
||||
const eventID = Event.ID.create()
|
||||
sdk()
|
||||
.api.session.shell({
|
||||
sessionID: session.id,
|
||||
id: eventID,
|
||||
command: text,
|
||||
agent,
|
||||
model,
|
||||
command: text,
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
@@ -471,23 +506,23 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const customCommand = sync().data.command.find((c) => c.name === commandName)
|
||||
if (customCommand) {
|
||||
clearInput()
|
||||
client.session
|
||||
.command({
|
||||
const messageID = Identifier.ascending("message")
|
||||
serverSync().session.set("session_status", session.id, { type: "busy" })
|
||||
sdk()
|
||||
.api.session.command({
|
||||
sessionID: session.id,
|
||||
id: messageID,
|
||||
command: commandName,
|
||||
arguments: args.join(" "),
|
||||
agent,
|
||||
model: `${model.providerID}/${model.modelID}`,
|
||||
variant,
|
||||
parts: images.map((attachment) => ({
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file" as const,
|
||||
mime: attachment.mime,
|
||||
url: attachment.dataUrl,
|
||||
filename: attachment.filename,
|
||||
model: { id: model.modelID, providerID: model.providerID, variant },
|
||||
files: images.map((attachment) => ({
|
||||
uri: attachment.dataUrl,
|
||||
name: attachment.filename,
|
||||
})),
|
||||
})
|
||||
.catch((err) => {
|
||||
serverSync().session.set("session_status", session.id, { type: "idle" })
|
||||
showToast({
|
||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||
@@ -571,7 +606,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
|
||||
void sendFollowupDraft({
|
||||
client,
|
||||
api: sdk().api.session,
|
||||
sync: sync(),
|
||||
serverSync: serverSync(),
|
||||
draft,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useLanguage } from "@/context/language"
|
||||
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
|
||||
import { createMenuDismissController } from "@/utils/menu-dismiss-controller"
|
||||
|
||||
export type PromptProject = {
|
||||
name?: string
|
||||
@@ -197,8 +198,8 @@ export function PromptProjectSelector(props: {
|
||||
}) {
|
||||
const [triggerReady, setTriggerReady] = createSignal(false)
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
const dismiss = createMenuDismissController(() => contentRef)
|
||||
let triggerFrame: number | undefined
|
||||
let restoreTrigger = true
|
||||
|
||||
// Floating UI requires a connected anchor; route transitions can construct this trigger before adoption.
|
||||
const setTriggerRef = (element: HTMLButtonElement) => {
|
||||
@@ -221,25 +222,15 @@ export function PromptProjectSelector(props: {
|
||||
props.controller.active()
|
||||
? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
|
||||
: undefined
|
||||
const afterClose = (callback: () => void) => {
|
||||
const complete = () => {
|
||||
if (contentRef?.isConnected) {
|
||||
requestAnimationFrame(complete)
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(() => requestAnimationFrame(callback))
|
||||
}
|
||||
requestAnimationFrame(complete)
|
||||
}
|
||||
const selectProject = (project: PromptProject) => {
|
||||
restoreTrigger = false
|
||||
dismiss.preventTriggerRestore()
|
||||
props.controller.setOpen(false)
|
||||
afterClose(() => props.controller.select(project))
|
||||
dismiss.afterClose(() => props.controller.select(project))
|
||||
}
|
||||
const selectAction = (server?: string) => {
|
||||
restoreTrigger = false
|
||||
dismiss.preventTriggerRestore()
|
||||
props.controller.setOpen(false)
|
||||
afterClose(() => props.controller.add(server))
|
||||
dismiss.afterClose(() => props.controller.add(server))
|
||||
}
|
||||
const selectActive = () => {
|
||||
const project = props.controller.activeProject()
|
||||
@@ -267,7 +258,7 @@ export function PromptProjectSelector(props: {
|
||||
)
|
||||
.filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap"))
|
||||
.findLast((element) => element.offsetParent !== null)
|
||||
restoreTrigger = false
|
||||
dismiss.preventTriggerRestore()
|
||||
target?.focus()
|
||||
queueMicrotask(() => {
|
||||
if (props.controller.open()) props.controller.setOpen(false)
|
||||
@@ -291,7 +282,10 @@ export function PromptProjectSelector(props: {
|
||||
placement={props.placement ?? "bottom"}
|
||||
gutter={4}
|
||||
modal={false}
|
||||
onOpenChange={(open) => props.controller.setOpen(open)}
|
||||
onOpenChange={(open) => {
|
||||
if (open) dismiss.allowTriggerRestore()
|
||||
props.controller.setOpen(open)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Trigger as={ProjectTrigger} ref={setTriggerRef} controller={props.controller} />
|
||||
<DropdownMenu.Portal>
|
||||
@@ -300,11 +294,9 @@ export function PromptProjectSelector(props: {
|
||||
id="prompt-project-menu"
|
||||
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onPointerDownOutside={() => (restoreTrigger = false)}
|
||||
onFocusOutside={() => (restoreTrigger = false)}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!restoreTrigger) event.preventDefault()
|
||||
}}
|
||||
onPointerDownOutside={dismiss.preventTriggerRestore}
|
||||
onFocusOutside={dismiss.preventTriggerRestore}
|
||||
onCloseAutoFocus={dismiss.onCloseAutoFocus}
|
||||
>
|
||||
<div class="flex flex-col p-0.5">
|
||||
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
||||
@@ -364,41 +356,45 @@ export function PromptProjectSelector(props: {
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<Show
|
||||
when={props.controller.servers().length > 1}
|
||||
fallback={
|
||||
<DropdownMenu.RadioGroup value={selectedValue()}>
|
||||
<For each={props.controller.projects()}>
|
||||
{(project) => (
|
||||
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
|
||||
)}
|
||||
</For>
|
||||
</DropdownMenu.RadioGroup>
|
||||
}
|
||||
>
|
||||
<For
|
||||
each={props.controller
|
||||
.servers()
|
||||
.filter((server) =>
|
||||
props.controller.projects().some((project) => project.server?.key === server!.key),
|
||||
)}
|
||||
<div class="max-h-[224px] overflow-y-auto">
|
||||
<Show
|
||||
when={props.controller.servers().length > 1}
|
||||
fallback={
|
||||
<DropdownMenu.RadioGroup value={selectedValue()}>
|
||||
<For each={props.controller.projects()}>
|
||||
{(project) => (
|
||||
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
|
||||
)}
|
||||
</For>
|
||||
</DropdownMenu.RadioGroup>
|
||||
}
|
||||
>
|
||||
{(server) => (
|
||||
<div>
|
||||
<div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint">
|
||||
{server!.name}
|
||||
<For
|
||||
each={props.controller
|
||||
.servers()
|
||||
.filter((server) =>
|
||||
props.controller.projects().some((project) => project.server?.key === server!.key),
|
||||
)}
|
||||
>
|
||||
{(server) => (
|
||||
<div>
|
||||
<div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint">
|
||||
{server!.name}
|
||||
</div>
|
||||
<DropdownMenu.RadioGroup value={selectedValue()}>
|
||||
<For
|
||||
each={props.controller.projects().filter((project) => project.server?.key === server!.key)}
|
||||
>
|
||||
{(project) => (
|
||||
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
|
||||
)}
|
||||
</For>
|
||||
</DropdownMenu.RadioGroup>
|
||||
</div>
|
||||
<DropdownMenu.RadioGroup value={selectedValue()}>
|
||||
<For each={props.controller.projects().filter((project) => project.server?.key === server!.key)}>
|
||||
{(project) => (
|
||||
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
|
||||
)}
|
||||
</For>
|
||||
</DropdownMenu.RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-px bg-v2-border-border-muted" />
|
||||
<div class="flex flex-col p-0.5">
|
||||
|
||||
@@ -15,9 +15,49 @@ export const ServerRowMenu: Component<{
|
||||
}> = (props) => {
|
||||
const language = useLanguage()
|
||||
const key = ServerConnection.key(props.server)
|
||||
const builtin = ServerConnection.builtin(props.server)
|
||||
const isDefault = () => props.controller.defaultKey() === key
|
||||
return (
|
||||
<ServerRowMenuView
|
||||
server={props.server}
|
||||
labels={serverMenuLabels(language)}
|
||||
canDefault={props.controller.canDefault()}
|
||||
isDefault={props.controller.defaultKey() === key}
|
||||
canRemove={props.controller.canRemove(key)}
|
||||
onEdit={props.onEdit}
|
||||
onSetDefault={() => props.controller.setDefault(key)}
|
||||
onRemoveDefault={() => props.controller.setDefault(null)}
|
||||
onRemove={() => props.controller.handleRemove(key)}
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function serverMenuLabels(language: ReturnType<typeof useLanguage>) {
|
||||
return {
|
||||
more: language.t("common.moreOptions"),
|
||||
server: language.t("settings.section.server"),
|
||||
edit: language.t("dialog.server.menu.edit"),
|
||||
default: language.t("dialog.server.menu.default"),
|
||||
defaultRemove: language.t("dialog.server.menu.defaultRemove"),
|
||||
delete: language.t("dialog.server.menu.delete"),
|
||||
}
|
||||
}
|
||||
|
||||
export const ServerRowMenuView: Component<{
|
||||
server: ServerConnection.Any
|
||||
labels: ReturnType<typeof serverMenuLabels>
|
||||
canDefault: boolean
|
||||
isDefault: boolean
|
||||
canRemove: boolean
|
||||
onEdit: (server: ServerConnection.Http) => void
|
||||
onSetDefault: () => void
|
||||
onRemoveDefault: () => void
|
||||
onRemove: () => void
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}> = (props) => {
|
||||
const builtin = () => ServerConnection.builtin(props.server)
|
||||
const httpServer = () => (props.server.type === "http" ? props.server : undefined)
|
||||
return (
|
||||
<MenuV2 gutter={6} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<MenuV2.Trigger
|
||||
@@ -25,32 +65,31 @@ export const ServerRowMenu: Component<{
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="outline-dots" />}
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
aria-label={props.labels.more}
|
||||
/>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content>
|
||||
<MenuV2.Group>
|
||||
<MenuV2.GroupLabel>{language.t("settings.section.server")}</MenuV2.GroupLabel>
|
||||
<MenuV2.GroupLabel>{props.labels.server}</MenuV2.GroupLabel>
|
||||
<MenuV2.Item
|
||||
disabled={builtin || props.server.type !== "http"}
|
||||
onSelect={() => props.onEdit(props.server as ServerConnection.Http)}
|
||||
disabled={builtin() || !httpServer()}
|
||||
onSelect={() => {
|
||||
const server = httpServer()
|
||||
if (server) props.onEdit(server)
|
||||
}}
|
||||
>
|
||||
{language.t("dialog.server.menu.edit")}
|
||||
{props.labels.edit}
|
||||
</MenuV2.Item>
|
||||
<Show when={props.controller.canDefault() && !isDefault()}>
|
||||
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
|
||||
{language.t("dialog.server.menu.default")}
|
||||
</MenuV2.Item>
|
||||
<Show when={props.canDefault && !props.isDefault}>
|
||||
<MenuV2.Item onSelect={props.onSetDefault}>{props.labels.default}</MenuV2.Item>
|
||||
</Show>
|
||||
<Show when={props.controller.canDefault() && isDefault()}>
|
||||
<MenuV2.Item onSelect={() => props.controller.setDefault(null)}>
|
||||
{language.t("dialog.server.menu.defaultRemove")}
|
||||
</MenuV2.Item>
|
||||
<Show when={props.canDefault && props.isDefault}>
|
||||
<MenuV2.Item onSelect={props.onRemoveDefault}>{props.labels.defaultRemove}</MenuV2.Item>
|
||||
</Show>
|
||||
<Show when={props.canRemove}>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Item onSelect={props.onRemove}>{props.labels.delete}</MenuV2.Item>
|
||||
</Show>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Item disabled={builtin} onSelect={() => props.controller.handleRemove(key)}>
|
||||
{language.t("dialog.server.menu.delete")}
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Group>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
|
||||
@@ -4,4 +4,3 @@ export { SortableTab, FileVisual } from "./session-sortable-tab"
|
||||
export { SortableTabV2 } from "./session-sortable-tab-v2"
|
||||
export { SortableTerminalTab } from "./session-sortable-terminal-tab"
|
||||
export { NewSessionView } from "./session-new-view"
|
||||
export { NewSessionDesignView } from "./session-new-design-view"
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { JSX } from "solid-js"
|
||||
import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2"
|
||||
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
||||
|
||||
export function NewSessionDesignView(props: { children: JSX.Element }) {
|
||||
return (
|
||||
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep ">
|
||||
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
|
||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
|
||||
<div class="mt-8">{props.children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -127,11 +127,14 @@ export const SettingsGeneral: Component = () => {
|
||||
const serverSdk = useServerSDK()
|
||||
|
||||
const [shells] = createResource(
|
||||
() =>
|
||||
serverSdk()
|
||||
.client.pty.shells()
|
||||
.then((res) => res.data ?? [])
|
||||
.catch(() => [] as ShellOption[]),
|
||||
async () => {
|
||||
const sdk = serverSdk()
|
||||
if ((await sdk.protocol) === "v1") {
|
||||
return (await sdk.client.pty.shells()).data ?? []
|
||||
}
|
||||
// return (await sdk.api.pty.shells()).data
|
||||
return [] as ShellOption[]
|
||||
},
|
||||
{ initialValue: [] as ShellOption[] },
|
||||
)
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ type KeybindMeta = {
|
||||
|
||||
type KeybindMap = Record<string, string | undefined>
|
||||
type CommandContext = ReturnType<typeof useCommand>
|
||||
type LanguageContext = ReturnType<typeof useLanguage>
|
||||
type SettingsContext = ReturnType<typeof useSettings>
|
||||
|
||||
const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"]
|
||||
|
||||
@@ -122,7 +124,7 @@ function keybinds(value: unknown): KeybindMap {
|
||||
return value as KeybindMap
|
||||
}
|
||||
|
||||
function listFor(command: CommandContext, map: KeybindMap, palette: string) {
|
||||
function listFor(command: Pick<CommandContext, "catalog" | "options">, map: KeybindMap, palette: string) {
|
||||
const out = new Map<string, KeybindMeta>()
|
||||
out.set(PALETTE_ID, { title: palette, group: "General" })
|
||||
|
||||
@@ -262,7 +264,274 @@ function useKeyCapture(input: {
|
||||
})
|
||||
}
|
||||
|
||||
export function createKeybindSettingsController(
|
||||
input: {
|
||||
command: Pick<CommandContext, "catalog" | "options" | "keybinds">
|
||||
settings: {
|
||||
current: { keybinds: unknown }
|
||||
keybinds: Pick<SettingsContext["keybinds"], "get" | "set" | "resetAll">
|
||||
}
|
||||
target?: Document
|
||||
notify?: (toast: { title: string; description: string }) => void
|
||||
},
|
||||
language: Pick<LanguageContext, "locale" | "t"> = useLanguage(),
|
||||
) {
|
||||
const [store, setStore] = createStore({ active: null as string | null })
|
||||
const overrides = createMemo(() => keybinds(input.settings.current.keybinds))
|
||||
const list = createMemo(() => {
|
||||
language.locale()
|
||||
return listFor(input.command, overrides(), language.t("command.palette"))
|
||||
})
|
||||
const grouped = createMemo(() => groupedFor(list()))
|
||||
const title = (id: string) => list().get(id)?.title ?? ""
|
||||
const effective = (id: string) => {
|
||||
if (id === PALETTE_ID) return input.settings.keybinds.get(id) ?? DEFAULT_PALETTE_KEYBIND
|
||||
|
||||
const custom = input.settings.keybinds.get(id)
|
||||
if (typeof custom === "string") return custom
|
||||
|
||||
const live = input.command.options.find((item) => item.id === id)
|
||||
if (live?.keybind) return live.keybind
|
||||
return input.command.catalog.find((item) => item.id === id)?.keybind
|
||||
}
|
||||
const used = createMemo(() => {
|
||||
const value = new Map<string, { id: string; title: string }[]>()
|
||||
|
||||
for (const id of list().keys()) {
|
||||
for (const signature of signatures(effective(id))) {
|
||||
const items = value.get(signature)
|
||||
if (items) {
|
||||
items.push({ id, title: title(id) })
|
||||
continue
|
||||
}
|
||||
value.set(signature, [{ id, title: title(id) }])
|
||||
}
|
||||
}
|
||||
|
||||
return value
|
||||
})
|
||||
const stop = () => {
|
||||
if (!store.active) return
|
||||
setStore("active", null)
|
||||
input.command.keybinds(true)
|
||||
}
|
||||
const toggle = (id: string) => {
|
||||
if (store.active === id) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
if (store.active) stop()
|
||||
setStore("active", id)
|
||||
input.command.keybinds(false)
|
||||
}
|
||||
const notify = input.notify ?? ((toast: { title: string; description: string }) => showToast(toast))
|
||||
|
||||
const handle = (event: KeyboardEvent) => {
|
||||
const id = store.active
|
||||
if (!id) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.stopImmediatePropagation()
|
||||
|
||||
if (event.key === "Escape") {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
const clear =
|
||||
(event.key === "Backspace" || event.key === "Delete") &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey
|
||||
if (clear) {
|
||||
input.settings.keybinds.set(id, "none")
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
const next = recordKeybind(event)
|
||||
if (!next) return
|
||||
|
||||
const conflicts = new Map<string, string>()
|
||||
for (const signature of signatures(next)) {
|
||||
for (const item of used().get(signature) ?? []) {
|
||||
if (item.id === id) continue
|
||||
conflicts.set(item.id, item.title)
|
||||
}
|
||||
}
|
||||
|
||||
if (conflicts.size > 0) {
|
||||
notify({
|
||||
title: language.t("settings.shortcuts.conflict.title"),
|
||||
description: language.t("settings.shortcuts.conflict.description", {
|
||||
keybind: formatKeybind(next, language.t),
|
||||
titles: [...conflicts.values()].join(", "),
|
||||
}),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
input.settings.keybinds.set(id, next)
|
||||
stop()
|
||||
}
|
||||
|
||||
const target = input.target ?? (typeof document === "object" ? document : undefined)
|
||||
if (target) makeEventListener(target, "keydown", handle, { capture: true })
|
||||
|
||||
onCleanup(() => {
|
||||
if (store.active) input.command.keybinds(true)
|
||||
})
|
||||
|
||||
return {
|
||||
catalog: {
|
||||
groups: GROUPS,
|
||||
filtered: (query: string) =>
|
||||
filteredFor(query, list(), grouped(), (id) => formatKeybind(effective(id) ?? "", language.t)),
|
||||
title,
|
||||
keybind: (id: string) => formatKeybind(effective(id) ?? "", language.t),
|
||||
},
|
||||
capture: {
|
||||
active: () => store.active,
|
||||
toggle,
|
||||
},
|
||||
settings: {
|
||||
hasOverrides: () => Object.values(overrides()).some((value) => typeof value === "string"),
|
||||
reset: () => {
|
||||
stop()
|
||||
input.settings.keybinds.resetAll()
|
||||
notify({
|
||||
title: language.t("settings.shortcuts.reset.toast.title"),
|
||||
description: language.t("settings.shortcuts.reset.toast.description"),
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function SettingsKeybindsV2() {
|
||||
const command = useCommand()
|
||||
const settings = useSettings()
|
||||
const controller = createKeybindSettingsController({
|
||||
command,
|
||||
settings,
|
||||
})
|
||||
|
||||
return (
|
||||
<SettingsKeybindsV2View
|
||||
groups={controller.catalog.groups}
|
||||
filtered={controller.catalog.filtered}
|
||||
title={controller.catalog.title}
|
||||
keybind={controller.catalog.keybind}
|
||||
active={controller.capture.active}
|
||||
onCapture={controller.capture.toggle}
|
||||
hasOverrides={controller.settings.hasOverrides}
|
||||
onReset={controller.settings.reset}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsKeybindsV2View(props: {
|
||||
groups: KeybindGroup[]
|
||||
filtered: (query: string) => Map<KeybindGroup, string[]>
|
||||
title: (id: string) => string
|
||||
keybind: (id: string) => string
|
||||
active: () => string | null
|
||||
onCapture: (id: string) => void
|
||||
hasOverrides: () => boolean
|
||||
onReset: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const [store, setStore] = createStore({ filter: "" })
|
||||
const filtered = createMemo(() => props.filtered(store.filter))
|
||||
const hasResults = createMemo(() => props.groups.some((group) => (filtered().get(group)?.length ?? 0) > 0))
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
||||
<div class="settings-v2-tab-header-row">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
|
||||
<ButtonV2 variant="ghost" onClick={props.onReset} disabled={!props.hasOverrides()}>
|
||||
{language.t("settings.shortcuts.reset.button")}
|
||||
</ButtonV2>
|
||||
</div>
|
||||
<div class="settings-v2-tab-search">
|
||||
<TextInputV2
|
||||
type="search"
|
||||
appearance="base"
|
||||
value={store.filter}
|
||||
onInput={(event) => setStore("filter", event.currentTarget.value)}
|
||||
placeholder={language.t("settings.shortcuts.search.placeholder")}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
aria-label={language.t("settings.shortcuts.search.placeholder")}
|
||||
/>
|
||||
<Show when={store.filter}>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
class="settings-v2-tab-search-clear"
|
||||
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
|
||||
onClick={() => setStore("filter", "")}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-v2-tab-body">
|
||||
<div class="settings-v2-shortcuts flex flex-col gap-8">
|
||||
<For each={props.groups}>
|
||||
{(group) => (
|
||||
<Show when={(filtered().get(group) ?? []).length > 0}>
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">{language.t(groupKey[group])}</h3>
|
||||
<SettingsListV2>
|
||||
<For each={filtered().get(group) ?? []}>
|
||||
{(id) => (
|
||||
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
||||
<span>{props.title(id)}</span>
|
||||
<button
|
||||
type="button"
|
||||
data-keybind-id={id}
|
||||
classList={{
|
||||
"settings-v2-keybind-button": true,
|
||||
"settings-v2-keybind-button--active": props.active() === id,
|
||||
}}
|
||||
onClick={() => props.onCapture(id)}
|
||||
>
|
||||
<Show
|
||||
when={props.active() === id}
|
||||
fallback={props.keybind(id) || language.t("settings.shortcuts.unassigned")}
|
||||
>
|
||||
{language.t("settings.shortcuts.pressKeys")}
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
<Show when={store.filter && !hasResults()}>
|
||||
<div class="settings-v2-shortcuts-status">
|
||||
<span>{language.t("settings.shortcuts.search.empty")}</span>
|
||||
<span class="settings-v2-shortcuts-status-filter">"{store.filter}"</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
|
||||
if (props.v2) return <SettingsKeybindsV2 />
|
||||
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
@@ -476,78 +745,37 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
|
||||
)
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={props.v2}
|
||||
fallback={
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
|
||||
<Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
|
||||
{language.t("settings.shortcuts.reset.button")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
|
||||
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
|
||||
<TextField
|
||||
variant="ghost"
|
||||
type="text"
|
||||
value={store.filter}
|
||||
onChange={(v) => setStore("filter", v)}
|
||||
placeholder={language.t("settings.shortcuts.search.placeholder")}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
class="flex-1"
|
||||
/>
|
||||
<Show when={store.filter}>
|
||||
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{groups}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<>
|
||||
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
||||
<div class="settings-v2-tab-header-row">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
|
||||
<ButtonV2 variant="ghost" onClick={resetAll} disabled={!hasOverrides()}>
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
|
||||
<Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
|
||||
{language.t("settings.shortcuts.reset.button")}
|
||||
</ButtonV2>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="settings-v2-tab-search">
|
||||
<TextInputV2
|
||||
type="search"
|
||||
appearance="base"
|
||||
|
||||
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
|
||||
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
|
||||
<TextField
|
||||
variant="ghost"
|
||||
type="text"
|
||||
value={store.filter}
|
||||
onInput={(event) => setStore("filter", event.currentTarget.value)}
|
||||
onChange={(v) => setStore("filter", v)}
|
||||
placeholder={language.t("settings.shortcuts.search.placeholder")}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
aria-label={language.t("settings.shortcuts.search.placeholder")}
|
||||
class="flex-1"
|
||||
/>
|
||||
<Show when={store.filter}>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
class="settings-v2-tab-search-clear"
|
||||
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
|
||||
onClick={() => setStore("filter", "")}
|
||||
/>
|
||||
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-v2-tab-body">{groups}</div>
|
||||
</>
|
||||
</Show>
|
||||
</div>
|
||||
{groups}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { showToast } from "@/utils/toast"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { createMemo, type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
|
||||
import { DialogCustomProvider } from "./dialog-custom-provider"
|
||||
@@ -39,8 +39,9 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const protocol = useServerProtocol()
|
||||
const serverSync = useServerSync()
|
||||
const providers = useProviders()
|
||||
const providers = useProviders(() => undefined)
|
||||
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
||||
|
||||
const connect = (provider?: string) => {
|
||||
@@ -83,7 +84,8 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||
return language.t("settings.providers.tag.other")
|
||||
}
|
||||
|
||||
const canDisconnect = (item: ProviderItem) => source(item) !== "env"
|
||||
const canDisconnect = (item: ProviderItem) =>
|
||||
source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id))
|
||||
|
||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||
|
||||
@@ -96,6 +98,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||
}
|
||||
|
||||
const disableProvider = async (providerID: string, name: string) => {
|
||||
if (protocol() !== "v1") return
|
||||
const before = serverSync().data.config.disabled_providers ?? []
|
||||
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||
serverSync().set("config", "disabled_providers", next)
|
||||
@@ -218,31 +221,33 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between gap-4 min-h-16 border-b border-border-weak-base last:border-none flex-wrap py-3"
|
||||
data-component="custom-provider-section"
|
||||
>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<ProviderIcon id="session.synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||
<span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span>
|
||||
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
||||
</div>
|
||||
<span class="text-12-regular text-text-weak pl-8">
|
||||
{language.t("settings.providers.custom.description")}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="large"
|
||||
variant="secondary"
|
||||
icon="plus-small"
|
||||
onClick={() => {
|
||||
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
|
||||
}}
|
||||
<Show when={protocol() === "v1"}>
|
||||
<div
|
||||
class="flex items-center justify-between gap-4 min-h-16 border-b border-border-weak-base last:border-none flex-wrap py-3"
|
||||
data-component="custom-provider-section"
|
||||
>
|
||||
{language.t("common.connect")}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||
<span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span>
|
||||
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
||||
</div>
|
||||
<span class="text-12-regular text-text-weak pl-8">
|
||||
{language.t("settings.providers.custom.description")}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="large"
|
||||
variant="secondary"
|
||||
icon="plus-small"
|
||||
onClick={() => {
|
||||
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
|
||||
}}
|
||||
>
|
||||
{language.t("common.connect")}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</SettingsList>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, createSignal, startTransition } from "solid-js"
|
||||
import { Component, createMemo, createSignal, startTransition } from "solid-js"
|
||||
import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
@@ -11,6 +11,9 @@ import { SettingsModelsV2 } from "./models"
|
||||
import "./settings-v2.css"
|
||||
import { SettingsServersV2 } from "./servers"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
|
||||
export const DialogSettings: Component<{
|
||||
sessionID?: string
|
||||
@@ -19,7 +22,20 @@ export const DialogSettings: Component<{
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const layout = useLayout()
|
||||
const tabs = useTabs()
|
||||
const serverSync = useServerSync()
|
||||
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
|
||||
const directory = createMemo(() => {
|
||||
const route = layout.route()
|
||||
if (route.type === "dir-new-sesssion") return route.dir
|
||||
if (route.type === "draft") {
|
||||
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
|
||||
return draft?.type === "draft" ? draft.directory : undefined
|
||||
}
|
||||
if (route.type === "session") return serverSync().session.get(route.sessionId)?.directory
|
||||
return undefined
|
||||
})
|
||||
|
||||
const showProviders = () => {
|
||||
void dialog.show(() => <DialogSettings sessionID={props.sessionID} defaultValue="providers" />)
|
||||
@@ -87,7 +103,7 @@ export const DialogSettings: Component<{
|
||||
<SettingsServersV2 />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="providers" class="settings-v2-panel">
|
||||
<SettingsProvidersV2 onBack={showProviders} />
|
||||
<SettingsProvidersV2 directory={directory} onBack={showProviders} />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="models" class="settings-v2-panel">
|
||||
<SettingsModelsV2 />
|
||||
|
||||
@@ -122,11 +122,14 @@ export const SettingsGeneralV2: Component<{
|
||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||
|
||||
const [shells] = createResource(
|
||||
() =>
|
||||
serverSdk()
|
||||
.client.pty.shells()
|
||||
.then((res) => res.data ?? [])
|
||||
.catch(() => [] as ShellOption[]),
|
||||
async () => {
|
||||
const sdk = serverSdk()
|
||||
if ((await sdk.protocol) === "v1") {
|
||||
return (await sdk.client.pty.shells()).data ?? []
|
||||
}
|
||||
// return (await sdk.api.pty.shells()).data
|
||||
return [] as ShellOption[]
|
||||
},
|
||||
{ initialValue: [] as ShellOption[] },
|
||||
)
|
||||
|
||||
|
||||
@@ -5,9 +5,12 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { type Component, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useModels } from "@/context/models"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { popularProviders } from "@/hooks/use-providers"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { SettingsRowV2 } from "./parts/row"
|
||||
import "./settings-v2.css"
|
||||
@@ -19,6 +22,11 @@ const PROVIDER_ICON_SIZE = 16
|
||||
export const SettingsModelsV2: Component = () => {
|
||||
const language = useLanguage()
|
||||
const models = useModels()
|
||||
const serverSdk = useServerSDK()
|
||||
const [store, setStore] = persisted(
|
||||
Persist.serverGlobal(serverSdk().scope, "settings-v2.models.providers"),
|
||||
createStore({ collapsed: {} as Record<string, boolean> }),
|
||||
)
|
||||
|
||||
const list = useFilteredList<ModelItem>({
|
||||
items: (_filter) => models.list(),
|
||||
@@ -94,41 +102,82 @@ export const SettingsModelsV2: Component = () => {
|
||||
}
|
||||
>
|
||||
<For each={list.grouped.latest}>
|
||||
{(group) => (
|
||||
<div class="settings-v2-section" data-component="settings-models-provider">
|
||||
<div class="settings-v2-models-group-header">
|
||||
<ProviderIcon
|
||||
id={group.category}
|
||||
width={PROVIDER_ICON_SIZE}
|
||||
height={PROVIDER_ICON_SIZE}
|
||||
class="settings-v2-models-provider-icon shrink-0"
|
||||
/>
|
||||
<h3 class="settings-v2-section-title">{group.items[0].provider.name}</h3>
|
||||
{(group) => {
|
||||
const searching = () => list.filter().length > 0
|
||||
const expanded = () => searching() || !store.collapsed[group.category]
|
||||
|
||||
return (
|
||||
<div
|
||||
class="settings-v2-section"
|
||||
data-component="settings-models-provider"
|
||||
data-expanded={expanded() ? "" : undefined}
|
||||
>
|
||||
<h3 class="settings-v2-models-group-header">
|
||||
<button
|
||||
type="button"
|
||||
class="settings-v2-models-group-trigger"
|
||||
aria-expanded={expanded()}
|
||||
disabled={searching()}
|
||||
onClick={() => setStore("collapsed", group.category, expanded())}
|
||||
>
|
||||
<span class="settings-v2-models-group-chevron">
|
||||
<Show
|
||||
when={expanded()}
|
||||
fallback={
|
||||
<svg width="5" height="6" viewBox="0 0 5 6" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M0.75194 5.31663C0.41861 5.51103 0 5.27063 0 4.88473V0.500754C0 0.114854 0.41861 -0.125577 0.75194 0.0688635L4.5096 2.26084C4.8404 2.45378 4.8404 2.93168 4.5096 3.12462L0.75194 5.31663Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M5.37624 6.75194C5.18184 6.41861 5.42224 6 5.80814 6H10.1921C10.578 6 10.8184 6.41861 10.624 6.75194L8.43203 10.5096C8.23909 10.8404 7.76119 10.8404 7.56825 10.5096L5.37624 6.75194Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</Show>
|
||||
</span>
|
||||
<span class="settings-v2-models-group-label">
|
||||
<ProviderIcon
|
||||
id={group.category}
|
||||
width={PROVIDER_ICON_SIZE}
|
||||
height={PROVIDER_ICON_SIZE}
|
||||
class="settings-v2-models-provider-icon shrink-0"
|
||||
/>
|
||||
<span class="settings-v2-section-title">{group.items[0].provider.name}</span>
|
||||
</span>
|
||||
</button>
|
||||
</h3>
|
||||
<Show when={expanded()}>
|
||||
<SettingsListV2>
|
||||
<For each={group.items}>
|
||||
{(item) => {
|
||||
const key = { providerID: item.provider.id, modelID: item.id }
|
||||
return (
|
||||
<SettingsRowV2 title={item.name} description="">
|
||||
<div>
|
||||
<Switch
|
||||
checked={models.visible(key)}
|
||||
onChange={(checked) => {
|
||||
models.setVisibility(key, checked)
|
||||
}}
|
||||
hideLabel
|
||||
>
|
||||
{item.name}
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</SettingsListV2>
|
||||
</Show>
|
||||
</div>
|
||||
<SettingsListV2>
|
||||
<For each={group.items}>
|
||||
{(item) => {
|
||||
const key = { providerID: item.provider.id, modelID: item.id }
|
||||
return (
|
||||
<SettingsRowV2 title={item.name} description="">
|
||||
<div>
|
||||
<Switch
|
||||
checked={models.visible(key)}
|
||||
onChange={(checked) => {
|
||||
models.setVisibility(key, checked)
|
||||
}}
|
||||
hideLabel
|
||||
>
|
||||
{item.name}
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
@@ -4,9 +4,9 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { createMemo, type Component, For, Show } from "solid-js"
|
||||
import { createMemo, type Accessor, type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
|
||||
import { DialogCustomProvider } from "../dialog-custom-provider"
|
||||
@@ -29,17 +29,21 @@ const PROVIDER_NOTES = [
|
||||
|
||||
const PROVIDER_ICON_SIZE = 16
|
||||
|
||||
export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) => {
|
||||
export const SettingsProvidersV2: Component<{
|
||||
directory: Accessor<string | undefined>
|
||||
onBack?: () => void
|
||||
}> = (props) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const serverSdk = useServerSDK()
|
||||
const protocol = useServerProtocol()
|
||||
const serverSync = useServerSync()
|
||||
const providers = useProviders()
|
||||
const providers = useProviders(props.directory)
|
||||
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
||||
|
||||
const connect = (provider?: string) => {
|
||||
providerConnect.select(provider)
|
||||
void dialog.show(() => <DialogConnectProvider controller={providerConnect} />)
|
||||
void dialog.show(() => <DialogConnectProvider directory={props.directory} controller={providerConnect} />)
|
||||
}
|
||||
|
||||
const connected = createMemo(() => {
|
||||
@@ -77,7 +81,8 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
|
||||
return language.t("settings.providers.tag.other")
|
||||
}
|
||||
|
||||
const canDisconnect = (item: ProviderItem) => source(item) !== "env"
|
||||
const canDisconnect = (item: ProviderItem) =>
|
||||
source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id))
|
||||
|
||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||
|
||||
@@ -90,6 +95,7 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
|
||||
}
|
||||
|
||||
const disableProvider = async (providerID: string, name: string) => {
|
||||
if (protocol() !== "v1") return
|
||||
const before = serverSync().data.config.disabled_providers ?? []
|
||||
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||
serverSync().set("config", "disabled_providers", next)
|
||||
@@ -218,33 +224,37 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
|
||||
)}
|
||||
</For>
|
||||
|
||||
<div class="settings-v2-provider-row" data-component="custom-provider-section">
|
||||
<div class="settings-v2-provider-lead">
|
||||
<ProviderIcon
|
||||
id="session.synthetic"
|
||||
width={PROVIDER_ICON_SIZE}
|
||||
height={PROVIDER_ICON_SIZE}
|
||||
class="settings-v2-provider-icon shrink-0"
|
||||
/>
|
||||
<div class="settings-v2-provider-copy">
|
||||
<div class="settings-v2-provider-main">
|
||||
<span class="settings-v2-provider-name">{language.t("provider.custom.title")}</span>
|
||||
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
||||
<Show when={protocol() === "v1"}>
|
||||
<div class="settings-v2-provider-row" data-component="custom-provider-section">
|
||||
<div class="settings-v2-provider-lead">
|
||||
<ProviderIcon
|
||||
id="synthetic"
|
||||
width={PROVIDER_ICON_SIZE}
|
||||
height={PROVIDER_ICON_SIZE}
|
||||
class="settings-v2-provider-icon shrink-0"
|
||||
/>
|
||||
<div class="settings-v2-provider-copy">
|
||||
<div class="settings-v2-provider-main">
|
||||
<span class="settings-v2-provider-name">{language.t("provider.custom.title")}</span>
|
||||
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
||||
</div>
|
||||
<p class="settings-v2-provider-description">
|
||||
{language.t("settings.providers.custom.description")}
|
||||
</p>
|
||||
</div>
|
||||
<p class="settings-v2-provider-description">{language.t("settings.providers.custom.description")}</p>
|
||||
</div>
|
||||
<ButtonV2
|
||||
size="normal"
|
||||
variant="neutral"
|
||||
icon="plus"
|
||||
onClick={() => {
|
||||
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
|
||||
}}
|
||||
>
|
||||
{language.t("common.connect")}
|
||||
</ButtonV2>
|
||||
</div>
|
||||
<ButtonV2
|
||||
size="normal"
|
||||
variant="neutral"
|
||||
icon="plus"
|
||||
onClick={() => {
|
||||
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
|
||||
}}
|
||||
>
|
||||
{language.t("common.connect")}
|
||||
</ButtonV2>
|
||||
</div>
|
||||
</Show>
|
||||
</SettingsListV2>
|
||||
|
||||
<button type="button" class="settings-v2-providers-view-all" onClick={() => connect()}>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user