Compare commits

...
Author SHA1 Message Date
usrnk1 f5d87ae5a9 Merge branch 'v2' into worktree-ui 2026-09-04 17:20:18 +02:00
usrnk1 bd54dc508f feat(desktop): add dropzone to new sessions (#47303) 2026-09-04 17:19:05 +02:00
usrnk1 be6aba9482 fix(desktop): unify worktree container padding 2026-09-04 17:07:15 +02:00
usrnk1 b74c2cf143 fix(desktop): tune worktree settings spacing 2026-09-04 17:04:33 +02:00
usrnk1 2769d69420 feat(desktop): refine worktree experience 2026-09-04 17:00:16 +02:00
usrnk1 405cd21314 feat(ui): add worktree visual variants 2026-09-04 17:00:08 +02:00
Shoubhit Dash 0b3c45c948 feat(ai): add streamed compaction triggers (#47082) 2026-09-04 14:00:02 +00:00
Shoubhit Dash 689a354a43 fix(ai): preserve websocket replay baselines (#47291) 2026-09-04 13:46:00 +00:00
Shoubhit Dash acb462a173 fix(tui): restore agent and model selection parity (#47260) 2026-09-04 18:16:04 +05:30
opencode-agent[bot] e26cbb935e chore: update nix node_modules hashes 2026-09-04 12:21:18 +00:00
Luke Parker fa6dd9df8f chore: upgrade Bun to 1.4.1 and re-enable bytecode (#47271) 2026-09-04 22:01:12 +10:00
opencode-agent[bot]andBrendonovich b52b79d28f fix(app): align file tool disclosures with Patch (#47269)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-04 19:47:57 +08:00
Aarav Sareen 632b65854c fix(app): adjust sidebar and settings spacing (#47263) 2026-09-04 09:50:30 +00:00
opencode-agent[bot]andBrendonovich ed877cfebc fix(app): expose skills in slash commands (#47247)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-04 17:40:39 +08:00
opencode-agent[bot]andBrendonovich 3a08d95907 fix(app): preserve thinking trigger visibility invariant (#47256)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-04 09:34:18 +00:00
b09a74591c fix(app): omit notices from Used summary (#47245)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-09-04 09:13:00 +00:00
Brendan Allan e8481973ce refactor(app): make session composer ownership explicit (#47254) 2026-09-04 09:07:47 +00:00
usrnk1 211cd73f1a feat(desktop): update composer dropzone (#42312) 2026-09-04 16:45:42 +08:00
opencode-agent[bot]andBrendonovich 2375e81bd6 fix(app): match optimistic session heading to tab label (#47244)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-04 08:07:24 +00:00
103 changed files with 3612 additions and 938 deletions
+2 -2
View File
@@ -91,7 +91,7 @@ jobs:
- uses: ./.github/actions/setup-bun
with:
bun-version: 1.4.0
bun-version: 1.4.1
- name: Setup git committer
id: committer
@@ -113,7 +113,7 @@ jobs:
id: build
run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
env:
BUN_COMPILE_RELEASE: bun-v1.4.0
BUN_COMPILE_RELEASE: bun-v1.4.1
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
GH_REPO: ${{ needs.version.outputs.repo }}
+4 -9
View File
@@ -1065,6 +1065,7 @@
"@opentui/solid": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"bun-types": "1.4.1",
"effect": "catalog:",
"solid-js": "catalog:",
},
@@ -1102,7 +1103,7 @@
"@tanstack/solid-virtual": "3.13.37",
"@tsconfig/bun": "1.0.9",
"@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.13",
"@types/bun": "1.4.0",
"@types/cross-spawn": "6.0.6",
"@types/luxon": "3.7.1",
"@types/node": "24.12.2",
@@ -3032,7 +3033,7 @@
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
"@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
"@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="],
"@types/cacache": ["@types/cacache@20.0.1", "", { "dependencies": { "@types/node": "*", "minipass": "*" } }, "sha512-QlKW3AFoFr/hvPHwFHMIVUH/ZCYeetBNou3PCmxu5LaNDvrtBlPJtIA6uhmU9JRt9oxj7IYoqoLcpxtzpPiTcw=="],
@@ -3484,7 +3485,7 @@
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
"bun-types": ["bun-types@1.4.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-loKuVrAFZKfEv+JvWkHRS9GW5IqLuLRjVXN9p+vZvBN86O5hf/pBZQ5hSoyipsrMmWObZBDvWnlmKvjKTM0PdA=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@@ -6340,10 +6341,6 @@
"@solidjs/start/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="],
"@standard-community/standard-json/effect": ["effect@4.0.0-rc.111", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA=="],
"@standard-community/standard-openapi/effect": ["effect@4.0.0-rc.111", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA=="],
"@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
@@ -6922,8 +6919,6 @@
"@brendonovich/vite-plugin-opencode/@opencode-ai/client/@opencode-ai/schema": ["@opencode-ai/schema@0.0.0-beta-18050", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.111" } }, "sha512-/D6VXaWlytTXR3IOiMLIKuPcfp7FQNUzRPm9z3K7UBFd1Bw4q/WZksaf5RVcBGz+0YRxYMc1V4D7MFlceSgtyg=="],
"@brendonovich/vite-plugin-opencode/@opencode-ai/client/effect": ["effect@4.0.0-rc.111", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA=="],
"@bruits/satteri-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
"@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-I2sHU46Bdp1DmW/ygNKKxF6U53oNIeVeAS8TmMdtvzw=",
"aarch64-linux": "sha256-aQUEBmfHhaR/YdsRyw0u6hsEo0TEmXHBMyiGnOFRCeI=",
"aarch64-darwin": "sha256-40Y3HYSjs8S0M3SFNUrU8lMYaHy7vpmiW+7b6Y9pKzs=",
"x86_64-darwin": "sha256-7iGegI/1pXcR5W0UBX0wAWAga+F34fhj9lbAHotVwEo="
"x86_64-linux": "sha256-J7xJ1+x+f13zso+UMP1YYzkjFM0LiHZURmf8HhO3X4M=",
"aarch64-linux": "sha256-CF7BiObbwjFT4BV5FUqB535fqoPuXuExKpERcev/Re0=",
"aarch64-darwin": "sha256-ts3zgHiPeQcrehST1ikr+BNwlF+3JDn0/IUeJSAOB3o=",
"x86_64-darwin": "sha256-awKiMBuDMrapw3Lrm39TbE+ILJZ1P8R8sqYJEWapSf8="
}
}
+3 -2
View File
@@ -5,7 +5,7 @@
"version": "0.0.0",
"private": true,
"type": "module",
"packageManager": "bun@1.4.0",
"packageManager": "bun@1.4.1",
"scripts": {
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
@@ -43,7 +43,7 @@
"@effect/platform-node-shared": "4.0.0-rc.112",
"@effect/sql-sqlite-bun": "4.0.0-rc.112",
"@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.13",
"@types/bun": "1.4.0",
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
@@ -159,6 +159,7 @@
"@effect/platform-node-shared": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"bun-types": "1.4.1",
"effect": "catalog:",
"solid-js": "catalog:"
},
+38 -1
View File
@@ -247,6 +247,8 @@ it does not repair or truncate them.
For explicit compaction, script a `CompactionResponse` through `push`, `always`, or `serve`. Its `replacement` contains the next context window, including retained user messages. The client returns that result and usage directly, with the same lazy request recording and gates. Generation and compaction reject fixtures for the wrong operation instead of converting between response shapes.
For `compact(request, { mechanism: "trigger" })`, script a `CompactionCheckpointResponse` instead. It carries `checkpoint`, `responseID`, and optional `usage`. Endpoint and trigger calls reject each other's fixtures; both share the same queue, gates, lazy recording, and fallback controls.
The published legacy `Service`, `layer`, `clientLayer`, and module-level controls remain available as adapters
over the same implementation, including the legacy live `requests` array. New tests should use `Test` and
`testLayer`.
@@ -259,7 +261,7 @@ This is different from prompt caching, server-side history storage, or truncatio
### Explicit compaction
`LLMClient.compact(request)` is the caller-controlled operation for OpenAI, Azure, and xAI Responses. It performs exactly one HTTP call to `/responses/compact`, using the selected route's endpoint, credentials, query, and HTTP middleware. It returns a `CompactionResponse` with `replacement: Message[]` and optional `usage`, not a normal generation response.
`LLMClient.compact(request)` (equivalently, `{ mechanism: "endpoint" }`) is the caller-controlled operation for OpenAI, Azure, and xAI Responses. It performs exactly one HTTP call to `/responses/compact`, using the selected route's endpoint, credentials, query, and HTTP middleware. It returns a `CompactionResponse` with `replacement: Message[]` and optional `usage`, not a normal generation response. This mechanism does not accept a WebSocket executor.
Prefer this operation, where supported, when the application owns compaction policy and durable context updates.
@@ -279,6 +281,41 @@ Generation-only body overlays such as `stream` and `store` are not sent to the c
The input must still fit the model's context window. Explicit compaction is not an overflow-recovery operation. Anthropic does not expose this operation in this package; its in-band compaction remains available below. Compatible routes do not inherit an explicit compact endpoint simply because they use a Responses protocol.
### Streamed checkpoint compaction
OpenAI Responses also exposes a separate, explicitly selected mechanism:
```ts
const result =
yield *
LLMClient.compact(request, {
mechanism: "trigger",
webSocket, // Optional: without it, the request uses HTTP/SSE.
})
result.checkpoint // Successful encrypted CompactionPart.
result.responseID
result.usage
```
This appends a native `compaction_trigger` control item to the full input and sends a normal Responses request. It follows the [Codex V2 request shape](https://github.com/openai/codex/blob/728cb12/codex-rs/core/src/compact_remote_v2_attempt.rs), with tools and instructions retained, `stream: true`, `store: false`, and parallel tool calls enabled. It removes normal-answer text/output-format controls, forced tool choices, output-token/tool-call limits, and automatic `context_management`. Body overlays cannot replace `input` or supply `previous_response_id`/`conversation`; the complete canonical history is required for safe stateless replay. Session/cache identifiers, auth, headers, query parameters, service tier, and supported prompt-cache settings are preserved.
Only a successful `response.completed` with a response ID and exactly one logical encrypted checkpoint succeeds. Repeated item events are correlated by ID/output slot, including ID-less checkpoints. Other output is ignored, not returned as assistant text or dispatched as tools. Failed, incomplete, malformed, and interrupted responses return errors rather than partial checkpoints.
The result is **not a replacement window**. The caller selects retained history, combines it with `result.checkpoint`, and durably installs it before continuing. The operation does not choose a retention budget, prune messages, or modify the original request.
The supplied WebSocket executor can reuse a compatible append baseline for the compaction request. On completion the protocol supplies no continuation checkpoint, clearing the old baseline so the next generation sends the newly installed window in full. Validation occurs before transport completion is acknowledged. There is no operation-level retry or fallback to `/responses/compact`; existing safe transport fallback may use SSE, with full history and no connection-local response ID.
Trigger support is separate from endpoint support. Only the OpenAI Responses route advertises it; Azure, xAI, Chat, and compatible Responses routes do not inherit it. Untyped calls still fail before sending: missing route capabilities return `UnsupportedOperation`, while unknown mechanism names and invalid inputs return `InvalidRequest`. Dynamic callers must narrow for the selected mechanism:
```ts
if (LLMClient.canCompact(request, { mechanism: "trigger" })) {
const result = yield * LLMClient.compact(request, { mechanism: "trigger" })
}
```
This capability describes protocol implementation, **not universal availability on OpenAI API deployments**. The host application owns subscription/deployment eligibility, OAuth, endpoint selection, and deployment-specific headers. Local protocol/socket tests do not establish live provider support.
### Advanced: in-band compaction
`providerOptions.contextManagement` lets the provider decide when to compact during an ordinary `generate` or `stream` call. This is an advanced option for callers that own persistence and recovery: persist the complete assistant message, including its checkpoint, before continuing. Enabling the option does not provide durable checkpoint storage, interruption recovery, or model-switch policy. Keep the prior context until a successful checkpoint has been persisted.
@@ -42,6 +42,7 @@ const canonical = (value: unknown): string => {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`
if (!ProviderShared.isRecord(value)) return ProviderShared.encodeJson(value)
return `{${Object.keys(value)
.filter((key) => value[key] !== undefined)
.sort()
.map((key) => `${ProviderShared.encodeJson(key)}:${canonical(value[key])}`)
.join(",")}}`
@@ -57,7 +58,12 @@ const comparable = (value: unknown) => {
if (value.type === "message" && value.role === "assistant")
return {
role: "assistant",
content: value.content,
// Annotations and logprobs describe the response, not the text replayed in model input.
content: Array.isArray(value.content)
? value.content.map((part) =>
ProviderShared.isRecord(part) && part.type === "output_text" ? { type: part.type, text: part.text } : part,
)
: value.content,
...(value.phase === undefined ? {} : { phase: value.phase }),
}
if (value.type === "function_call")
@@ -121,7 +127,7 @@ const rejected = (
export const driver = (input: DriverInput): WebSocketChannelDriver => {
const { previous_response_id: _previousResponseID, ...request } = input.request
let output: unknown[] = []
let output: OpenResponses.StreamItem[] = []
return {
create: (checkpoint) =>
Effect.sync(() => {
@@ -149,6 +155,12 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
if (rejection === "websocket_connection_limit_reached") return rejected(observation, "rotate-and-retry-full")
}
if (observation.type !== "completed") return observation
// A trigger installs a different context window. Clear the append baseline, retaining the socket.
if (
Array.isArray(request.input) &&
request.input.some((item) => ProviderShared.isRecord(item) && item.type === "compaction_trigger")
)
return observation
const responseID = event.response?.id
if (!responseID || responseID.trim().length === 0) return observation
return {
@@ -159,7 +171,14 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
version: VERSION,
responseID,
request,
output: event.response?.output ? [...event.response.output] : output.slice(),
// Completion can re-encrypt reasoning. Callers replay the item already emitted by output_item.done.
output: event.response?.output
? event.response.output.map((item) =>
item.type === "reasoning" && item.id !== undefined
? (output.find((done) => done.type === item.type && done.id === item.id) ?? item)
: item,
)
: output.slice(),
} satisfies CheckpointValue,
},
}
+7 -3
View File
@@ -923,7 +923,7 @@ const joinReasoningText = (parts: ReadonlyArray<string | undefined>) => {
return parts.filter((part) => part !== undefined).join("\n\n")
}
const outputItemID = (state: ParserState, event: Event) =>
const outputItemID = (state: Pick<ParserState, "outputItems">, event: Event) =>
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
const ITEM_ID_PREFIX: Readonly<Record<string, string>> = {
@@ -935,7 +935,11 @@ const ITEM_ID_PREFIX: Readonly<Record<string, string>> = {
// An item without an id adopts the id already open in its output slot,
// otherwise it gets a locally minted one.
const resolveItem = (state: ParserState, item: StreamItem, index: number | undefined): OutputItem => ({
const resolveItem = (
state: Pick<ParserState, "outputItems">,
item: StreamItem,
index: number | undefined,
): OutputItem => ({
...item,
id:
item.id ??
@@ -945,7 +949,7 @@ const resolveItem = (state: ParserState, item: StreamItem, index: number | undef
// Registered output slots are authoritative for `item_id` routing, and items
// are resolved here so everything downstream can rely on `item.id`.
export const normalize = (state: ParserState, input: Event): NormalizedEvent => ({
export const normalize = (state: Pick<ParserState, "outputItems">, input: Event): NormalizedEvent => ({
...input,
item_id: input.item_id === undefined ? undefined : outputItemID(state, input),
item: input.item ? resolveItem(state, input.item, input.output_index) : input.item,
+44 -2
View File
@@ -5,7 +5,7 @@ import { Auth } from "../route/auth.js"
import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js"
import { HttpTransport } from "../route/transport/index.js"
import type { LLMRequest, JsonSchema, ToolDefinition, ToolEntry } from "../schema/index.js"
import { LLMRequest, mergeJsonRecords, type JsonSchema, type ToolDefinition, type ToolEntry } from "../schema/index.js"
import { OpenResponses } from "./open-responses.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { OpenAIImage } from "./utils/openai-image.js"
@@ -13,6 +13,7 @@ import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { OpenResponsesChannel } from "./open-responses-channel.js"
import { ResponsesCompaction } from "./utils/responses-compaction.js"
import { ResponsesCheckpoint } from "./utils/responses-checkpoint.js"
const ADAPTER = "openai-responses"
const NAME = "OpenAI Responses"
@@ -114,6 +115,18 @@ const OpenAIResponsesBody = Schema.Struct({
})
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
/** Request control, never conversation content. */
export const CompactionTrigger = Schema.Struct({ type: Schema.Literal("compaction_trigger") })
const CheckpointBody = Schema.Struct({
...OpenAIResponsesBody.fields,
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem, CompactionTrigger])),
store: Schema.Literal(false),
prompt_cache_retention: optionalNull(Schema.String),
prompt_cache_options: optionalNull(
Schema.Struct({ mode: Schema.optional(Schema.String), ttl: Schema.optional(Schema.String) }),
),
})
const adapter = {
id: ADAPTER,
name: NAME,
@@ -191,6 +204,35 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
})
})
const checkpointBody = {
schema: CheckpointBody,
from: Effect.fn("OpenAIResponses.checkpointBody")(function* (request: LLMRequest) {
const native = yield* fromRequest(LLMRequest.update(request, { toolChoice: undefined }))
const overlay = request.http?.body
// Complete history is required for stateless replay and SSE recovery. Raw input overrides bypass that contract.
if (
overlay?.input !== undefined ||
overlay?.previous_response_id !== undefined ||
overlay?.conversation !== undefined
)
return yield* ProviderShared.invalidRequest(
"Trigger compaction requires complete canonical history, not an input or continuation override",
)
return yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(CheckpointBody))({
...mergeJsonRecords(native, overlay),
input: [...native.input, { type: "compaction_trigger" }],
stream: true,
store: false,
parallel_tool_calls: true,
tool_choice: undefined,
context_management: undefined,
text: undefined,
max_output_tokens: undefined,
max_tool_calls: undefined,
})
}),
}
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: ResponsesHostedTools.Item) {
const isError = item.error !== undefined && item.error !== null
if (item.type === "image_generation_call" && item.result) {
@@ -268,7 +310,7 @@ export const transport = channelTransport({
})
export const route = Route.make({
compact: ResponsesCompaction.make(adapter),
compact: { endpoint: ResponsesCompaction.make(adapter), trigger: ResponsesCheckpoint.make(checkpointBody) },
id: ADAPTER,
provider: "openai",
providerMetadataKey: "openai",
@@ -0,0 +1,122 @@
import { Effect, Schema, Stream } from "effect"
import { Route, type RouteBody, type TriggerCompactOperation } from "../../route/client.js"
import { Protocol } from "../../route/protocol.js"
import { CompactionCheckpointResponse, HttpOptions, LLMEvent, LLMRequest } from "../../schema/index.js"
import { OpenResponses } from "../open-responses.js"
import { ProviderShared } from "../shared.js"
interface State {
readonly parser: Pick<OpenResponses.ParserState, "id" | "provider" | "outputItems">
readonly checkpoints: Readonly<Record<string, CompactionCheckpointResponse["checkpoint"]>>
readonly responseID?: string
}
const onOutputItem = Effect.fn("ResponsesCheckpoint.onOutputItem")(function* (
state: State,
input: OpenResponses.Event,
) {
const event = OpenResponses.normalize(state.parser, input)
const item = event.item
if (!item) return state
const parser =
event.output_index === undefined || state.parser.outputItems[event.output_index] === item.id
? state.parser
: { ...state.parser, outputItems: { ...state.parser.outputItems, [event.output_index]: item.id } }
const next = parser === state.parser ? state : { ...state, parser }
if (event.type === "response.output_item.added" || item.type !== "compaction") return next
if (
event.output_index !== undefined &&
Object.entries(state.parser.outputItems).some(
([index, id]) => id === item.id && Number(index) !== event.output_index,
)
)
return yield* ProviderShared.eventError(parser.id, "Compaction checkpoint appeared in multiple output slots")
if (!item.encrypted_content)
return yield* ProviderShared.eventError(parser.id, "Compaction output is missing its encrypted content")
const previous = state.checkpoints[item.id]
if (previous && previous.encrypted !== item.encrypted_content)
return yield* ProviderShared.eventError(parser.id, "Compaction output changed after completion")
if (previous) return next
return {
...next,
checkpoints: {
...state.checkpoints,
[item.id]: { type: "compaction", provider: parser.provider, id: item.id, encrypted: item.encrypted_content },
},
} satisfies State
})
/** Collect a trigger response before acknowledging transport completion. No generation output escapes. */
export const make = <Body>(body: RouteBody<Body>): TriggerCompactOperation =>
Effect.fn("ResponsesCheckpoint.execute")(function* (request, executor, options) {
const source = request.model.route
let result: CompactionCheckpointResponse | undefined
// Route registries erase the frame type. The codec validates that boundary before parsing.
const event: Schema.Codec<OpenResponses.Event, unknown> = OpenResponses.protocol.stream.event
const protocol = Protocol.make({
id: source.protocol,
body,
stream: {
event,
initial: (request: LLMRequest): State => ({
parser: { id: source.id, provider: request.model.provider, outputItems: {} },
checkpoints: {},
}),
terminal: OpenResponses.terminal,
step: Effect.fn("ResponsesCheckpoint.step")(function* (state: State, event: OpenResponses.Event) {
if (event.response?.id && state.responseID && event.response.id !== state.responseID)
return yield* ProviderShared.eventError(source.id, "Compaction response ID changed during execution")
if (event.type === "response.created") return [{ ...state, responseID: event.response?.id }, []] as const
if (event.type === "error" || event.type === "response.failed")
return yield* OpenResponses.providerFailure(event, "Compaction request failed")
if (event.type === "response.incomplete")
return yield* ProviderShared.eventError(source.id, "Compaction response was incomplete")
if (event.type === "response.output_item.added" || event.type === "response.output_item.done")
return [yield* onOutputItem(state, event), []] as const
if (event.type !== "response.completed") return [state, []] as const
const responseID = event.response?.id
if (!responseID?.trim())
return yield* ProviderShared.eventError(source.id, "Compaction response is missing its response ID")
if (event.response?.status !== undefined && event.response.status !== "completed")
return yield* ProviderShared.eventError(source.id, "Compaction response did not complete successfully")
let next = state
for (const [index, item] of (event.response?.output ?? []).entries()) {
next = yield* onOutputItem(next, { type: "response.output_item.done", output_index: index, item })
}
const checkpoints = Object.values(next.checkpoints)
const checkpoint = checkpoints[0]
if (checkpoints.length !== 1 || !checkpoint)
return yield* ProviderShared.eventError(
source.id,
"Compaction response must contain exactly one checkpoint",
)
result = new CompactionCheckpointResponse({
checkpoint,
responseID,
usage: OpenResponses.mapUsage(event.response?.usage, OpenResponses.metadataKey(request.model)),
})
return [next, [LLMEvent.finish({ reason: { normalized: "stop" } })]] as const
}),
},
})
const route = Route.make({
id: source.id,
provider: source.provider,
providerMetadataKey: source.providerMetadataKey,
protocol,
endpoint: source.endpoint,
auth: source.auth,
transport: source.transport,
})
const native = yield* body.from(request)
// The body builder already applied and validated overlays. Do not let transport reapply them.
const preparedRequest = LLMRequest.update(request, {
http: request.http === undefined ? undefined : new HttpOptions({ ...request.http, body: undefined }),
})
const prepared = yield* route.prepareTransport(native, preparedRequest, options)
yield* route.streamPrepared(prepared, preparedRequest, { http: executor }, options).pipe(Stream.runDrain)
if (!result) return yield* ProviderShared.eventError(source.id, "Compaction response ended without a checkpoint")
return result
})
export * as ResponsesCheckpoint from "./responses-checkpoint.js"
+2 -2
View File
@@ -1,5 +1,5 @@
import type { LanguageModel, ProviderOptions } from "./schema/index.js"
import type { CompactOperation } from "./route/client.js"
import type { CompactionOperations } from "./route/client.js"
export interface Settings extends Readonly<Record<string, unknown>> {
readonly baseURL?: string
@@ -10,7 +10,7 @@ export interface Settings extends Readonly<Record<string, unknown>> {
export interface Definition<
ProviderSettings extends Settings = Settings,
Options extends ProviderOptions = ProviderOptions,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
> {
readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options, Compact>
}
+4 -3
View File
@@ -1,7 +1,7 @@
import { Headers } from "effect/unstable/http"
import { Auth } from "../route/auth.js"
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route, RouteDefaultsInput, CompactOperation } from "../route/client.js"
import type { Route, RouteDefaultsInput, CompactionOperations } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
@@ -39,6 +39,7 @@ export type Settings = ProviderPackage.Settings &
const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai`
const responsesRoute = OpenAIResponses.route.with({
compact: { endpoint: OpenAIResponses.route.compact.endpoint },
id: "azure-openai-responses",
provider: id,
auth: routeAuth,
@@ -102,7 +103,7 @@ const auth = (input: Config) => {
)
}
const configuredRoute = <Body, Prepared, Compact extends CompactOperation | undefined>(
const configuredRoute = <Body, Prepared, Compact extends CompactionOperations | undefined>(
route: Route<Body, Prepared, Compact>,
input: Config,
modelID: string | ModelID,
@@ -168,7 +169,7 @@ const config = (settings: Settings): Config => {
export const responsesModel: ProviderPackage.Definition<
Settings,
OpenAIProviderOptionsInput,
CompactOperation
typeof responsesRoute.compact
>["model"] = (modelID, settings) => configure(config(settings)).responses(modelID)
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
+7 -6
View File
@@ -1,5 +1,5 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route, RouteDefaultsInput, CompactOperation } from "../route/client.js"
import type { Route, RouteDefaultsInput, CompactionOperations } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID } from "../schema/index.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
@@ -73,7 +73,7 @@ const defaults = (input: Config) => {
return rest
}
const configuredRoute = <Body, Prepared, Compact extends CompactOperation | undefined>(
const configuredRoute = <Body, Prepared, Compact extends CompactionOperations | undefined>(
route: Route<Body, Prepared, Compact>,
input: Config,
) =>
@@ -132,10 +132,11 @@ const config = (settings: Settings): Config => {
}
}
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput, CompactOperation>["model"] = (
modelID,
settings,
) => {
export const model: ProviderPackage.Definition<
Settings,
OpenAIProviderOptionsInput,
typeof OpenAIResponses.route.compact
>["model"] = (modelID, settings) => {
return configure(config(settings)).responses(modelID)
}
+7 -6
View File
@@ -1,5 +1,5 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput, type CompactOperation } from "../route/client.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
@@ -32,7 +32,7 @@ export type { XAIImageOptions } from "../protocols/xai-images.js"
const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 24 * 60 * 1000
const responsesRoute = Route.make({
compact: XAIResponses.compact,
compact: { endpoint: XAIResponses.compact },
id: "openai-responses",
provider: id,
providerMetadataKey: "xai",
@@ -103,10 +103,11 @@ export const configure = (input: LanguageModelOptions = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput, CompactOperation>["model"] = (
modelID,
settings,
) =>
export const model: ProviderPackage.Definition<
Settings,
XAIProviderOptionsInput,
typeof responsesRoute.compact
>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
+114 -39
View File
@@ -14,6 +14,7 @@ import type { ProtocolID, ProviderOptions } from "../schema/index.js"
import {
AIError,
CompactionResponse,
CompactionCheckpointResponse,
AIErrorReason,
GenerationOptions,
HttpOptions,
@@ -38,7 +39,7 @@ export interface RouteBody<Body> {
export interface Route<
Body,
Prepared = unknown,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
> {
readonly compact: Compact
readonly id: string
@@ -53,7 +54,15 @@ export interface Route<
readonly transport: Transport<Body, Prepared, unknown>
readonly defaults: RouteDefaults
readonly body: RouteBody<Body>
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared, Compact>
readonly with: {
<Next extends CompactionOperations | undefined>(
patch: RoutePatch<Body, Prepared> & { readonly compact: Next },
): Route<Body, Prepared, Next>
(
patch: Omit<RoutePatch<Body, Prepared>, "compact"> & { readonly compact?: undefined },
): Route<Body, Prepared, Compact>
(patch: RoutePatch<Body, Prepared>): Route<Body, Prepared>
}
readonly model: <Options extends ProviderOptions = ProviderOptions>(
input: RouteMappedLanguageModelInput,
) => LanguageModel<Options, Compact>
@@ -74,7 +83,7 @@ export interface Route<
// Normal call sites use `OpenAIChat.route`; callers only need body types
// when preparing a request with a protocol-specific type assertion.
// oxlint-disable-next-line typescript-eslint/no-explicit-any
export type AnyRoute<Compact extends CompactOperation | undefined = CompactOperation | undefined> = Route<
export type AnyRoute<Compact extends CompactionOperations | undefined = CompactionOperations | undefined> = Route<
any,
any,
Compact
@@ -101,6 +110,7 @@ export interface RouteDefaultsInput {
}
export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
readonly compact?: CompactionOperations
readonly id?: string
readonly provider?: string | ProviderID
readonly providerMetadataKey?: string
@@ -111,7 +121,7 @@ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
type RouteMappedLanguageModelInput = RouteLanguageModelInput | RouteRoutedLanguageModelInput
const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
route: AnyRoute<Compact>,
mapped: RouteMappedLanguageModelInput,
) => {
@@ -162,10 +172,7 @@ export const httpOptions = (input: HttpOptionsInput | undefined) => {
}
export interface Interface {
readonly compact: (
request: CompactionRequest,
options?: Pick<StreamOptions, "http">,
) => Effect.Effect<CompactionResponse, AIError>
readonly compact: CompactMethod
readonly stream: StreamMethod
readonly generate: GenerateMethod
}
@@ -189,12 +196,64 @@ export type CompactOperation = (
options?: Pick<StreamOptions, "http">,
) => Effect.Effect<CompactionResponse, AIError>
export type CompactionRequest = LLMRequest & {
readonly model: LanguageModel<ProviderOptions, CompactOperation>
export type TriggerCompactOperation = (
request: LLMRequest,
executor: RequestExecutor.Interface,
options: TriggerCompactOptions,
) => Effect.Effect<CompactionCheckpointResponse, AIError>
/** Protocol capabilities, not deployment/model eligibility. */
export interface CompactionOperations {
readonly endpoint?: CompactOperation
readonly trigger?: TriggerCompactOperation
}
export const canCompact = (request: LLMRequest): request is CompactionRequest =>
request.model.route.compact !== undefined
export interface EndpointCompactOptions extends Pick<StreamOptions, "http"> {
readonly mechanism?: "endpoint"
readonly webSocket?: never
}
export interface TriggerCompactOptions extends StreamOptions {
readonly mechanism: "trigger"
}
// Keep the required route shape explicit: the schema class's self type erases its model parameter in assignability.
export type CompactionRequest = LLMRequest & {
readonly model: LanguageModel<ProviderOptions, { readonly endpoint: CompactOperation }>
}
export type CheckpointRequest = LLMRequest & {
readonly model: LanguageModel<ProviderOptions, { readonly trigger: TriggerCompactOperation }>
}
export interface CompactMethod<R = never> {
(request: CheckpointRequest, options: TriggerCompactOptions): Effect.Effect<CompactionCheckpointResponse, AIError, R>
(request: CompactionRequest, options?: EndpointCompactOptions): Effect.Effect<CompactionResponse, AIError, R>
}
export function canCompact(
request: LLMRequest,
options?: { readonly mechanism?: "endpoint" },
): request is CompactionRequest
export function canCompact(
request: LLMRequest,
options: { readonly mechanism: "trigger" },
): request is CheckpointRequest
export function canCompact(request: LLMRequest, options?: { readonly mechanism?: string }) {
if (options?.mechanism === "trigger") return request.model.route.compact?.trigger !== undefined
if (options?.mechanism !== undefined && options.mechanism !== "endpoint") return false
return request.model.route.compact?.endpoint !== undefined
}
const unsupportedCompaction = (request: LLMRequest, mechanism: string | undefined) => {
if (mechanism !== undefined && mechanism !== "endpoint" && mechanism !== "trigger")
return ProviderShared.invalidRequest(`Unknown compaction mechanism: ${mechanism}`)
return ProviderShared.unsupportedOperation({
operation: mechanism === "trigger" ? "compact.trigger" : "compact",
provider: request.model.provider,
route: request.model.route.id,
message: `${request.model.provider}/${request.model.route.id} does not support ${mechanism === "trigger" ? "trigger" : "explicit"} compaction`,
})
}
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
@@ -216,7 +275,7 @@ const resolveRequestOptions = (request: LLMRequest) => {
}
export interface MakeInput<Body, Frame, Event, State> {
readonly compact?: CompactOperation
readonly compact?: CompactionOperations
/** Route id used in diagnostics and prepared request metadata. */
readonly id: string
/** Provider identity for route-owned model construction. */
@@ -238,7 +297,7 @@ export interface MakeInput<Body, Frame, Event, State> {
}
export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
readonly compact?: CompactOperation
readonly compact?: CompactionOperations
/** Route id used in diagnostics and prepared request metadata. */
readonly id: string
/** Provider identity for route-owned model construction. */
@@ -326,9 +385,10 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
defaults: routeInput.defaults ?? {},
body: protocol.body,
with: (patch: RoutePatch<Body, Prepared>) => {
const { id, provider, providerMetadataKey, auth, transport, endpoint, ...defaults } = patch
const { compact, id, provider, providerMetadataKey, auth, transport, endpoint, ...defaults } = patch
return build({
...routeInput,
compact: "compact" in patch ? compact : routeInput.compact,
id: id ?? routeInput.id,
provider: provider ?? routeInput.provider,
providerMetadataKey:
@@ -343,7 +403,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
})
},
model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedLanguageModelInput) =>
makeRouteLanguageModel<Options, CompactOperation | undefined>(route, input),
makeRouteLanguageModel<Options, CompactionOperations | undefined>(route, input),
prepareTransport: (body, request, options) =>
routeInput.transport.prepare({
body,
@@ -440,12 +500,12 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
return build({ ...input, defaults: mergeRouteDefaults(undefined, input.defaults ?? {}) })
}
export function make<Body, Prepared, Frame, Event, State>(
input: MakeTransportInput<Body, Prepared, Frame, Event, State> & { readonly compact: CompactOperation },
): Route<Body, Prepared, CompactOperation>
export function make<Body, Frame, Event, State>(
input: MakeInput<Body, Frame, Event, State> & { readonly compact: CompactOperation },
): Route<Body, HttpTransport.HttpPrepared<Frame>, CompactOperation>
export function make<Body, Prepared, Frame, Event, State, Compact extends CompactionOperations>(
input: MakeTransportInput<Body, Prepared, Frame, Event, State> & { readonly compact: Compact },
): Route<Body, Prepared, Compact>
export function make<Body, Frame, Event, State, Compact extends CompactionOperations>(
input: MakeInput<Body, Frame, Event, State> & { readonly compact: Compact },
): Route<Body, HttpTransport.HttpPrepared<Frame>, Compact>
export function make<Body, Prepared, Frame, Event, State>(
input: MakeTransportInput<Body, Prepared, Frame, Event, State>,
): Route<Body, Prepared>
@@ -561,14 +621,23 @@ export function generate(request: LLMRequest, options?: StreamOptions): Effect.E
})
}
export const compact = (
export function compact(
request: CheckpointRequest,
options: TriggerCompactOptions,
): Effect.Effect<CompactionCheckpointResponse, AIError, Service>
export function compact(
request: CompactionRequest,
options?: Pick<StreamOptions, "http">,
): Effect.Effect<CompactionResponse, AIError, Service> =>
Effect.gen(function* () {
options?: EndpointCompactOptions,
): Effect.Effect<CompactionResponse, AIError, Service>
export function compact(request: LLMRequest, options?: EndpointCompactOptions | TriggerCompactOptions) {
return Effect.gen(function* () {
const client = yield* Service
return yield* client.compact(request, options)
if (options?.mechanism === "trigger" && canCompact(request, options)) return yield* client.compact(request, options)
if ((options?.mechanism === undefined || options.mechanism === "endpoint") && canCompact(request))
return yield* client.compact(request, options)
return yield* unsupportedCompaction(request, options?.mechanism)
})
}
export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
Stream.unwrap(
@@ -582,21 +651,27 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const stream = streamRequestWith({ http: executor })
function compact(
request: CompactionRequest,
options?: EndpointCompactOptions,
): Effect.Effect<CompactionResponse, AIError>
function compact(
request: CheckpointRequest,
options: TriggerCompactOptions,
): Effect.Effect<CompactionCheckpointResponse, AIError>
function compact(request: LLMRequest, options?: EndpointCompactOptions | TriggerCompactOptions) {
return Effect.suspend((): Effect.Effect<CompactionResponse | CompactionCheckpointResponse, AIError> => {
if (options?.mechanism === "trigger" && canCompact(request, options))
return request.model.route.compact.trigger(prepareRequest(request), executor, options)
if ((options?.mechanism === undefined || options.mechanism === "endpoint") && canCompact(request))
return request.model.route.compact.endpoint(prepareRequest(request), executor, options)
return unsupportedCompaction(request, options?.mechanism)
})
}
return Service.of({
stream,
generate: generateWith(stream),
compact: (request, options) =>
Effect.suspend(() => {
const operation = request.model.route.compact
if (!operation)
return ProviderShared.unsupportedOperation({
operation: "compact",
provider: request.model.provider,
route: request.model.route.id,
message: `${request.model.provider}/${request.model.route.id} does not support explicit compaction`,
})
return operation(prepareRequest(request), executor, options)
}),
compact,
})
}),
)
+6
View File
@@ -9,6 +9,12 @@ export type {
Interface as LLMClientShape,
Service as LLMClientService,
StreamOptions,
CompactMethod,
CompactionOperations,
CompactionRequest,
CheckpointRequest,
EndpointCompactOptions,
TriggerCompactOptions,
} from "./client.js"
export * from "./executor.js"
export { Auth } from "./auth.js"
+15
View File
@@ -97,6 +97,21 @@ export class CompactionResponse extends Schema.Class<CompactionResponse>("LLM.Co
usage: Schema.optional(Usage),
}) {}
/** A checkpoint only; retained history and replacement-window construction belong to the caller. */
export class CompactionCheckpointResponse extends Schema.Class<CompactionCheckpointResponse>(
"LLM.CompactionCheckpointResponse",
)({
checkpoint: CompactionPart.pipe(
Schema.refine(
(part): part is CompactionPart & { readonly encrypted: string; readonly text?: never } =>
part.encrypted !== undefined && part.encrypted.length > 0,
{ message: "A checkpoint response requires encrypted compaction content" },
),
),
responseID: Schema.String.check(Schema.isPattern(/\S/)),
usage: Schema.optional(Usage),
}) {}
export const StepStart = Schema.Struct({
type: Schema.tag("step-start"),
index: Schema.Number,
+8 -8
View File
@@ -1,6 +1,6 @@
import { Schema } from "effect"
import { ModelID, ProviderID } from "./ids.js"
import type { AnyRoute, CompactOperation } from "../route/client.js"
import type { AnyRoute, CompactionOperations } from "../route/client.js"
import { isRecord } from "../utils/record.js"
export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown)
@@ -175,7 +175,7 @@ export namespace LanguageModelCompatibility {
export class LanguageModel<
Options extends ProviderOptions = ProviderOptions,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
> {
declare protected readonly _ProviderOptions: Options
readonly id: ModelID
@@ -194,7 +194,7 @@ export class LanguageModel<
static make<
Options extends ProviderOptions = ProviderOptions,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
>(input: LanguageModel.Input<Compact>) {
return new LanguageModel<Options, Compact>({
id: ModelID.make(input.id),
@@ -206,7 +206,7 @@ export class LanguageModel<
})
}
static input<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
static input<Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
model: LanguageModel<Options, Compact>,
): LanguageModel.ConstructorInput<Compact> {
return {
@@ -218,11 +218,11 @@ export class LanguageModel<
}
}
static update<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
static update<Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
model: LanguageModel<Options>,
patch: Partial<LanguageModel.Input<Compact>> & { readonly route: AnyRoute<Compact> },
): LanguageModel<Options, Compact>
static update<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
static update<Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
model: LanguageModel<Options, Compact>,
patch: Partial<Omit<LanguageModel.Input, "route">> & { readonly route?: undefined },
): LanguageModel<Options, Compact>
@@ -241,7 +241,7 @@ export class LanguageModel<
}
export namespace LanguageModel {
export type ConstructorInput<Compact extends CompactOperation | undefined = CompactOperation | undefined> = {
export type ConstructorInput<Compact extends CompactionOperations | undefined = CompactionOperations | undefined> = {
readonly id: ModelID
readonly provider: ProviderID
readonly route: AnyRoute<Compact>
@@ -249,7 +249,7 @@ export namespace LanguageModel {
readonly compatibility?: LanguageModelCompatibility
}
export type Input<Compact extends CompactOperation | undefined = CompactOperation | undefined> = Omit<
export type Input<Compact extends CompactionOperations | undefined = CompactionOperations | undefined> = Omit<
ConstructorInput<Compact>,
"id" | "provider" | "defaults" | "compatibility"
> & {
+39 -11
View File
@@ -1,10 +1,17 @@
export * as TestLLM from "./testing.js"
import { LLMClient } from "./route/client.js"
import {
LLMClient,
type CompactionRequest,
type CheckpointRequest,
type EndpointCompactOptions,
type TriggerCompactOptions,
} from "./route/client.js"
import {
LLMEvent,
LLMResponse,
CompactionResponse,
CompactionCheckpointResponse,
type FinishReasonDetails,
type AIError,
type LLMRequest,
@@ -13,7 +20,11 @@ import {
} from "./schema/index.js"
import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect"
export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, AIError> | CompactionResponse
export type Response =
| readonly LLMEvent[]
| Stream.Stream<LLMEvent, AIError>
| CompactionResponse
| CompactionCheckpointResponse
export type Gate = Readonly<{ started: Effect.Effect<void>; release: Effect.Effect<void> }>
@@ -132,21 +143,38 @@ const make = (options: LayerOptions) =>
Stream.unwrap(
take(request).pipe(
Effect.map((response) => {
if (response instanceof CompactionResponse)
if (response instanceof CompactionResponse || response instanceof CompactionCheckpointResponse)
return Stream.die("TestLLM generation requires an event response")
return Stream.isStream(response) ? response : Stream.fromIterable(response)
}),
),
)
const test = Test.of({
compact: (request) =>
take(request).pipe(
Effect.flatMap((response) =>
response instanceof CompactionResponse
function compact(
request: CompactionRequest,
options?: EndpointCompactOptions,
): Effect.Effect<CompactionResponse, AIError>
function compact(
request: CheckpointRequest,
options: TriggerCompactOptions,
): Effect.Effect<CompactionCheckpointResponse, AIError>
function compact(
request: LLMRequest,
options?: EndpointCompactOptions | TriggerCompactOptions,
): Effect.Effect<CompactionResponse | CompactionCheckpointResponse, AIError> {
return take(request).pipe(
Effect.flatMap((response): Effect.Effect<CompactionResponse | CompactionCheckpointResponse> => {
if (options?.mechanism === "trigger")
return response instanceof CompactionCheckpointResponse
? Effect.succeed(response)
: Effect.die("TestLLM compaction requires a CompactionResponse"),
),
),
: Effect.die("TestLLM trigger compaction requires a CompactionCheckpointResponse")
return response instanceof CompactionResponse
? Effect.succeed(response)
: Effect.die("TestLLM compaction requires a CompactionResponse")
}),
)
}
const test = Test.of({
compact,
stream,
generate: (request) =>
stream(request).pipe(
@@ -0,0 +1,129 @@
import { Effect } from "effect"
import {
CompactionCheckpointResponse,
CompactionResponse,
LanguageModel,
LLM,
LLMClient,
LLMRequest,
} from "../../src/index.js"
import {
Anthropic,
Azure,
AmazonBedrock,
AmazonBedrockMantle,
OpenAI,
OpenAICompatibleResponses,
XAI,
} from "../../src/providers.js"
import type { WebSocketChannelExecutor } from "../../src/route.js"
import type { RoutePatch } from "../../src/route/client.js"
import type { OpenAIResponsesBody } from "../../src/protocols/openai-responses.js"
import type { Prepared } from "../../src/protocols/open-responses-channel.js"
declare const webSocket: WebSocketChannelExecutor
const model = OpenAI.configure().responses("fixture")
const request = LLM.request({ model, prompt: "hello" })
LLMClient.compact(request, { mechanism: "endpoint" }).pipe(Effect.map((result) => result satisfies CompactionResponse))
LLMClient.compact(request, { mechanism: "trigger", webSocket }).pipe(
Effect.map((result) => {
result satisfies CompactionCheckpointResponse
result.checkpoint.encrypted satisfies string
result.responseID satisfies string
// @ts-expect-error A trigger does not return replacement history.
result.replacement
}),
)
// @ts-expect-error Endpoint compaction does not accept a WebSocket executor.
LLMClient.compact(request, { mechanism: "endpoint", webSocket })
// @ts-expect-error Omitting mechanism selects the HTTP endpoint.
LLMClient.compact(request, { webSocket })
// @ts-expect-error Unknown mechanisms do not have a permissive fallback overload.
LLMClient.compact(request, { mechanism: "other" })
for (const selected of [
model,
OpenAI.model("fixture", {}),
model.route.with({ headers: { fixture: "test" } }).model({ id: "fixture" }),
LanguageModel.make(LanguageModel.input(model)),
LanguageModel.update(model, { defaults: { generation: { maxTokens: 100 } } }),
]) {
LLMClient.compact(LLM.request({ model: selected }), { mechanism: "trigger" })
}
LLMClient.compact(new LLMRequest(LLMRequest.input(request)), { mechanism: "trigger" })
LLMClient.compact(LLMRequest.update(request, { messages: [] }), { mechanism: "trigger" })
const azure = Azure.configure({ resourceName: "fixture" }).responses("fixture")
const xai = XAI.configure().responses("fixture")
// @ts-expect-error Azure must not inherit OpenAI's trigger operation.
LLMClient.compact(LLM.request({ model: azure }), { mechanism: "trigger" })
LLMClient.compact(LLM.request({ model: Azure.responsesModel("fixture", { resourceName: "fixture" }) }), {
// @ts-expect-error Azure's package entrypoint must preserve its narrower capability.
mechanism: "trigger",
})
// @ts-expect-error xAI endpoint support does not imply trigger support.
LLMClient.compact(LLM.request({ model: xai }), { mechanism: "trigger" })
// @ts-expect-error xAI's package entrypoint must preserve its narrower capability.
LLMClient.compact(LLM.request({ model: XAI.model("fixture", {}) }), { mechanism: "trigger" })
const unsupported = {
bedrock: LLM.request({ model: AmazonBedrock.configure().model("fixture") }),
mantle: LLM.request({ model: AmazonBedrockMantle.configure().responses("fixture") }),
anthropic: LLM.request({ model: Anthropic.configure().model("fixture") }),
openai: LLM.request({ model: OpenAI.configure().chat("fixture") }),
azure: LLM.request({ model: Azure.configure({ resourceName: "fixture" }).chat("fixture") }),
xai: LLM.request({ model: XAI.configure().chat("fixture") }),
compatible: LLM.request({
model: OpenAICompatibleResponses.configure({ baseURL: "https://example.com" }).model("fixture"),
}),
}
// @ts-expect-error Bedrock does not expose trigger compaction.
LLMClient.compact(unsupported.bedrock, { mechanism: "trigger" })
// @ts-expect-error Mantle must not inherit trigger support from OpenAI's protocol.
LLMClient.compact(unsupported.mantle, { mechanism: "trigger" })
// @ts-expect-error Anthropic does not expose trigger compaction.
LLMClient.compact(unsupported.anthropic, { mechanism: "trigger" })
// @ts-expect-error OpenAI Chat does not expose trigger compaction.
LLMClient.compact(unsupported.openai, { mechanism: "trigger" })
// @ts-expect-error Azure Chat does not expose trigger compaction.
LLMClient.compact(unsupported.azure, { mechanism: "trigger" })
// @ts-expect-error xAI Chat does not expose trigger compaction.
LLMClient.compact(unsupported.xai, { mechanism: "trigger" })
// @ts-expect-error Generic protocol compatibility does not grant trigger support.
LLMClient.compact(unsupported.compatible, { mechanism: "trigger" })
// @ts-expect-error Changing the model replaces its capability.
LLMClient.compact(LLMRequest.update(request, { model: azure }), { mechanism: "trigger" })
// @ts-expect-error Changing the route replaces its capability.
LLMClient.compact(LLM.request({ model: LanguageModel.update(model, { route: azure.route }) }), { mechanism: "trigger" })
LLMClient.compact(
LLM.request({
model: model.route.with({ compact: { endpoint: model.route.compact.endpoint } }).model({ id: "fixture" }),
}),
// @ts-expect-error Replacing route operations does not retain the old trigger capability.
{ mechanism: "trigger" },
)
declare const dynamic: LLMRequest
declare const patch: Partial<LLMRequest.Input>
declare const routePatch: RoutePatch<OpenAIResponsesBody, Prepared>
// @ts-expect-error A dynamic operation override cannot preserve trigger support.
LLMClient.compact(LLM.request({ model: model.route.with(routePatch).model({ id: "fixture" }) }), {
mechanism: "trigger",
})
// @ts-expect-error Explicitly removing operations removes trigger support.
LLMClient.compact(LLM.request({ model: model.route.with({ compact: undefined }).model({ id: "fixture" }) }), {
mechanism: "trigger",
})
// @ts-expect-error Dynamic models must be narrowed.
LLMClient.compact(dynamic, { mechanism: "trigger" })
if (LLMClient.canCompact(dynamic, { mechanism: "trigger" })) {
LLMClient.compact(dynamic, { mechanism: "trigger" })
LLMClient.Service.use((client) => client.compact(dynamic, { mechanism: "trigger" }))
}
if (LLMClient.canCompact(dynamic)) {
LLMClient.compact(dynamic)
// @ts-expect-error Endpoint narrowing does not grant trigger support.
LLMClient.compact(dynamic, { mechanism: "trigger" })
}
// @ts-expect-error A dynamic model override cannot preserve trigger support.
LLMClient.compact(LLMRequest.update(request, patch), { mechanism: "trigger" })
@@ -0,0 +1,243 @@
import { expect } from "bun:test"
import { Effect, Stream } from "effect"
import { LLM, LLMClient, LLMRequest } from "../../src/index.js"
import { Azure, OpenAI } from "../../src/providers.js"
import { testEffect } from "../lib/effect.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
const checkpoint = { type: "compaction", id: "cmp_1", encrypted_content: "opaque" }
const request = LLM.request({ model: OpenAI.configure({ apiKey: "fixture" }).responses("fixture"), prompt: "hello" })
const trigger = { mechanism: "trigger" } as const
testEffect(
dynamicResponse(({ request, text, respond }) =>
Effect.sync(() => {
expect(new URL(request.url).pathname).toBe("/v1/responses")
expect(new URL(request.url).searchParams.get("trace")).toBe("request")
expect(request.headers.authorization).toBe("Bearer fixture")
expect(request.headers["chatgpt-account-id"]).toBe("fixture-account")
expect(request.headers["x-codex-beta-features"]).toBe("remote_compaction_v2")
const body = JSON.parse(text)
expect(body).toMatchObject({
model: "fixture",
stream: true,
store: false,
instructions: "Keep the context",
parallel_tool_calls: true,
prompt_cache_key: "session-key",
service_tier: "priority",
reasoning: { effort: "high", summary: "auto" },
prompt_cache_retention: "24h",
prompt_cache_options: { mode: "session", ttl: "1h" },
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }, { type: "compaction_trigger" }],
})
expect(body.tools).toHaveLength(1)
expect(body.tools[0].name).toBe("lookup")
expect(body.tool_choice).toBeUndefined()
expect(body.context_management).toBeUndefined()
expect(body.text).toBeUndefined()
expect(body.max_output_tokens).toBeUndefined()
expect(body.previous_response_id).toBeUndefined()
return respond(
sseEvents({
type: "response.completed",
response: {
id: "resp_1",
output: [checkpoint],
usage: {
input_tokens: 100,
input_tokens_details: { cached_tokens: 40 },
output_tokens: 5,
total_tokens: 105,
},
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
}),
),
).effect("trigger uses normal request preparation, configured deployment, and supplied subscription headers", () =>
Effect.gen(function* () {
const calls: string[] = []
const input = LLM.request({
model: request.model,
system: "Keep the context",
prompt: "hello",
promptCacheKey: "session-key",
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
toolChoice: { type: "tool", name: "lookup" },
generation: { maxTokens: 1 },
providerOptions: {
store: true,
reasoningEffort: "high",
reasoningSummary: "auto",
contextManagement: [{ type: "compaction" }],
},
http: {
headers: { "chatgpt-account-id": "fixture-account", "x-codex-beta-features": "remote_compaction_v2" },
query: { trace: "request" },
body: {
service_tier: "priority",
prompt_cache_retention: "24h",
prompt_cache_options: { mode: "session", ttl: "1h" },
store: true,
stream: false,
text: { format: { type: "json_object" } },
tool_choice: "required",
},
},
})
const original = LLMRequest.input(input)
const result = yield* LLMClient.compact(input, {
...trigger,
http: (request, next) => {
calls.push("http")
return next(request)
},
})
expect(result.checkpoint).toMatchObject({
type: "compaction",
provider: "openai",
id: "cmp_1",
encrypted: "opaque",
})
expect(result.responseID).toBe("resp_1")
expect(result.usage).toMatchObject({
inputTokens: 100,
outputTokens: 5,
totalTokens: 105,
cacheReadInputTokens: 40,
})
expect(LLMRequest.input(input)).toEqual(original)
expect(calls).toEqual(["http"])
}),
)
const idless = { type: "compaction", encrypted_content: "opaque" }
testEffect(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
output_index: 0,
item: { type: "function_call", id: "fc_1", name: "unexpected", arguments: "not JSON" },
},
{ type: "response.output_text.delta", delta: "do not expose this" },
{ type: "response.output_item.added", output_index: 1, item: { type: "compaction" } },
{ type: "response.output_item.done", output_index: 1, item: idless },
{ type: "response.output_item.done", output_index: 1, item: idless },
{
type: "response.completed",
response: {
id: "resp_1",
output: [{ type: "function_call", id: "fc_1", name: "unexpected", arguments: "not JSON" }, idless],
},
},
),
),
).effect("correlates an ID-less checkpoint across repeated events and ignores other output", () =>
Effect.gen(function* () {
const result = yield* LLMClient.compact(request, trigger)
expect(result.checkpoint.encrypted).toBe("opaque")
expect(result.checkpoint.id).toBeString()
expect("message" in result).toBe(false)
}),
)
for (const [name, events] of Object.entries({
missing: [{ type: "response.completed", response: { id: "resp_1", output: [] } }],
multiple: [
{ type: "response.completed", response: { id: "resp_1", output: [checkpoint, { ...checkpoint, id: "cmp_2" }] } },
],
duplicateSlots: [{ type: "response.completed", response: { id: "resp_1", output: [checkpoint, checkpoint] } }],
malformed: [
{ type: "response.completed", response: { id: "resp_1", output: [{ type: "compaction", id: "cmp_1" }] } },
],
noResponseID: [{ type: "response.completed", response: { output: [checkpoint] } }],
incomplete: [
{ type: "response.output_item.done", item: checkpoint },
{ type: "response.incomplete", response: { id: "resp_1", incomplete_details: { reason: "max_output_tokens" } } },
],
failed: [
{ type: "response.output_item.done", item: checkpoint },
{ type: "response.failed", response: { id: "resp_1", error: { code: "server_error", message: "failed" } } },
],
})) {
const wire = events.map((event) => ({ ...event, fixture_extra: "preserved" }))
testEffect(
fixedResponse(sseEvents(...wire), { headers: { "content-type": "text/event-stream", "x-fixture": "preserved" } }),
).effect(`rejects ${name} checkpoint response and preserves original error context`, () =>
Effect.gen(function* () {
const error = yield* LLMClient.compact(request, trigger).pipe(Effect.flip)
expect(error.reason.body).toBe(JSON.stringify(wire.at(-1)))
expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-fixture": "preserved" } })
}),
)
}
testEffect(fixedResponse(sseEvents({ type: "response.output_item.done", item: checkpoint }))).effect(
"rejects clean EOF without response.completed",
() =>
Effect.gen(function* () {
const error = yield* LLMClient.compact(request, trigger).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}),
)
for (const body of [{ input: [] }, { previous_response_id: "stale" }]) {
testEffect(dynamicResponse(() => Effect.die("Must reject before sending"))).effect(
`rejects caller-supplied ${Object.keys(body)[0]} before sending trigger`,
() =>
Effect.gen(function* () {
const error = yield* LLMClient.compact(LLMRequest.update(request, { http: { body } }), trigger).pipe(
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidRequest")
}),
)
}
testEffect(dynamicResponse(() => Effect.die("Must reject before sending"))).effect(
"endpoint support does not grant trigger support, including for untyped callers",
() =>
Effect.gen(function* () {
const client = yield* LLMClient.Service
const unsupported = LLM.request({ model: Azure.configure({ resourceName: "fixture" }).responses("fixture") })
expect(LLMClient.canCompact(unsupported)).toBe(true)
expect(LLMClient.canCompact(unsupported, trigger)).toBe(false)
// @ts-expect-error Exercise untyped consumers; the service must reject before sending.
const serviceError = yield* client.compact(unsupported, trigger).pipe(Effect.flip)
expect(serviceError.reason._tag).toBe("UnsupportedOperation")
// @ts-expect-error Exercise an unknown mechanism supplied by JavaScript.
const error = yield* LLMClient.compact(request, { mechanism: "other" }).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
}),
)
testEffect(fixedResponse("must not use HTTP")).effect(
"invalid checkpoint output is rejected before acknowledging channel completion",
() =>
Effect.gen(function* () {
let completed = 0
const operation = LLMClient.compact(request, {
mechanism: "trigger",
webSocket: {
execute: () =>
Effect.succeed({
frames: Stream.make(
JSON.stringify({
type: "response.completed",
response: { id: "resp_1", output: [] },
}),
),
complete: Effect.sync(() => {
completed++
}),
}),
},
})
const error = yield* operation.pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(completed).toBe(0)
}),
)
@@ -657,7 +657,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("continues a promoted steer after the completed assistant output", () =>
it.effect("continues a promoted steer after assistant output with response-only text metadata", () =>
Effect.gen(function* () {
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "First" }] }]
const first = continuationDriver({ type: "response.create", model: "gpt-5.2", store: false, input: firstInput })
@@ -671,7 +671,7 @@ describe("OpenAI Responses route", () => {
id: "msg_1",
status: "completed",
role: "assistant",
content: [{ type: "output_text", text: "Hello" }],
content: [{ type: "output_text", text: "Hello", annotations: [], logprobs: [] }],
},
}),
)
@@ -699,42 +699,37 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("continues store-false reasoning while retaining the output item ID", () =>
it.effect("continues streamed reasoning when completion re-encrypts the same item", () =>
Effect.gen(function* () {
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "Think" }] }]
const request = { type: "response.create", model: "gpt-5.2", store: false, input: firstInput }
const reasoning = {
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Thought" }],
encrypted_content: "encrypted",
}
const first = continuationDriver(request)
const create = yield* first.create(undefined)
yield* first.observe(
create,
ProviderShared.encodeJson({
type: "response.output_item.done",
item: {
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Thought" }],
encrypted_content: "encrypted",
},
item: reasoning,
}),
)
const saved = checkpoint(
yield* first.observe(
create,
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
ProviderShared.encodeJson({
type: "response.completed",
response: { id: "resp_1", output: [{ ...reasoning, encrypted_content: "terminal-encrypted" }] },
}),
),
)
const next = continuationDriver({
...request,
input: [
...firstInput,
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Thought" }],
encrypted_content: "encrypted",
},
{ role: "user", content: [{ type: "input_text", text: "Continue" }] },
],
input: [...firstInput, reasoning, { role: "user", content: [{ type: "input_text", text: "Continue" }] }],
})
const continued = yield* next.create(saved)
@@ -744,6 +739,11 @@ describe("OpenAI Responses route", () => {
previous_response_id: "resp_1",
input: [{ role: "user", content: [{ type: "input_text", text: "Continue" }] }],
})
const edited = yield* continuationDriver({
...request,
input: [...firstInput, { ...reasoning, encrypted_content: "edited" }, { role: "user", content: "Continue" }],
}).create(saved)
expect(edited.mode).toBe("full")
}),
)
+26
View File
@@ -3,6 +3,7 @@ import {
AIError,
CompactionPart,
CompactionResponse,
CompactionCheckpointResponse,
LanguageModel,
LLM,
LLMClient,
@@ -79,6 +80,31 @@ describe("TestLLM legacy client", () => {
})
describe("TestLLM first-class client", () => {
it.effect("returns scripted checkpoints only for trigger compaction", () =>
Effect.gen(function* () {
const client = yield* TestLLM.Test
const request = LLM.request({ model: OpenAI.configure().responses("fixture"), prompt: "hello" })
const checkpoint = new CompactionCheckpointResponse({
checkpoint: { type: "compaction", provider: ProviderID.make("openai"), encrypted: "opaque" },
responseID: "resp_fixture",
})
const endpoint = new CompactionResponse({ replacement: [] })
yield* client.push(checkpoint, endpoint, checkpoint, checkpoint)
const operation = LLMClient.compact(request, { mechanism: "trigger" })
expect(yield* client.requests()).toEqual([])
expect(yield* operation).toBe(checkpoint)
expect(yield* client.compact(request, { mechanism: "trigger" }).pipe(Effect.catchDefect(Effect.succeed))).toBe(
"TestLLM trigger compaction requires a CompactionCheckpointResponse",
)
expect(yield* client.compact(request).pipe(Effect.catchDefect(Effect.succeed))).toBe(
"TestLLM compaction requires a CompactionResponse",
)
expect(yield* client.generate(request).pipe(Effect.catchDefect(Effect.succeed))).toBe(
"TestLLM generation requires an event response",
)
}),
)
it.effect("rejects response fixtures for the wrong operation", () =>
Effect.gen(function* () {
const client = yield* TestLLM.Test
+1 -1
View File
@@ -5,5 +5,5 @@
"noEmit": true,
"rootDir": "."
},
"include": ["test/**/*.types.ts", "test/testing.test.ts"]
"include": ["test/**/*.types.ts", "test/testing.test.ts", "test/provider/checkpoint.test.ts"]
}
+71 -10
View File
@@ -88,27 +88,88 @@ story("renders a draft once and supports editing, caret restoration, and failure
await expect(input).toHaveText("Preserve this draft on failure")
})
// Moved from packages/app/e2e/regression/prompt-thinking-level.spec.ts
story("shows the thinking level control while relevant", async ({ mount, page }) => {
story("shows thinking on composer hover or when a non-default variant is selected", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--model-and-variant")
const composer = component.locator('[data-component="composer"]')
const input = composer.locator('[data-component="composer-editor"]')
const input = composer.getByRole("textbox", { name: "Prompt", exact: true })
const control = composer.getByRole("button", { name: "Choose model variant" })
await component.getByRole("status").click()
await page.mouse.move(0, 0)
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
await expect(control).toBeVisible()
await expect(control).toHaveText("balanced")
await expect(control).toHaveCSS("opacity", "1")
await control.click()
await page.getByRole("menuitemradio", { name: "default", exact: true }).click()
await component.getByRole("status").click()
await expect(control).toHaveText("default")
await expect(control).toHaveCSS("opacity", "0")
await expect(control).toHaveCSS("pointer-events", "none")
await input.hover()
await expect(control).toHaveCSS("opacity", "1")
await expect(control).toHaveCSS("pointer-events", "auto")
await input.click()
await page.mouse.move(0, 0)
await expect(input).toBeFocused()
await expect(control).toHaveCSS("opacity", "0")
await input.hover()
await control.click()
const high = page.getByRole("menuitemradio", { name: "high" })
await expect(high).toBeVisible()
await page.mouse.move(0, 0)
await expect(control).toBeVisible()
await expect(control).toHaveAttribute("aria-expanded", "true")
await expect(control).toHaveCSS("opacity", "1")
await expect(high).toBeVisible()
await high.click()
await component.getByRole("status").click()
await expect(control).toHaveText("high")
await expect(control).toHaveCSS("opacity", "1")
await input.focus()
await expect(control).toBeVisible()
await input.blur()
await expect(control).toBeVisible()
await control.click()
await page.getByRole("menuitemradio", { name: "default", exact: true }).click()
await component.getByRole("status").click()
await expect(control).toHaveCSS("opacity", "0")
})
story("keeps default thinking accessible by keyboard without composer hover", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--model-and-variant")
const input = component.getByRole("textbox", { name: "Prompt", exact: true })
const control = component.getByRole("button", { name: "Choose model variant" })
await control.click()
await page.getByRole("menuitemradio", { name: "default", exact: true }).click()
await input.click()
await page.mouse.move(0, 0)
await expect(control).toHaveCSS("opacity", "0")
// Tab through Add, Agent, and Model to the visually hidden thinking trigger.
for (let count = 0; count < 4; count++) await page.keyboard.press("Tab")
await expect(control).toBeFocused()
await expect(control).toHaveCSS("opacity", "1")
await page.keyboard.press("Enter")
await expect(control).toHaveAttribute("aria-expanded", "true")
await expect(page.getByRole("menuitemradio", { name: "default", exact: true })).toBeFocused()
await expect(control).toHaveCSS("opacity", "1")
await page.keyboard.press("Escape")
await expect(control).toHaveAttribute("aria-expanded", "false")
await expect(control).toBeFocused()
await expect(control).toHaveCSS("opacity", "1")
await page.keyboard.press("Enter")
await expect(page.getByRole("menuitemradio", { name: "default", exact: true })).toBeFocused()
await page.keyboard.press("End")
await expect(page.getByRole("menuitemradio", { name: "high", exact: true })).toBeFocused()
await page.keyboard.press("Enter")
await expect(control).toHaveText("high")
await expect(control).toBeFocused()
await page.keyboard.press("Enter")
await expect(page.getByRole("menuitemradio", { name: "default", exact: true })).toBeFocused()
await page.keyboard.press("Enter")
await expect(control).toHaveText("default")
await expect(control).toBeFocused()
await expect(control).toHaveCSS("opacity", "1")
await page.keyboard.press("Tab")
await expect(control).not.toBeFocused()
await expect(control).toHaveCSS("opacity", "0")
})
@@ -0,0 +1,195 @@
import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "/repo/slash-skills"
const projectID = "proj_slash_skills"
const sessionID = "ses_slash_skills"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
async function setup(page: Page, queued = false) {
const prompts: Record<string, unknown>[] = []
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "slash-skills",
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,
projectID,
directory,
title: "Slash skills",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: [] }),
sessionStatus: queued ? { [sessionID]: { type: "running" } } : {},
inbox: queued
? [
{
id: "inb_slash_skill",
sessionID,
timeCreated: 1700000000000,
type: "user",
payload: { text: "Explain caching" },
delivery: "queue",
},
]
: [],
findFiles: ({ query }) =>
query.includes("cache")
? [
{
name: "cache.ts",
path: "src/cache.ts",
absolute: `${directory}/src/cache.ts`,
type: "file",
ignored: false,
},
]
: [],
onPrompt: ({ body }) => prompts.push(body),
})
await page.route("**/api/skill?*", (route) =>
route.fulfill({
json: {
location: { directory, project: { id: projectID, directory, canonical: directory } },
data: [
{ id: "show-me", slash: true, autoinvoke: false },
{ id: "hidden", slash: false },
{ id: "implicit" },
{ id: "review", slash: true },
{ id: "model", slash: true },
].map((skill) => ({
name: skill.id === "show-me" ? "Show Me" : skill.id,
description: "Explain the current topic visually",
location: `/skills/${skill.id}/SKILL.md`,
content: "Explain visually",
...skill,
})),
},
headers: { "access-control-allow-origin": "*" },
}),
)
await page.route("**/api/command?*", (route) =>
route.fulfill({
json: {
location: { directory, project: { id: projectID, directory, canonical: directory } },
data: [{ name: "review", description: "Review changes" }],
},
headers: { "access-control-allow-origin": "*" },
}),
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
const composer = page.locator('[data-component="composer"]')
const editor = composer.locator('[data-component="composer-editor"]')
await expect(editor).toBeEditable()
return { prompts, composer, editor }
}
for (const selection of ["keyboard", "pointer"]) {
test(`selects and submits a manual-only slash skill with ${selection}`, async ({ page }) => {
const { prompts, editor } = await setup(page)
await editor.fill("/show")
const skill = page.locator('[data-suggestion-id="skill:show-me"]')
await expect(skill).toContainText("/show-me")
await page.screenshot({ path: test.info().outputPath("slash-menu.png") })
if (selection === "keyboard") await editor.press("Enter")
if (selection === "pointer") await skill.click()
await expect(editor).toHaveText("/show-me ")
await expect(editor).toBeFocused()
await editor.pressSequentially("explain caching")
await editor.press("Enter")
await expect.poll(() => prompts.length).toBe(1)
expect(prompts[0]).toMatchObject({
text: "/show-me explain caching",
skills: [{ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }],
})
await expect(editor).toBeEmpty()
})
}
test("respects slash flags and command precedence without hiding context skills", async ({ page }) => {
const { editor } = await setup(page)
await editor.fill("/")
await expect(page.locator('[data-suggestion-id="skill:show-me"]')).toBeVisible()
await expect(page.locator('[data-suggestion-id="custom.review"]')).toBeVisible()
await expect(page.locator('[data-suggestion-id="model.choose"]')).toBeVisible()
for (const id of ["hidden", "implicit", "review", "model"]) {
await expect(page.locator(`[data-suggestion-id="skill:${id}"]`)).toHaveCount(0)
}
await editor.press("Escape")
await expect(page.locator('[data-suggestion-id="skill:show-me"]')).toHaveCount(0)
await expect(editor).toBeFocused()
await editor.fill("@hidden")
const hidden = page.locator('[data-suggestion-id="skill:hidden"]')
await expect(hidden).toContainText("@hidden")
await hidden.click()
await expect(editor).toHaveText("@hidden ")
await expect(editor).toBeFocused()
})
test("preserves structured attachments when adding a slash skill from the command menu", async ({ page }) => {
const { prompts, composer, editor } = await setup(page)
await editor.fill("explain @cache")
const file = page.locator('[data-suggestion-id="file:src/cache.ts"]')
await expect(file).toBeVisible()
await file.click()
await expect(editor).toHaveText("explain @src/cache.ts ")
await composer.getByRole("button", { name: "Add images and files" }).click()
await page.getByRole("menuitem", { name: "Commands" }).click()
const skill = page.locator('[data-suggestion-id="skill:show-me"]')
await expect(skill).toBeVisible()
await skill.click()
await expect(editor).toHaveText("/show-me explain @src/cache.ts ")
await expect(editor).toBeFocused()
await editor.press("Enter")
await expect.poll(() => prompts.length).toBe(1)
expect(prompts[0]).toMatchObject({
skills: [{ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }],
files: [{ name: "cache.ts", mention: { start: 17, end: 30, text: "@src/cache.ts" } }],
})
})
for (const select of [true, false]) {
test(`keeps slash skills in queued edits (${select ? "selected" : "typed"})`, async ({ page }) => {
const { prompts, editor } = await setup(page, true)
const row = page.locator('[data-component="session-queue-row"]')
await row.getByText("Explain caching", { exact: true }).click()
await expect(editor).toHaveText("Explain caching")
if (select) {
await editor.fill("/show")
const skill = page.locator('[data-suggestion-id="skill:show-me"]')
await expect(skill).toBeVisible()
await skill.click()
await expect(editor).toHaveText("/show-me ")
await editor.pressSequentially("explain caching")
}
if (!select) await editor.fill("/show-me explain caching")
await editor.press("Enter")
await expect.poll(() => prompts.length).toBe(1)
expect(prompts[0]).toMatchObject({
delivery: "queue",
text: "/show-me explain caching",
skills: [{ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }],
})
})
}
@@ -0,0 +1,60 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const draftID = "draft_new_session_dropzone"
const directory = "C:/OpenCode/NewSessionDropzone"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("shows the dropzone and attaches a dropped file", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_new_session_dropzone",
worktree: directory,
vcs: "git",
name: "new-session-dropzone",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, draftID, server }) => {
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([{ type: "draft", draftID, server, directory }]),
)
},
{ directory, draftID, server },
)
await page.goto(`/new-session?draftId=${draftID}`)
await expectAppVisible(page.locator('[data-component="composer-editor"]'))
const surface = page.locator('[data-component="new-session"]')
const dropzone = page.locator('[data-component="session-dropzone"]')
const transfer = await page.evaluateHandle(() => {
const value = new DataTransfer()
value.items.add(new File(["Dropzone fixture"], "dropzone.txt", { type: "text/plain" }))
return value
})
await expect(dropzone).toHaveCount(0)
await surface.dispatchEvent("dragover", { dataTransfer: transfer })
await expect(dropzone).toHaveAttribute("data-visible", "true")
await expect(dropzone).toContainText("Drop files to add")
await surface.dispatchEvent("drop", { dataTransfer: transfer })
await expect(page.locator('[data-component="composer-attachments"]')).toContainText("dropzone.txt")
await expect(dropzone).toHaveCount(0)
})
@@ -134,7 +134,7 @@ for (const viewport of [
await expect(page).toHaveURL(pending.url)
await expect(pending.message).toHaveAttribute("data-timeline-part-id", `${pending.messageID}:text:0`)
await expect(pending.shimmer).toHaveAttribute("data-active", "true")
await expect(pending.title).toHaveText("New session")
await expect(pending.title).toHaveText("Session")
await expect(editor).toHaveText(followUp)
expect(mock.calls).toEqual(["worktree"])
@@ -238,7 +238,7 @@ for (const direction of ["ltr", "rtl"]) {
!frame.message ||
!frame.spinner ||
frame.draft !== followUp ||
!["New session", "Created workspace session"].includes(frame.title ?? ""),
!["Session", "Created workspace session"].includes(frame.title ?? ""),
),
).toEqual([])
const after = await title.boundingBox()
@@ -598,7 +598,7 @@ async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDra
const shimmer = preparing.getByRole("status").locator('[data-component="text-shimmer"]')
const title = preparing.getByRole("heading", { level: 1 })
await expect(preparing).toBeVisible()
await expect(title).toHaveText("New session")
await expect(title).toHaveText("Session")
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await expect(page.locator('[data-action="composer-submit"]')).toBeDisabled()
await expect(preparing.locator('[data-component="user-message"]')).toHaveCount(1)
@@ -1,4 +1,4 @@
import { expect, test, type Page } from "@playwright/test"
import { expect, test } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
@@ -8,7 +8,7 @@ const projectID = "proj_prompt_thinking_level_regression"
const sessionID = "ses_prompt_thinking_level_regression"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("shows the thinking level control while relevant", async ({ page }) => {
test("shows thinking on hover or a non-default selection while preserving keyboard access", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
@@ -52,30 +52,57 @@ test("shows the thinking level control while relevant", async ({ page }) => {
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
const composer = page.locator('[data-component="composer"]')
const input = composer.locator('[data-component="composer-editor"]')
const input = composer.getByRole("textbox", { name: "Prompt", exact: true })
const control = composer.getByRole("button", { name: "Choose model variant" })
await expectAppVisible(composer)
await idleComposer(page)
await expect(control).toBeVisible()
await page.mouse.move(0, 0)
await expect(control).toHaveText("default")
await expect(control).toHaveCSS("opacity", "0")
await expect(control).toHaveCSS("pointer-events", "none")
await input.hover()
await expect(control).toHaveCSS("opacity", "1")
await input.click()
await page.mouse.move(0, 0)
await expect(input).toBeFocused()
await expect(control).toHaveCSS("opacity", "0")
await input.hover()
await control.click()
const high = page.getByRole("menuitemradio", { name: "high" })
await expect(high).toBeVisible()
await page.mouse.move(0, 0)
await expect(control).toBeVisible()
await expect(control).toHaveAttribute("aria-expanded", "true")
await expect(control).toHaveCSS("opacity", "1")
await expect(high).toBeVisible()
await high.click()
await idleComposer(page)
await input.focus()
await expect(control).toBeVisible()
await idleComposer(page)
await expect(control).toBeVisible()
})
async function idleComposer(page: Page) {
await input.click()
await page.mouse.move(0, 0)
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
}
await expect(control).toHaveText("high")
await expect(control).toHaveCSS("opacity", "1")
await control.click()
await page.getByRole("menuitemradio", { name: "default", exact: true }).click()
await input.click()
await page.mouse.move(0, 0)
await expect(control).toHaveText("default")
await expect(control).toHaveCSS("opacity", "0")
// The single-agent fixture has only Add and Model before the thinking trigger.
await page.keyboard.press("Tab")
await expect(composer.getByRole("button", { name: "Add images and files" })).toBeFocused()
await page.keyboard.press("Tab")
await expect(composer.getByRole("button", { name: "Thinking Model" })).toBeFocused()
await page.keyboard.press("Tab")
await expect(control).toBeFocused()
await expect(control).toHaveCSS("opacity", "1")
await page.keyboard.press("Enter")
await expect(page.getByRole("menuitemradio", { name: "default", exact: true })).toBeFocused()
await expect(control).toHaveCSS("opacity", "1")
await page.keyboard.press("Escape")
await expect(control).toBeFocused()
await page.keyboard.press("Tab")
await expect(control).toHaveCSS("opacity", "0")
})
@@ -1,4 +1,4 @@
import { onMount } from "solid-js"
import { onCleanup, onMount } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { ComposerAttachment, ComposerPrompt } from "../types"
@@ -78,6 +78,7 @@ export type ComposerAttachmentConfig = {
onError: (error: unknown) => void
readClipboardImage?: () => Promise<File | null>
getPathForFile?: (file: File) => string
onDragCancel?: (callback: () => void) => () => void
store?: (file: File) => Promise<{ id: string; url: string }>
}
@@ -90,6 +91,9 @@ export function createComposerAttachments(
setDraggingType: (type: "image" | "@mention" | null) => void
},
) {
const clearDrag = () => {
input.setDraggingType(null)
}
const capture = () => {
const prompt = input.capture()
const editor = input.editor()
@@ -178,7 +182,7 @@ export function createComposerAttachments(
const handleDrop = async (event: DragEvent) => {
if (input.isDialogActive()) return
event.preventDefault()
input.setDraggingType(null)
clearDrag()
const plainText = event.dataTransfer?.getData("text/plain")
if (plainText?.startsWith("file:")) {
const path = plainText.slice("file:".length)
@@ -191,6 +195,8 @@ export function createComposerAttachments(
}
onMount(() => {
const cancel = input.onDragCancel?.(clearDrag)
if (cancel) onCleanup(cancel)
makeEventListener(document, "dragover", (event) => {
if (input.isDialogActive()) return
event.preventDefault()
@@ -198,7 +204,10 @@ export function createComposerAttachments(
else if (event.dataTransfer?.types.includes("text/plain")) input.setDraggingType("@mention")
})
makeEventListener(document, "dragleave", (event) => {
if (!input.isDialogActive() && !event.relatedTarget) input.setDraggingType(null)
if (!input.isDialogActive() && !event.relatedTarget) clearDrag()
})
makeEventListener(document, "keydown", (event) => {
if (event.key === "Escape") clearDrag()
})
makeEventListener(document, "drop", handleDrop)
})
@@ -134,6 +134,7 @@ function ComposerStory(props: {
},
variant: {
options: () => [
{ id: "default", label: "default" },
{ id: "balanced", label: "balanced" },
{ id: "high", label: "high" },
],
+87
View File
@@ -0,0 +1,87 @@
import { Icon } from "@opencode-ai/ui/icon"
import { Show, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { createAnimatedPresence } from "@/runtime/animated-presence"
export function ComposerDropzone(props: {
active: boolean
input?: { image?: boolean; pdf?: boolean }
identity?: () => unknown
}) {
const language = useLanguage()
const [elements, setElements] = createStore<{ dropzone?: HTMLDivElement }>({})
const label = createMemo(() => {
if (!props.input?.image && !props.input?.pdf) return language.t("ui.promptInput.dropFiles")
if (!props.input.pdf) return language.t("ui.promptInput.dropFiles.image")
if (!props.input.image) return language.t("ui.promptInput.dropFiles.pdf")
return language.t("ui.promptInput.dropFiles.imagePdf")
})
const presence = createAnimatedPresence(
() => (props.active ? label() : undefined),
() => elements.dropzone ?? null,
props.identity,
)
return (
<>
<div
data-slot="session-dropzone-blur"
data-visible={props.active}
class="pointer-events-none absolute inset-0 z-[79] rounded-[inherit] opacity-[0.001] backdrop-blur-[1.5px] transition-opacity duration-200 ease-[cubic-bezier(0.215,0.61,0.355,1)] will-change-[opacity,backdrop-filter] data-[visible=true]:opacity-100 motion-reduce:transition-none"
style={{
"-webkit-mask-image":
"linear-gradient(to right, transparent 0%, black 25%, black 75%, transparent 100%), linear-gradient(to bottom, transparent 0%, black 28%, black 72%, transparent 100%)",
"-webkit-mask-composite": "source-in",
"mask-image":
"linear-gradient(to right, transparent 0%, black 25%, black 75%, transparent 100%), linear-gradient(to bottom, transparent 0%, black 28%, black 72%, transparent 100%)",
"mask-composite": "intersect",
}}
/>
<Show when={presence.present()}>
<div
ref={(element) => setElements("dropzone", element)}
data-component="session-dropzone"
data-visible={props.active}
class="pointer-events-none absolute inset-0 z-[80] grid place-items-center overflow-hidden rounded-[inherit] bg-[color-mix(in_srgb,var(--v2-text-text-base)_var(--session-dropzone-wash),transparent)] opacity-100 transition-opacity duration-200 ease-[cubic-bezier(0.215,0.61,0.355,1)] data-[visible=false]:opacity-0 motion-reduce:transition-none"
>
<div class="absolute inset-0 bg-v2-background-bg-base/25" />
<div
class="absolute inset-y-0 left-1/2 w-full -translate-x-1/2 md:max-w-200 2xl:max-w-[1000px]"
style={{
"-webkit-mask-image": "linear-gradient(to right, transparent 0%, black 12%, black 88%, transparent 100%)",
"mask-image": "linear-gradient(to right, transparent 0%, black 12%, black 88%, transparent 100%)",
}}
>
<div
data-slot="session-dropzone-stripes"
class="absolute inset-0 opacity-60"
style={{
background:
"repeating-linear-gradient(135deg, transparent 0px, transparent 12px, color-mix(in srgb, var(--v2-text-text-base) var(--session-dropzone-stripe), transparent) 12px, color-mix(in srgb, var(--v2-text-text-base) var(--session-dropzone-stripe), transparent) 24px)",
"-webkit-mask-image":
"radial-gradient(ellipse 59% 40% at center, black 0%, rgba(0,0,0,0.72) 58%, transparent 100%)",
"mask-image":
"radial-gradient(ellipse 59% 40% at center, black 0%, rgba(0,0,0,0.72) 58%, transparent 100%)",
}}
/>
</div>
<div
data-slot="session-dropzone-content"
class="relative flex translate-y-0 flex-col items-center gap-5 opacity-100 transition-[opacity,transform] duration-200 ease-[cubic-bezier(0.215,0.61,0.355,1)] data-[visible=false]:translate-y-1 data-[visible=false]:opacity-0 motion-reduce:transition-none"
data-visible={props.active}
>
<div
data-slot="session-dropzone-upload"
class="flex size-10 items-center justify-center rounded-full bg-[var(--session-dropzone-card)] text-v2-icon-icon-muted shadow-[var(--v2-elevation-floating)]"
aria-hidden="true"
>
<Icon name="arrow-up" size="normal" class="text-v2-icon-icon-muted" />
</div>
<div class="text-[15px] font-[530] leading-6 text-v2-text-text-base">{presence.value()}</div>
</div>
</div>
</Show>
</>
)
}
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import { createStore } from "solid-js/store"
import { Skill } from "@opencode-ai/schema/skill"
import type { ComposerPersistedState } from "../types"
import { createComposerEditorActions } from "./actions"
@@ -97,4 +98,26 @@ describe("Composer store", () => {
expect(prompt.state.prompt).toEqual([{ type: "text", content: "old", start: 0, end: 3 }])
})
test("prepends a slash skill to an attachment-only draft without flattening it", () => {
const prompt = createPromptStore()
prompt.setPrompt([{ type: "file", path: "one", content: "@one", start: 0, end: 4 }], 4)
prompt.addMention(
{
type: "skill",
id: Skill.ID.make("show-me"),
name: Skill.Name.make("Show Me"),
content: "/show-me",
start: 0,
end: 0,
},
{ start: 0, end: 0 },
)
expect(prompt.state.prompt).toMatchObject([
{ type: "skill", id: "show-me", content: "/show-me", start: 0, end: 8 },
{ type: "text", content: " ", start: 8, end: 9 },
{ type: "file", path: "one", content: "@one", start: 9, end: 13 },
])
expect(prompt.state.cursor).toBe(9)
})
})
+9 -3
View File
@@ -69,12 +69,15 @@ export function createComposerEditorActions(input: ComposerStateStoreInput) {
setStore()("context", "items", (items) => items.filter((item) => item.key !== key))
clearRetry()
},
addMention(mention: ComposerFilePart | ComposerAgentPart | ComposerSkillPart) {
addMention(
mention: ComposerFilePart | ComposerAgentPart | ComposerSkillPart,
range?: { start: number; end: number },
) {
const text = store()
.prompt.map((part) => ("content" in part ? part.content : ""))
.join("")
const end = store().cursor ?? text.length
const start = text.slice(0, end).lastIndexOf("@")
const end = range?.end ?? store().cursor ?? text.length
const start = range?.start ?? text.slice(0, end).lastIndexOf("@")
setStore()("prompt", insertMention(store().prompt, start < 0 ? end : start, end, mention))
setStore()("cursor", (start < 0 ? end : start) + mention.content.length + 1)
clearRetry()
@@ -113,6 +116,9 @@ function insertMention(
end: number,
mention: ComposerFilePart | ComposerAgentPart | ComposerSkillPart,
): ComposerPrompt {
if (start === 0 && end === 0) {
return withOffsets([mention, { type: "text", content: " ", start: 0, end: 0 }, ...prompt])
}
let position = 0
const parts = prompt.flatMap<ComposerPrompt[number]>((part) => {
if (part.type === "image") return [part]
@@ -2,6 +2,11 @@
content: "\200B";
}
[data-component="composer"]:not(:hover) .composer-variant-default:not(:focus-visible, [data-expanded]) {
opacity: 0;
pointer-events: none;
}
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"],
[data-color-scheme="dark"] [data-component="new-session"] [data-component="composer"] {
background: var(--v2-background-bg-layer-01);
+3 -7
View File
@@ -118,7 +118,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl bg-v2-background-bg-base"
classList={{
"shadow-[var(--v2-elevation-raised)]": !props.borderUnderlay,
"border border-v2-icon-icon-info border-dashed": state.drag === "active",
}}
onSubmit={(event) => {
event.preventDefault()
@@ -129,12 +128,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
onDragLeave={props.controller.onDragLeave}
onDrop={props.controller.onDrop}
>
<Show when={state.drag === "active"}>
<div class="pointer-events-none absolute inset-0 z-20 grid place-items-center rounded-xl bg-v2-background-bg-base/90 text-v2-text-text-base">
{i18n.t("ui.promptInput.dropFiles")}
</div>
</Show>
<Show when={state.mode === "normal"}>
<ComposerAttachments
attachments={props.controller.attachments()}
@@ -270,6 +263,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
title={i18n.t("ui.promptInput.chooseVariant")}
keybind={["Shift", "Mod", "D"]}
control={control}
class={control.current() === "default" ? "composer-variant-default" : undefined}
/>
</Show>
)}
@@ -603,12 +597,14 @@ function ComposerEditorConfiguredSelect(props: {
keybind?: string[]
control: ComposerSelectControl
model?: boolean
class?: string
}) {
const current = () => props.control.current()
const providerID = () => props.control.options().find((option) => option.id === current())?.providerID
return (
<ComposerEditorSelect
title={props.title}
class={props.class}
keybind={props.control.keybind?.() ?? props.keybind}
options={props.control.options()}
current={current()}
@@ -145,7 +145,7 @@ export function createComposerEditor(input: {
return
}
if (command.type === "mention.add") {
if (command.item.mention) draft.addMention(command.item.mention)
if (command.item.mention) draft.addMention(command.item.mention, command.range)
return
}
if (command.type === "popover.filter") {
+35 -8
View File
@@ -21,7 +21,7 @@ import type { ImageAttachmentPart } from "./state"
import type { PromptHistoryComment } from "./history/entry"
import { createComposerHistory } from "./history/store"
import { composerPlaceholder } from "./placeholder"
import { createComposerSubmit } from "./submit"
import { createComposerSubmit, withSlashSkill } from "./submit"
export type ComposerModel = ComposerEditorModel & {
readonly model: ComposerControls["model"]
@@ -42,9 +42,14 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
const interaction = createComposerEditorState(prompt.mode.current())
createEffect(
on(adapter.ready, (ready) => {
if (ready) interaction[1]("mode", prompt.mode.current())
}),
on(
() => (adapter.ready() ? prompt.mode.current() : undefined),
(mode) => {
if (!mode) return
// Project external draft changes without another mode write clearing restored retry metadata.
interaction[1](mode === "shell" ? { mode, popover: { type: "closed" } } : { mode })
},
),
)
const mode = () => interaction[0].mode
const history = createComposerHistory()
@@ -236,22 +241,42 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
type: "builtin" as const,
})),
])
const commands = createMemo<ComposerSuggestion[]>(() =>
slashCommands().map((item) => ({
const slashSkills = createMemo(() =>
skills().filter((skill) => skill.slash === true && !slashCommands().some((item) => item.trigger === skill.id)),
)
const commands = createMemo<ComposerSuggestion[]>(() => [
...slashCommands().map((item) => ({
id: item.id,
kind: "command",
kind: "command" as const,
label: `/${item.trigger}`,
trigger: item.trigger,
title: item.title,
description: item.description,
keybind: command.keybindParts(item.id),
})),
)
...slashSkills().map((skill) => ({
id: `skill:${skill.id}`,
kind: "skill" as const,
label: `/${skill.id}`,
trigger: skill.id,
title: skill.name,
description: skill.description,
mention: {
type: "skill" as const,
id: Skill.ID.make(skill.id),
name: Skill.Name.make(skill.name),
content: `/${skill.id}`,
start: 0,
end: 0,
},
})),
])
const variants = createMemo(() => ["default", ...adapter.controls().model.selection.variant.list()])
const submission = createComposerSubmit({
adapter,
mode,
commands: () => data.location.command.list({ directory: sdk().directory }),
skills: slashSkills,
editor: () => editor,
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
addToHistory: (value, mode) => controller.addHistory(value, mode),
@@ -346,6 +371,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
}),
readClipboardImage: platform.readClipboardImage,
getPathForFile: platform.getPathForFile,
onDragCancel: platform.onDragCancel,
store: platform.draftStore?.putBlob,
},
view: {
@@ -377,6 +403,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
// the composer value as a new prompt. Enter keeps it queued in
// place; the alternate action sends it as a steer.
if (queue?.editing()) {
prompt.set(withSlashSkill(prompt.current(), slashSkills()))
queue.confirmEdit(submitOptions?.alternate ? "steer" : "queue")
return
}
+132 -5
View File
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
import type { ModelSelection } from "@/providers/models/selection"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import { Skill } from "@opencode-ai/schema/skill"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import type { ActiveComposerAdapter, ComposerControls, ComposerSession, NewSessionComposerAdapter } from "./adapter"
import { createMemoryComposerState } from "./state"
import { createComposerSubmit } from "./submit"
@@ -31,6 +32,16 @@ const selection = {
},
} satisfies ModelSelection
const slashSkill = Skill.Info.make({
id: Skill.ID.make("show-me"),
name: Skill.Name.make("Show Me"),
description: "Explain visually",
slash: true,
autoinvoke: false,
location: AbsolutePath.make("/skills/show-me/SKILL.md"),
content: "Explain visually",
})
function controls(): ComposerControls {
return {
agents: {
@@ -53,11 +64,13 @@ function submitInput(
notify = { missingSelection() {}, failed(_kind: "shell" | "command" | "prompt", _error: unknown) {} },
mode: "normal" | "shell" = "normal",
commands: () => readonly { name: string }[] | undefined = () => [],
skills: () => readonly Skill.Info[] | undefined = () => [],
) {
return createComposerSubmit({
adapter,
mode: () => mode,
commands,
skills,
editor: () => undefined,
queueScroll() {},
addToHistory() {},
@@ -109,6 +122,107 @@ function session(input: {
}
describe("Composer submission", () => {
test("submits a slash skill with its trailing text and attachments", async () => {
const state = createMemoryComposerState({ prompt: "/show-me explain " }).capture()
state.set([
{ type: "text", content: "/show-me explain ", start: 0, end: 17 },
{ type: "file", path: "src/cache.ts", content: "@src/cache.ts", start: 17, end: 30 },
])
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
const target = session({ calls: [], prompt: async (value) => admitted.resolve(value) })
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
await submitInput(
adapter,
undefined,
"normal",
() => [],
() => [slashSkill],
).submit(new Event("submit"))
const request = await admitted.promise
expect(request.text).toBe("/show-me explain @src/cache.ts")
expect(request.skills).toEqual([
expect.objectContaining({ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }),
])
expect(request.files).toEqual([
{ uri: "file:///C:/repo/src/cache.ts", name: "cache.ts", mention: { start: 17, end: 30, text: "@src/cache.ts" } },
])
})
test.each([
{ text: "/show-me", slash: true, expected: ["show-me"] },
{ text: "/show-me\nexplain caching", slash: true, expected: ["show-me"] },
{ text: "/show-me\texplain caching", slash: true, expected: ["show-me"] },
{ text: "/show-me", slash: false, expected: [] },
{ text: "/show-me", slash: undefined, expected: [] },
{ text: "/show-me-extra", slash: true, expected: [] },
{ text: "Explain /show-me", slash: true, expected: [] },
])("resolves raw slash skill input $text with slash=$slash", async ({ text, slash, expected }) => {
const state = createMemoryComposerState({ prompt: text }).capture()
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
const target = session({ calls: [], prompt: async (value) => admitted.resolve(value) })
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
await submitInput(
adapter,
undefined,
"normal",
() => [],
() => [{ ...slashSkill, slash }],
).submit(new Event("submit"))
const request = await admitted.promise
expect(request.text).toBe(text)
expect(request.skills?.map((skill) => skill.id)).toEqual([...expected])
})
test("captures slash skills before creating a session in a new worktree", async () => {
const state = createMemoryComposerState({ prompt: "/show-me" }).capture()
let skills: readonly Skill.Info[] | undefined = [slashSkill]
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
const target = session({ calls: [], prompt: async (value) => admitted.resolve(value) })
const adapter: NewSessionComposerAdapter = {
kind: "new-session",
state,
ready: () => true,
controls,
working: () => false,
submitted() {},
async start() {
skills = undefined
return { session: target, cleanupReady: Promise.resolve() }
},
}
await submitInput(
adapter,
undefined,
"normal",
() => [],
() => skills,
).submit(new Event("submit"))
expect((await admitted.promise).skills).toEqual([
expect.objectContaining({ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }),
])
})
test("sends one captured value with explicit delivery after selection switches", async () => {
const state = createMemoryComposerState({ prompt: "ship it" }).capture()
const calls: string[] = []
@@ -357,8 +471,8 @@ describe("Composer submission", () => {
expect(promoted.mode.current()).toBe("shell")
})
test("reuses the message ID when an unacknowledged admission is retried", async () => {
const state = createMemoryComposerState({ prompt: "retry me" }).capture()
test.each(["retry me", "/show-me retry me"])("restores and retries an unacknowledged admission: %s", async (text) => {
const state = createMemoryComposerState({ prompt: text }).capture()
const attempts: string[] = []
const statuses: ("idle" | "running")[] = []
const first = Promise.withResolvers<void>()
@@ -368,6 +482,7 @@ describe("Composer submission", () => {
statuses,
prompt: async (value) => {
attempts.push(value.id ?? "")
expect(value.skills?.map((skill) => skill.id)).toEqual(text.startsWith("/") ? ["show-me"] : [])
throw new Error("network unavailable")
},
})
@@ -386,7 +501,13 @@ describe("Composer submission", () => {
missingSelection() {},
failed: () => (attempts.length === 2 ? first.resolve() : second.resolve()),
}
const submission = submitInput(adapter, notify)
const submission = submitInput(
adapter,
notify,
"normal",
() => [],
() => [slashSkill],
)
await submission.submit(new Event("submit"))
await first.promise
@@ -396,7 +517,7 @@ describe("Composer submission", () => {
expect(attempts).toHaveLength(4)
expect(new Set(attempts).size).toBe(1)
expect(statuses).toEqual(["running", "idle", "running", "idle"])
expect(state.current()).toMatchObject([{ type: "text", content: "retry me" }])
expect(state.current()).toMatchObject([{ type: "text", content: text }])
})
test("forwards structured mentions to custom commands", async () => {
@@ -472,7 +593,13 @@ describe("Composer submission", () => {
},
}
await submitInput(adapter, undefined, "normal", () => catalog).submit(new Event("submit"))
await submitInput(
adapter,
undefined,
"normal",
() => catalog,
() => [{ ...slashSkill, id: Skill.ID.make("review") }],
).submit(new Event("submit"))
expect(await sent.promise).toBe("command")
expect(requests).toEqual([
+25 -1
View File
@@ -1,5 +1,6 @@
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import type { SessionMessageUser, SkillInfo } from "@opencode-ai/client/promise"
import { Skill } from "@opencode-ai/schema/skill"
import { Event } from "@opencode-ai/schema/event"
import type { Accessor } from "solid-js"
import type { PromptHistoryComment } from "./history/entry"
@@ -28,6 +29,7 @@ type ComposerSubmitInput = {
adapter: ComposerAdapter
mode: Accessor<"normal" | "shell">
commands: Accessor<readonly { name: string }[] | undefined>
skills: Accessor<readonly Pick<SkillInfo, "id" | "name" | "slash">[] | undefined>
editor: () => HTMLDivElement | undefined
queueScroll: () => void
addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void
@@ -68,6 +70,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
const comments = input.comments.capture()
// Capture command intent before starting a session in a worktree whose catalog has not loaded.
const command = value.mode === "normal" ? findCommand(input.commands(), value.text) : undefined
if (value.mode === "normal" && !command) value.prompt = withSlashSkill(value.prompt, input.skills())
try {
const started =
@@ -287,6 +290,27 @@ function findCommand(commands: ReturnType<ComposerSubmitInput["commands"]>, text
return { command, arguments: arguments_.join(" ") }
}
export function withSlashSkill(prompt: Prompt, skills: ReturnType<ComposerSubmitInput["skills"]>): Prompt {
const first = prompt[0]
if (first?.type !== "text") return prompt
const name = /^\/(\S+)(?:\s|$)/.exec(first.content)?.[1]
const skill = skills?.find((item) => item.slash === true && item.id === name)
if (!skill || prompt.some((part) => part.type === "skill" && part.id === skill.id)) return prompt
const content = `/${skill.id}`
return [
{
type: "skill",
id: Skill.ID.make(skill.id),
name: Skill.Name.make(skill.name),
content,
start: 0,
end: content.length,
},
{ ...first, content: first.content.slice(content.length), start: content.length },
...prompt.slice(1),
]
}
async function sendCommand(
session: ComposerSession,
value: ComposerSubmission,
@@ -35,7 +35,7 @@ export type ComposerInteractionEvent =
export type ComposerInteractionCommand =
| { type: "draft.setText"; value: string }
| { type: "draft.addText"; value: string }
| { type: "mention.add"; item: ComposerSuggestion }
| { type: "mention.add"; item: ComposerSuggestion; range?: { start: number; end: number } }
| { type: "popover.filter"; popover: "command" | "context"; query: string }
| { type: "suggestion.select"; id: string }
| { type: "focus.editor" }
@@ -178,7 +178,13 @@ function suggestionSelected(
: replaceTrigger(current, "/", `${item.label} `),
})
} else {
commands.push({ type: "mention.add", item })
commands.push({
type: "mention.add",
item,
...(item.kind === "skill" && item.label.startsWith("/")
? { range: { start: 0, end: state.popover.type === "command-menu" ? 0 : current.length } }
: {}),
})
}
commands.push({ type: "focus.editor" })
return changed({ ...state, popover: { type: "closed" }, focus: "editor" }, commands)
+63
View File
@@ -319,6 +319,69 @@
}
}
@keyframes session-dropzone-enter {
from {
opacity: 0;
}
}
@keyframes session-dropzone-content-enter {
from {
opacity: 0;
transform: translateY(4px) scale(0.96);
}
}
@keyframes session-dropzone-upload-enter {
from {
opacity: 0;
transform: translateY(4px) scale(0.92);
}
}
@keyframes session-dropzone-stripes-drift {
to {
background-position: 33.941px 0;
}
}
[data-component="session-dropzone"][data-visible="true"] {
animation: session-dropzone-enter 200ms cubic-bezier(0.215, 0.61, 0.355, 1);
}
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-content"] {
animation: session-dropzone-content-enter 200ms cubic-bezier(0.215, 0.61, 0.355, 1);
}
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-upload"] {
animation: session-dropzone-upload-enter 240ms cubic-bezier(0.215, 0.61, 0.355, 1);
}
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-stripes"] {
animation: session-dropzone-stripes-drift 3s linear infinite;
}
[data-component="session-dropzone"] {
--session-dropzone-wash: 4%;
--session-dropzone-stripe: 7%;
--session-dropzone-card: var(--v2-background-bg-base);
}
[data-color-scheme="dark"] [data-component="session-dropzone"] {
--session-dropzone-wash: 6%;
--session-dropzone-stripe: 14%;
--session-dropzone-card: var(--v2-background-bg-layer-01);
}
@media (prefers-reduced-motion: reduce) {
[data-component="session-dropzone"][data-visible="true"],
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-content"],
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-upload"],
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-stripes"] {
animation: none;
}
}
[data-slot="home-projects-scroll"] {
timeline-scope: --home-projects-scroll;
}
+64 -15
View File
@@ -6,6 +6,7 @@ import { Show, createMemo, createSignal } from "solid-js"
import { Schema } from "effect"
import createPresence from "solid-presence"
import { Composer } from "@/composer/composer"
import { ComposerDropzone } from "@/composer/dropzone"
import type { ComposerModel } from "@/composer/model"
import { PromptGitStatus, PromptWorkspaceSelector } from "@/new-session/workspace/selector"
import {
@@ -33,6 +34,8 @@ export const ProviderTipSchema = Persistence.struct({
dismissedAt: Schema.Finite,
})
export const WorkspaceTipSchema = ProviderTipSchema
export function NewSessionView(props: {
composer: ComposerModel
project: PromptProjectController
@@ -54,6 +57,10 @@ export function NewSessionView(props: {
data-component="new-session"
class="relative flex-1 min-h-0 overflow-hidden rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
>
<ComposerDropzone
active={props.composer.state.drag === "active"}
input={props.composer.model.selection.current()?.capabilities.input}
/>
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}>
<Wordmark class="h-auto w-full text-v2-background-bg-inverse" />
@@ -94,7 +101,15 @@ export function NewSessionView(props: {
</div>
</div>
</div>
<ProviderTip />
<NewSessionTips
workspaceEligible={
!!props.project.selected() &&
props.workspace.bar.visible() &&
props.workspace.selection.value() !== "create" &&
props.workspace.project.managed() === 0
}
onWorkspace={() => select("create")}
/>
</div>
</div>
)
@@ -113,56 +128,90 @@ export function NewSessionStatus(props: { visible: boolean }) {
)
}
function ProviderTip() {
function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () => void }) {
const language = useLanguage()
const dialog = useDialog()
const sdk = useWorkspaceLocation()
const providers = useProviders(() => sdk().directory)
const [persistedState, setPersistedState, , persistedReady] = persisted(
const [providerState, setProviderState, , providerReady] = persisted(
Persist.global("new-session.provider-tip"),
ProviderTipSchema,
{ dismissedAt: 0 },
)
const visible = createMemo(
const [workspaceState, setWorkspaceState, , workspaceReady] = persisted(
Persist.global("new-session.workspace-tip"),
WorkspaceTipSchema,
{ dismissedAt: 0 },
)
const workspaceVisible = createMemo(
() =>
props.workspaceEligible &&
workspaceReady() &&
Date.now() - workspaceState.dismissedAt >= providerTipDismissalDuration,
)
const providerVisible = createMemo(
() =>
providers.ready() &&
persistedReady() &&
providerReady() &&
providers.paid().length === 0 &&
Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration,
Date.now() - providerState.dismissedAt >= providerTipDismissalDuration,
)
const tip = createMemo<"workspace" | "provider" | undefined>(() => {
if (providerVisible()) return "provider"
if (workspaceVisible()) return "workspace"
})
const displayed = createMemo<"workspace" | "provider" | undefined>((previous) => tip() ?? previous)
const [ref, setRef] = createSignal<HTMLDivElement>()
const presence = createPresence({
show: visible,
show: () => tip() !== undefined,
element: () => ref() ?? null,
})
const openProviders = () => {
const open = () => {
const current = tip()
if (!current) return
if (current === "workspace") {
setWorkspaceState("dismissedAt", Date.now())
props.onWorkspace()
return
}
void import("@/providers/connect/dialog").then(({ DialogConnectProvider }) => {
void dialog.show(() => <DialogConnectProvider directory={sdk().directory} />)
})
}
const dismiss = () => {
const current = tip()
if (!current) return
if (current === "workspace") {
setWorkspaceState("dismissedAt", Date.now())
return
}
setProviderState("dismissedAt", Date.now())
}
return (
<Show when={presence.present()}>
<div class="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-10">
<div
ref={setRef}
data-component="provider-tip"
data-visible={visible()}
class="group/provider-tip pointer-events-auto relative flex h-6 max-w-full items-center transition-[opacity,transform] duration-[250ms] ease-[cubic-bezier(0.215,0.61,0.355,1)] motion-reduce:transition-none"
data-component="new-session-tip"
data-visible={tip() !== undefined}
class="group/new-session-tip pointer-events-auto relative flex h-6 max-w-full items-center transition-[opacity,transform] duration-[250ms] ease-[cubic-bezier(0.215,0.61,0.355,1)] motion-reduce:transition-none"
classList={{ "data-[visible=false]:animate-out fade-out slide-out-to-bottom-4": true }}
>
<button
type="button"
class="flex h-6 min-w-0 items-center rounded-[4px] pl-1.5 text-[13px] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-muted focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-muted focus-visible:outline-none"
onClick={openProviders}
onClick={open}
>
<span class="truncate">{language.t("home.providerTip")}</span>
<span class="truncate">
{language.t(displayed() === "workspace" ? "home.workspaceTip" : "home.providerTip")}
</span>
<span class="flex size-6 shrink-0 items-center justify-center" aria-hidden="true">
<Icon name="chevron-down" size="small" class="-rotate-90" />
</span>
</button>
<Tooltip
class="hover-reveal absolute left-full top-0 flex h-6 w-7 items-center justify-end delay-0 duration-0 group-hover/provider-tip:delay-[250ms] group-hover/provider-tip:duration-150 group-hover/provider-tip:opacity-100 focus-within:delay-0 focus-within:duration-0 focus-within:opacity-100"
class="hover-reveal absolute left-full top-0 flex h-6 w-7 items-center justify-end delay-0 duration-0 group-hover/new-session-tip:delay-[250ms] group-hover/new-session-tip:duration-150 group-hover/new-session-tip:opacity-100 focus-within:delay-0 focus-within:duration-0 focus-within:opacity-100"
placement="top"
openDelay={1000}
value={language.t("common.dismiss")}
@@ -171,7 +220,7 @@ function ProviderTip() {
type="button"
class="flex size-6 items-center justify-center rounded-[4px] text-v2-icon-icon-muted transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-icon-icon-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-icon-icon-base focus-visible:outline-none"
aria-label={language.t("common.dismiss")}
onClick={() => setPersistedState("dismissedAt", Date.now())}
onClick={dismiss}
>
<Icon name="xmark-small" />
</button>
@@ -1,5 +1,5 @@
import { debounce } from "@solid-primitives/scheduled"
import { createEffect, createMemo, createResource } from "solid-js"
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
@@ -11,8 +11,8 @@ import { normalizeProjectInfo } from "@/runtime/server/global-sync/utils"
import {
isWorkspaceDirectory,
isWorkspaceSelection,
sameDirectory,
workspaceDefaultSelection,
workspaceDirectories,
workspaceSelectionDestination,
} from "@/workspaces/paths"
@@ -56,6 +56,45 @@ export function createNewSessionWorkspaceController(input: {
const current = projectID ? data.project.get(projectID) : undefined
return current ? normalizeProjectInfo(current) : undefined
})
const [worktrees, worktreeActions] = createResource(
() => currentProject()?.id,
async (projectID) => ({
projectID,
items: await serverSDK.api.worktree
.list({ projectID })
.catch(() => (currentProject()?.id === projectID ? currentProject()?.worktrees : undefined) ?? []),
}),
)
onCleanup(
serverSDK.event.listen((event) => {
if (event.type === "worktree.updated") void worktreeActions.refetch()
}),
)
const worktreeItems = createMemo(() => {
const project = currentProject()
if (!project) return []
const loaded = worktrees.latest
return loaded?.projectID === project.id ? loaded.items : project.worktrees
})
const worktreeDirectories = createMemo(() => {
const project = currentProject()
if (!project) return []
const directories = [
...worktreeItems().map((item) => item.directory),
...project.worktrees.map((item) => item.directory),
...(project.sandboxes ?? []),
]
return directories
.filter((directory) => !sameDirectory(project.worktree, directory))
.filter((directory, index, items) => items.findIndex((item) => sameDirectory(item, directory)) === index)
})
const managedWorktrees = createMemo(() => {
const project = currentProject()
if (!project) return 0
return worktreeItems().filter(
(item) => item.strategy !== undefined && !sameDirectory(project.worktree, item.directory),
).length
})
const visible = createMemo(() =>
resolveNewSessionGit({
projectVcs: currentProject()?.vcs,
@@ -66,7 +105,9 @@ export function createNewSessionWorkspaceController(input: {
const project = currentProject()
const worktree = input.selectedWorktree()
if (!project || !worktree) return
return isWorkspaceSelection(project, worktree) ? worktree : undefined
return isWorkspaceSelection(project, worktree) || worktreeDirectories().some((item) => sameDirectory(item, worktree))
? worktree
: undefined
})
const fallback = createMemo(() => {
const project = currentProject()
@@ -97,7 +138,7 @@ export function createNewSessionWorkspaceController(input: {
() => undefined,
)
const project = currentProject()
const directories = project ? [project.worktree, ...workspaceDirectories(project)] : [sdk().directory]
const directories = project ? [project.worktree, ...worktreeDirectories()] : [sdk().directory]
directories.forEach((directory) => void data.location.vcs.sync({ directory }).catch(() => undefined))
})
const branch = createMemo(() =>
@@ -122,7 +163,11 @@ export function createNewSessionWorkspaceController(input: {
workspace: createMemo(() => {
const project = currentProject()
const current = value()
return current === "create" || (!!project && isWorkspaceDirectory(project, current))
return (
current === "create" ||
(!!project &&
(isWorkspaceDirectory(project, current) || worktreeDirectories().some((item) => sameDirectory(item, current))))
)
}),
reset: () => {
input.setSelectedWorktree(undefined)
@@ -142,10 +187,8 @@ export function createNewSessionWorkspaceController(input: {
},
project: {
root: projectRoot,
workspaces: () => {
const project = currentProject()
return project ? workspaceDirectories(project) : []
},
workspaces: worktreeDirectories,
managed: managedWorktrees,
git: visible,
branches: () => {
const current = data.location.vcs.info({ directory: sdk().directory })?.branch.current
@@ -1,5 +1,6 @@
import { createMemo, For, Show } from "solid-js"
import { createMemo, createSignal, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Menu } from "@opencode-ai/ui/menu"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Icon } from "@opencode-ai/ui/icon"
@@ -25,6 +26,9 @@ export function PromptWorkspaceSelector(props: {
let searchInput: HTMLInputElement | undefined
let branchSearchInput: HTMLInputElement | undefined
let focusSearch = false
const branchTruncation = createTruncatedText()
const focusWorktreeSearch = () =>
requestAnimationFrame(() => requestAnimationFrame(() => searchInput?.focus({ preventScroll: true })))
let pending: { type: "select"; value: string } | { type: "create"; branch: string } | { type: "viewAll" } | undefined
const selected = () => (sameDirectory(props.value, props.projectRoot) ? "main" : props.value)
const workspaces = createMemo(() => {
@@ -34,8 +38,8 @@ export function PromptWorkspaceSelector(props: {
})
const icon = () => {
if (selected() === "main") return "monitor"
if (selected() === "create") return "workspace-new"
return "workspace-isolated"
if (selected() === "create") return "plus"
return "outline-worktree"
}
const select = (value: string) => {
pending = { type: "select", value }
@@ -66,13 +70,14 @@ export function PromptWorkspaceSelector(props: {
<>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<Tooltip
appearance={props.onboarding ? "large" : undefined}
placement="top"
openDelay={800}
value={
props.onboarding ? (
<div class="flex flex-col gap-1 text-start">
<div class="flex items-center gap-1.5 font-[530] text-v2-text-text-base">
<Icon name="workspace-isolated" size="small" class="shrink-0 text-v2-text-text-accent" />
<Icon name="outline-worktree" size="small" class="shrink-0 text-v2-text-text-accent" />
<span>{language.t("workspace.onboarding.title")}</span>
</div>
<span class="font-[440] text-v2-text-text-muted">{language.t("workspace.onboarding.description")}</span>
@@ -89,7 +94,10 @@ export function PromptWorkspaceSelector(props: {
aria-description={language.t("session.new.workspace.trigger.tooltip")}
class="flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"
>
<Icon name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
<Icon
name={icon()}
class={`shrink-0 ${selected() === "main" || selected() === "create" ? "text-v2-icon-icon-muted" : "text-v2-icon-icon-accent"}`}
/>
<span class="min-w-0 truncate">{label()}</span>
<Show when={props.onboarding}>
<span
@@ -109,14 +117,8 @@ export function PromptWorkspaceSelector(props: {
<Tooltip
placement="right"
openDelay={800}
value={
<span class="flex flex-col gap-0.5">
<span>{language.t("session.new.workspace.local")}</span>
<span class="font-[440] text-v2-text-text-muted">
{language.t("session.new.workspace.local.tooltip")}
</span>
</span>
}
value={language.t("session.new.workspace.local.tooltip")}
contentClass="max-w-[140px]"
class="min-w-0 flex-1"
>
<span class="min-w-0 truncate">{language.t("session.new.workspace.local")}</span>
@@ -126,24 +128,22 @@ export function PromptWorkspaceSelector(props: {
</Show>
</Menu.Item>
<Menu.Item onSelect={() => select("create")}>
<Icon name="workspace-new" />
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
<Icon name="plus" />
<Tooltip
placement="right"
openDelay={800}
value={language.t("session.new.workspace.new.tooltip")}
contentClass="max-w-[140px]"
class="min-w-0 flex-1"
>
<span class="min-w-0 truncate">{language.t("workspace.new")}</span>
</Tooltip>
<Show when={selected() === "create"}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</Menu.Item>
</Menu.Group>
<Show
when={props.workspaces.length > 0}
fallback={
<>
<Menu.Separator class="h-[0.5px]" />
<Menu.Item onSelect={() => (pending = { type: "viewAll" })}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</Menu.Item>
</>
}
>
<Show when={props.workspaces.length > 0}>
<Menu.Separator class="h-[0.5px]" />
<Menu.Sub
gutter={0}
@@ -156,10 +156,11 @@ export function PromptWorkspaceSelector(props: {
}
if (!focusSearch || props.workspaces.length < 10) return
focusSearch = false
requestAnimationFrame(() => searchInput?.focus())
focusWorktreeSearch()
}}
>
<Menu.SubTrigger
onClick={focusWorktreeSearch}
onKeyDown={(event) => {
if (
event.key === "ArrowRight" ||
@@ -170,7 +171,7 @@ export function PromptWorkspaceSelector(props: {
focusSearch = true
}}
>
<Icon name="workspace-isolated" />
<Icon name="outline-worktree" />
<span class="min-w-0 flex-1 truncate">
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
</span>
@@ -205,7 +206,7 @@ export function PromptWorkspaceSelector(props: {
<For each={workspaces()}>
{(workspace) => (
<Menu.Item onSelect={() => select(workspace)}>
<Icon name="workspace-isolated" />
<Icon name="outline-worktree" />
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
<Show when={selected() === workspace}>
<Icon name="check" size="small" class="shrink-0" />
@@ -213,6 +214,11 @@ export function PromptWorkspaceSelector(props: {
</Menu.Item>
)}
</For>
<Show when={search.workspaces.trim() && workspaces().length === 0}>
<div class="px-3 py-4 text-center text-[13px] font-[440] leading-5 text-v2-text-text-muted">
{language.t("session.new.workspace.search.empty")}
</div>
</Show>
<Menu.Separator class="h-[0.5px]" />
<Menu.Item onSelect={() => (pending = { type: "viewAll" })}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
@@ -232,6 +238,7 @@ export function PromptWorkspaceSelector(props: {
<Tooltip
placement="top"
value={language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
disabled={!branchTruncation.truncated()}
class="ms-1 min-w-0 max-w-[220px]"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
@@ -245,7 +252,7 @@ export function PromptWorkspaceSelector(props: {
>
<Menu.Trigger class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-muted focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03 data-[expanded]:text-v2-text-text-muted">
<Icon name="branch-out" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">
<span ref={branchTruncation.observe} class="min-w-0 truncate">
{language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
@@ -319,6 +326,7 @@ export function PromptWorkspaceSelector(props: {
export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?: boolean; class?: string }) {
const language = useLanguage()
const truncation = createTruncatedText()
const label = () => {
if (props.noGit) return language.t("session.new.git.none")
if (!props.branch) return undefined
@@ -338,15 +346,27 @@ export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?
<Tooltip
placement="top"
value={value()}
disabled={!truncation.truncated()}
class={`min-w-0 max-w-[220px] ${props.class ?? ""}`}
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<div class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint">
<Icon name={icon()} size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{value()}</span>
<span ref={truncation.observe} class="min-w-0 truncate">
{value()}
</span>
</div>
</Tooltip>
)}
</Show>
)
}
function createTruncatedText() {
const [truncated, setTruncated] = createSignal(false)
return {
truncated,
observe: (element: HTMLSpanElement) =>
createResizeObserver(element, () => setTruncated(element.scrollWidth > element.clientWidth)),
}
}
+27 -11
View File
@@ -659,6 +659,7 @@ export const dict = {
"home.sessions.group.yesterday": "Yesterday",
"home.sessions.group.older": "Older",
"home.providerTip": "Connect to 75+ providers to use other models, including Claude, GPT, Gemini, etc",
"home.workspaceTip": "Start next session in a new workspace to keep changes isolated",
"session.tab.session": "Session",
"session.tab.files": "Files",
@@ -1238,30 +1239,43 @@ export const dict = {
"workspace.new": "New worktree",
"common.viewAll": "View all",
"session.new.workspace.local.tooltip": "Use current checkout",
"session.new.workspace.new.tooltip": "Create isolated checkout",
"session.new.workspace.local.tooltip": "Uses projects current checkout",
"session.new.workspace.new.tooltip": "Creates isolated copy from current checkout",
"session.new.workspace.fromBranch": "from {{branch}}",
"session.new.workspace.createFrom": "Create from branch",
"session.new.workspace.branch.search.placeholder": "Search branches",
"session.new.workspace.trigger.tooltip": "Select where to run session",
"session.new.workspace.search.placeholder": "Search worktrees",
"session.new.workspace.search.empty": "No matching worktrees",
"settings.tab.workspaces": "Worktrees",
"settings.workspaces.description": "Review worktrees and manage disk usage",
"settings.workspaces.filter.all": "All projects",
"settings.workspaces.empty": "No worktrees",
"settings.workspaces.empty": "No worktrees yet",
"settings.workspaces.empty.description": "Worktrees created in OpenCode will appear here",
"settings.workspaces.count.one": "{{count}} worktree",
"settings.workspaces.count.other": "{{count}} worktrees",
"settings.workspaces.sessions.one": "{{count}} session in {{project}}",
"settings.workspaces.sessions.other": "{{count}} sessions in {{project}}",
"settings.workspaces.sessions.filtered.one": "{{count}} session",
"settings.workspaces.sessions.filtered.other": "{{count}} sessions",
"settings.workspaces.lastActiveSession": "Last active session",
"settings.workspaces.deleteAll": "Delete all worktrees",
"settings.workspaces.deleteAll.confirm": "Delete all {{count}} worktrees?",
"settings.workspaces.delete.warning":
"The worktree directory and branch will be permanently removed, including any unmerged changes shown below.",
"settings.workspaces.deleteAll.confirm.one": "This will permanently delete {{count}} selected worktree.",
"settings.workspaces.deleteAll.confirm.other": "This will permanently delete {{count}} selected worktrees.",
"settings.workspaces.deleteWithoutSessions": "Delete worktrees without sessions",
"settings.workspaces.deleteWithoutSessions.confirm.one":
"This will permanently delete {{count}} worktree without linked sessions.",
"settings.workspaces.deleteWithoutSessions.confirm.other":
"This will permanently delete {{count}} worktrees without linked sessions.",
"settings.workspaces.deleteWithoutSessions.warning":
"Worktrees with unmerged changes or active locations will be kept.",
"settings.workspaces.delete.button": "Delete worktrees",
"settings.workspaces.delete.warning": "This permanently deletes the worktree and its branch.",
"settings.workspaces.deleteAll.warning":
"The {{count}} selected worktrees in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.",
"Worktrees with unmerged changes, linked sessions, or active locations will be kept.",
"settings.workspaces.delete.blocked.active": "The active worktree cannot be deleted.",
"settings.workspaces.delete.blocked.linked":
"Linked sessions will remain, but their working directory will be permanently removed.",
"Linked sessions will remain, but their working directory will no longer exist.",
"settings.workspaces.default.title": "Default environment",
"settings.workspaces.default.description": "Choose where new sessions start",
"settings.workspaces.default.lastUsed": "Last used per project",
@@ -1272,9 +1286,10 @@ export const dict = {
"workspace.move.failed": "Failed to move session",
"workspace.lifecycle.creating": "Creating worktree",
"workspace.lifecycle.created": "Worktree created",
"workspace.lifecycle.deleting": "Deleting…",
"workspace.lifecycle.starting": "Starting session",
"workspace.onboarding.title": "Isolate sessions with worktrees",
"workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository",
"workspace.onboarding.description": "Each gets its own checkout",
"workspace.lifecycle.moving": "Moving to worktree",
"workspace.lifecycle.set": "Worktree set",
"session.summary.title": "Session details",
@@ -1293,9 +1308,10 @@ export const dict = {
"workspace.status.checking": "Checking for unmerged changes…",
"workspace.status.error": "Unable to verify git status.",
"workspace.status.clean": "No unmerged changes detected.",
"workspace.status.dirty": "Unmerged changes detected in this worktree.",
"workspace.status.dirty": "Unmerged changes will be lost.",
"workspace.delete.title": "Delete worktree",
"workspace.delete.confirm": 'Delete worktree "{{name}}"?',
"workspace.delete.confirm": "Delete {{name}}”?",
"workspace.delete.location": "Location",
"workspace.delete.button": "Delete worktree",
"workspace.reset.title": "Reset worktree",
"workspace.reset.confirm": 'Reset worktree "{{name}}"?',
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { WorkspaceOnboardingSchema, ProviderTipSchema } from "@/new-session/view"
import { WorkspaceOnboardingSchema, ProviderTipSchema, WorkspaceTipSchema } from "@/new-session/view"
import { ModelSelectionSchema } from "@/providers/models/selection"
import { Persistence } from "@/runtime/persistence/schema"
import { FileViewsSchema } from "@/workspaces/files/view-cache"
@@ -12,6 +12,7 @@ describe("persisted consumer schemas", () => {
test("onboarding and provider tip retain defaults and validate stored values", () => {
const onboarding = Schema.decodeUnknownSync(Persistence.withInitial(WorkspaceOnboardingSchema, { used: false }))
const tip = Schema.decodeUnknownSync(Persistence.withInitial(ProviderTipSchema, { dismissedAt: 0 }))
const workspaceTip = Schema.decodeUnknownSync(Persistence.withInitial(WorkspaceTipSchema, { dismissedAt: 0 }))
expect(onboarding({})).toEqual({ used: false })
expect(onboarding({ used: "true" })).toEqual({ used: false })
expect(onboarding({ used: true })).toEqual({ used: true })
@@ -19,6 +20,7 @@ describe("persisted consumer schemas", () => {
expect(tip({ dismissedAt: "yesterday" })).toEqual({ dismissedAt: 0 })
expect(tip({ dismissedAt: Infinity })).toEqual({ dismissedAt: 0 })
expect(tip({ dismissedAt: 123 })).toEqual({ dismissedAt: 123 })
expect(workspaceTip({ dismissedAt: 123 })).toEqual({ dismissedAt: 123 })
})
test("collapse records recover malformed entries without losing valid siblings", () => {
@@ -59,6 +59,9 @@ type PlatformBase = {
/** Resolve the native source path for a desktop File. */
getPathForFile?(file: File): string
/** Observe native drag cancellation that does not reach the renderer event loop. */
onDragCancel?(callback: () => void): () => void
/** Open a native save file dialog and write content to the selected path (desktop only) */
saveFile?(opts: SaveFilePickerOptions, content: string): Promise<boolean>
+2 -4
View File
@@ -4,16 +4,14 @@ import { useComposerState } from "@/composer/persistence"
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useWorkspaceLocation } from "@/workspaces/location"
import type { SessionModel } from "../model"
export function createActiveComposerAdapter(input: {
session: SessionModel
sessionID: string
controls: Accessor<ComposerControls>
submitted: () => void
setEditor: (element: HTMLDivElement) => void
}) {
const id = input.session.identity.params.id
if (!id) throw new Error("Active Composer requires a Session ID")
const id = input.sessionID
const prompt = useComposerState()
prompt.current()
@@ -0,0 +1,57 @@
import { createEffect, createMemo, on, type Accessor } from "solid-js"
import type { ComposerControls } from "@/composer/adapter"
import { setCursorPosition } from "@/composer/editor/dom"
import { createComposerModel } from "@/composer/model"
import { useSettings } from "@/settings/model"
import { createActiveComposerAdapter } from "./adapter"
import { createSessionQueue } from "./queue"
import { createSessionComposerRegionController } from "./session-composer-region-controller"
export function createSessionComposerController(input: {
sessionID: string
controls: Accessor<ComposerControls>
dock: Parameters<typeof createSessionComposerRegionController>[0]
}) {
const settings = useSettings()
const region = createSessionComposerRegionController(input.dock)
let editor: HTMLDivElement | undefined
const adapter = createActiveComposerAdapter({
sessionID: input.sessionID,
controls: input.controls,
submitted: region.onResponseSubmit,
setEditor: (element) => {
editor = element
region.setPromptRef(element)
},
})
const queue = createSessionQueue({
sessionID: input.sessionID,
draft: adapter.state,
working: adapter.working,
behavior: settings.general.followUpBehavior,
restoreFocus: (cursor) => {
const target = editor
if (!target) return
requestAnimationFrame(() => {
target.focus()
setCursorPosition(target, cursor)
})
},
})
const composer = createComposerModel(adapter, { queue })
const editable = createMemo(() => region.showComposer() && !region.child())
// Requests hide the view without disposing its draft or queue edit.
createEffect(on(editable, () => composer.onDragLeave()))
return {
region,
queue,
composer,
drop: {
active: () => editable() && composer.state.drag === "active",
input: () => composer.model.selection.current()?.capabilities.input,
},
}
}
export type SessionComposerController = ReturnType<typeof createSessionComposerController>
+5 -6
View File
@@ -4,7 +4,6 @@ import { useMutation } from "@tanstack/solid-query"
import type { SessionInboxInfo } from "@opencode-ai/client/promise"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { ComposerDelivery } from "@/composer/adapter"
import type { ComposerModel } from "@/composer/model"
import type { ComposerStateTarget } from "@/composer/submission-state"
import type { ImageAttachmentPart, Prompt } from "@/composer/state"
import { clonePrompt, promptLength } from "@/composer/prompt-parts"
@@ -30,7 +29,7 @@ export function createSessionQueue(input: {
draft: ComposerStateTarget
working: Accessor<boolean>
behavior: Accessor<ComposerDelivery>
composer: Accessor<ComposerModel | undefined>
restoreFocus: (cursor: number) => void
}) {
const data = useData()
const server = useServerSDK()
@@ -159,9 +158,9 @@ export function createSessionQueue(input: {
},
})
const text = queuedPromptText(item)
input.composer()?.dispatch({ type: "mode.normal" })
input.draft.mode.set("normal")
input.draft.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
input.composer()?.restoreFocus(text.length)
input.restoreFocus(text.length)
return true
}
const cancelEdit = () => {
@@ -170,10 +169,10 @@ export function createSessionQueue(input: {
setState("editing", undefined)
// Mode first, then prompt, then retry: mode and prompt writes both clear
// the retry marker.
input.composer()?.dispatch({ type: editing.stash.mode === "shell" ? "mode.shell" : "mode.normal" })
input.draft.mode.set(editing.stash.mode)
input.draft.set(editing.stash.prompt, editing.stash.cursor)
if (editing.stash.retry) input.draft.retry.set(editing.stash.retry)
input.composer()?.restoreFocus(editing.stash.cursor)
input.restoreFocus(editing.stash.cursor)
}
const confirmEdit = (delivery: ComposerDelivery) => {
const editing = state.editing
+31 -62
View File
@@ -4,9 +4,8 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode-ai/ui/scroll-view"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useNavigate } from "@solidjs/router"
import { createEffect, on, onMount } from "solid-js"
import { createEffect, createMemo, on, onMount, type Accessor } from "solid-js"
import { Composer } from "@/composer/composer"
import { createComposerModel, type ComposerModel } from "@/composer/model"
import { useComposerState } from "@/composer/persistence"
import { createComposerControls } from "@/composer/selection"
import { setCursorPosition } from "@/composer/editor/dom"
@@ -25,18 +24,16 @@ import { restorePromptModel, syncPromptModel, syncSessionModel } from "../sessio
import type { SessionTimelineInteraction } from "../timeline/interaction"
import { createSessionRevert } from "../revert"
import { SessionComposerRegion } from "./session-composer-region"
import { createSessionComposerRegionController } from "./session-composer-region-controller"
import { createActiveComposerAdapter } from "./adapter"
import { createSessionQueue } from "./queue"
import { createSessionComposerController, type SessionComposerController } from "./controller"
import { SessionQueuePanel } from "./queue-panel"
import { resolveSessionComposerSelection } from "./selection"
import { createSessionRequestModel } from "../requests/model"
import { useSettings } from "@/settings/model"
export function createActiveSessionRegion(input: {
session: SessionModel
screen: SessionScreenLayout
timeline: SessionTimelineInteraction
visible: Accessor<boolean>
}) {
const command = useCommand()
const dialog = useDialog()
@@ -180,7 +177,30 @@ export function createActiveSessionRegion(input: {
},
])
const dock = {
state,
parentID: input.session.data.parentID,
centered: input.screen.centered,
onResponseSubmit: input.timeline.actions.resume,
openParent,
setPromptRef: (element: HTMLDivElement) => {
promptRef = element
},
setDockRef: input.timeline.view.setDockRef,
}
const active = createMemo(
on(
() => (input.visible() ? input.session.identity.sessionID() : undefined),
(sessionID) => (sessionID ? createSessionComposerController({ sessionID, controls, dock }) : undefined),
),
)
return {
active,
drop: {
active: () => active()?.drop.active() ?? false,
input: () => active()?.drop.input(),
},
actions: {
timeline: {
get revert() {
@@ -190,76 +210,25 @@ export function createActiveSessionRegion(input: {
openAttachment,
} satisfies SessionUserActions,
},
region: {
centered: input.screen.centered,
openParent,
prompt,
setDockRef: input.timeline.view.setDockRef,
setPromptRef: (element: HTMLDivElement) => {
promptRef = element
},
state,
},
input: {
controls,
setPromptRef: (element: HTMLDivElement) => {
promptRef = element
},
},
submitted: () => input.timeline.actions.resume(),
requests: state,
workspaceMoveEligible: () => true,
}
}
export type ActiveSessionRegionModel = ReturnType<typeof createActiveSessionRegion>
export function ActiveSessionComposerRegion(props: {
model: ActiveSessionRegionModel
session: SessionModel
onResponseSubmit: () => void
}) {
const settings = useSettings()
const region = createSessionComposerRegionController({
state: props.model.region.state,
parentID: props.session.data.parentID,
centered: props.model.region.centered,
onResponseSubmit: props.onResponseSubmit,
openParent: props.model.region.openParent,
setPromptRef: props.model.region.setPromptRef,
setDockRef: props.model.region.setDockRef,
})
const adapter = createActiveComposerAdapter({
session: props.session,
controls: props.model.input.controls,
submitted: props.model.submitted,
setEditor: props.model.input.setPromptRef,
})
let composer: ComposerModel | undefined
const queue = createSessionQueue({
sessionID: requireSessionID(props.session),
draft: adapter.state,
working: adapter.working,
behavior: settings.general.followUpBehavior,
composer: () => composer,
})
composer = createComposerModel(adapter, { queue })
export function ActiveSessionComposerRegion(props: { model: SessionComposerController }) {
return (
<SessionComposerRegion
controller={region}
controller={props.model.region}
composer={
<div class="relative">
<SessionQueuePanel queue={queue} />
<SessionQueuePanel queue={props.model.queue} />
<div class="relative z-10">
<Composer model={composer} borderUnderlay />
<Composer model={props.model.composer} borderUnderlay />
</div>
</div>
}
/>
)
}
function requireSessionID(session: SessionModel) {
const id = session.identity.params.id
if (!id) throw new Error("Active Composer requires a Session ID")
return id
}
+11 -7
View File
@@ -15,6 +15,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { MessageTimeline, SessionSummaryPanel } from "@/session/timeline/message-timeline"
import { useServer } from "@/runtime/server/current"
import { projectForSession } from "@/shell/layout/helpers"
import { ComposerDropzone } from "@/composer/dropzone"
import type { SessionModel } from "@/session/model"
import { SESSION_PANEL_WIDTH_MIN } from "@/session/session-panel-width"
import { SessionPanelFrame } from "@/session/session-frame"
@@ -134,8 +135,8 @@ export function SessionScreen(props: { session: SessionModel }) {
session,
screen,
timeline,
visible: conversationVisible,
})
useUsageExceededDialogs()
const sessionErrorFallback = (error: unknown, reset: () => void) => {
@@ -176,7 +177,7 @@ export function SessionScreen(props: { session: SessionModel }) {
review.mobile.setTab("changes")
session.layout.view().terminal.close()
}}
backgroundTasks={composer.region.state.background.tasks()}
backgroundTasks={composer.requests.background.tasks()}
/>
)}
</Show>
@@ -198,6 +199,11 @@ export function SessionScreen(props: { session: SessionModel }) {
const sessionPanelContent = () => (
<>
<ComposerDropzone
active={composer.drop.active()}
input={composer.drop.input()}
identity={session.layout.tabKey}
/>
<Show when={!isDesktop() && !!session.identity.params.id}>{mobileTabs()}</Show>
{/* Surface query errors without suspending session metadata while messages load. */}
<Show when={timeline.resource.error}>
@@ -235,7 +241,7 @@ export function SessionScreen(props: { session: SessionModel }) {
<MessageTimeline
hideHeader={!isDesktop()}
session={session}
background={composer.region.state.background}
background={composer.requests.background}
actions={composer.actions.timeline}
scroll={timeline.scroll}
onResumeScroll={timeline.actions.resume}
@@ -263,10 +269,8 @@ export function SessionScreen(props: { session: SessionModel }) {
</Switch>
</div>
<Show when={conversationVisible() ? session.identity.params.id : undefined} keyed>
{(_id) => (
<ActiveSessionComposerRegion model={composer} session={session} onResponseSubmit={timeline.actions.resume} />
)}
<Show when={composer.active()} keyed>
{(model) => <ActiveSessionComposerRegion model={model} />}
</Show>
</>
)
+1 -1
View File
@@ -14,7 +14,7 @@ export function SessionRouteFrame(props: ParentProps<{ padded?: boolean }>) {
export function SessionPanelFrame(props: ParentProps<{ raised?: boolean }>) {
return (
<div
class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-[10px] bg-v2-background-bg-base"
class="relative flex min-h-0 flex-1 flex-col overflow-hidden rounded-[10px] bg-v2-background-bg-base"
classList={{
"shadow-[var(--v2-elevation-raised)]": props.raised,
}}
@@ -91,7 +91,7 @@ export function SessionProjectMenu(props: {
when={props.showProjectIcon}
fallback={
<span class={props.workspace ? "text-v2-icon-icon-accent" : "text-v2-icon-icon-muted"}>
<Icon name={props.workspace ? "workspace-isolated" : "monitor"} />
<Icon name={props.workspace ? "outline-worktree" : "monitor"} />
</span>
}
>
@@ -242,7 +242,7 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
)
const title = createMemo(() =>
pending()
? language.t("command.session.new")
? language.t("session.tab.session")
: sessionTitle(props.session?.title ?? (parentID() ? undefined : info()?.title)),
)
const project = createMemo(() => {
@@ -185,7 +185,7 @@ function WorkspaceMoveAction(props: {
: "flex h-[46px] w-full items-center gap-2 rounded-b-[6px] px-3 pe-9 pt-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted focus-visible:outline-none"
}
>
<Icon name="workspace-new" class="shrink-0 text-v2-icon-icon-muted" />
<Icon name="outline-worktree" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{language.t("workspace.move.title")}</span>
</SessionWorkspaceMenu>
<button
@@ -257,7 +257,10 @@ export function SessionSummaryPanel(props: {
gutter={props.mobile ? 4 : -22}
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed`}
>
<Icon name={props.local ? "monitor" : "workspace-isolated"} class="shrink-0 text-v2-icon-icon-muted" />
<Icon
name={props.local ? "monitor" : "outline-worktree"}
class={`shrink-0 ${props.local ? "text-v2-icon-icon-muted" : "text-v2-icon-icon-accent"}`}
/>
<span dir="auto" class="min-w-0 flex-1 truncate text-start">
{location()}
</span>
@@ -7,7 +7,6 @@ import type { Project } from "@/runtime/server/types"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { useSettingsDialog } from "@/settings/command"
import { pathKey } from "@/workspaces/path-key"
import { showToast } from "@/shell/notifications/toast"
import { containsDirectory, sameDirectory, workspaceDirectories } from "@/workspaces/paths"
@@ -28,7 +27,6 @@ export function SessionWorkspaceMenu(props: {
const language = useLanguage()
const serverSDK = useServerSDK()
const data = useData()
const openWorkspaces = useSettingsDialog("workspaces")
const [store, setStore] = createStore({ selected: undefined as string | undefined })
const [directories, setDirectories] = createSignal(workspaceDirectories(props.project))
const blocked = () => props.eligible === false || data.session.status(props.sessionID) === "running"
@@ -100,13 +98,13 @@ export function SessionWorkspaceMenu(props: {
</Menu.Item>
</Show>
<Menu.Item disabled={!!store.selected || blocked()} onSelect={() => void move("create")}>
<Icon name="workspace-new" />
<Icon name="plus" />
{language.t("workspace.new")}
</Menu.Item>
<Show when={workspaces().length > 0}>
<Menu.Sub gutter={0} overlap overflowPadding={8}>
<Menu.SubTrigger>
<Icon name="workspace-isolated" />
<Icon name="outline-worktree" />
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
</Menu.SubTrigger>
<Menu.Portal>
@@ -114,7 +112,7 @@ export function SessionWorkspaceMenu(props: {
<For each={workspaces()}>
{(workspace) => (
<Menu.Item disabled={!!store.selected || blocked()} onSelect={() => void move(workspace)}>
<Icon name="workspace-isolated" />
<Icon name="outline-worktree" />
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
</Menu.Item>
)}
@@ -124,10 +122,6 @@ export function SessionWorkspaceMenu(props: {
</Menu.Sub>
</Show>
</Menu.Group>
<Menu.Separator class="h-[0.5px] bg-v2-border-border-base" />
<Menu.Item onSelect={() => openWorkspaces()}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
+103 -14
View File
@@ -647,7 +647,8 @@
.settings-tab-search {
position: relative;
width: 100%;
width: calc(100% - 2px);
margin-inline: 1px;
}
.settings-tab-search [data-component="text-input-v2"] {
@@ -1028,25 +1029,92 @@
display: flex;
flex-direction: column;
gap: 0;
padding: 20px;
border-radius: 6px;
background-color: var(--v2-background-bg-base);
padding: 16px;
border-radius: 8px;
background-color: var(--v2-background-bg-layer-01);
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base);
}
.settings-workspaces-inventory[data-empty="true"] [data-component="settings-list"] {
padding: 0;
background-color: transparent;
box-shadow: none;
}
.settings-workspaces-row-motion {
display: grid;
grid-template-rows: minmax(0, 1fr);
opacity: 1;
transition:
grid-template-rows 150ms ease-out,
opacity 120ms ease-out;
}
.settings-workspaces-row-motion[data-removing="true"] {
grid-template-rows: minmax(0, 0fr);
opacity: 0;
}
.settings-workspaces-row {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
gap: 12px;
overflow: hidden;
transition:
padding-bottom 150ms ease-out,
margin-bottom 150ms ease-out,
border-color 120ms ease-out;
}
.settings-workspaces-row:not(:last-child) {
.settings-workspaces-row-motion:not(:last-child) > .settings-workspaces-row {
padding-bottom: 20px;
margin-bottom: 20px;
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.settings-workspaces-row-motion:has(+ .settings-workspaces-row-motion[data-removing="true"]:last-child)
> .settings-workspaces-row {
padding-bottom: 0;
margin-bottom: 0;
border-color: transparent;
}
.settings-workspaces-empty-motion {
display: grid;
min-height: 0;
grid-template-rows: minmax(0, 0fr);
opacity: 0;
overflow: hidden;
pointer-events: none;
transition:
grid-template-rows 150ms ease-out,
opacity 120ms ease-out;
}
.settings-workspaces-empty-motion[data-visible="true"] {
grid-template-rows: minmax(0, 1fr);
opacity: 1;
pointer-events: auto;
}
.settings-workspaces-empty-motion > .settings-workspaces-empty {
min-height: 0;
overflow: hidden;
}
@media (prefers-reduced-motion: reduce) {
.settings-workspaces-row-motion {
transition: none;
}
.settings-workspaces-row,
.settings-workspaces-empty-motion {
transition: none;
}
}
.settings-workspaces-row-header {
display: flex;
min-width: 0;
@@ -1087,7 +1155,7 @@
color: var(--v2-text-text-base);
font-family: inherit;
font-size: 13px;
font-weight: 530;
font-weight: 440;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
text-overflow: ellipsis;
@@ -1097,6 +1165,11 @@
cursor: default;
}
.settings-workspaces-path-name {
color: var(--v2-text-text-base);
font-weight: 530;
}
.settings-workspaces-meta {
font-size: 13px;
font-weight: 440;
@@ -1104,6 +1177,10 @@
color: var(--v2-text-text-faint);
}
.settings-workspaces-meta-project {
color: var(--v2-text-text-muted);
}
.settings-workspaces-active,
.settings-workspaces-more {
flex-shrink: 0;
@@ -1113,11 +1190,16 @@
color: var(--v2-text-text-faint);
}
.settings-workspaces-active {
font-size: 13px;
line-height: var(--line-height-compact);
}
.settings-workspaces-sessions {
display: flex;
flex-direction: column;
border: 0.5px solid var(--v2-border-border-base);
border-radius: 4px;
border-radius: 6px;
background-color: var(--v2-background-bg-base);
overflow: hidden;
}
@@ -1128,7 +1210,8 @@
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
padding-block: 10px;
padding-inline: 12px 16px;
font-size: 13px;
font-weight: 440;
line-height: 16px;
@@ -1148,22 +1231,28 @@
.settings-workspaces-session-time {
flex-shrink: 0;
font-size: 11px;
line-height: 1;
color: var(--v2-text-text-muted);
font-size: 13px;
line-height: var(--line-height-compact);
color: var(--v2-text-text-faint);
}
.settings-workspaces-empty {
display: flex;
align-items: center;
justify-content: center;
padding-block: 48px;
min-width: 0;
flex-direction: column;
align-items: flex-start;
gap: 4px;
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
color: var(--v2-text-text-muted);
}
.settings-workspaces-empty-title {
color: var(--v2-text-text-base);
font-weight: 530;
}
@media (max-width: 639px) {
.settings-workspaces-header {
padding: 24px 20px 20px;
+21 -4
View File
@@ -1,4 +1,5 @@
import { Component, createEffect, createMemo, For, Show, onMount, startTransition } from "solid-js"
import { createStore } from "solid-js/store"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon"
import { Menu } from "@opencode-ai/ui/menu"
@@ -35,7 +36,7 @@ const sections = [
[
{ value: "servers", icon: "server", label: "status.popover.tab.servers" },
{ value: "projects", icon: "folder", label: "settings.tab.projects" },
{ value: "workspaces", icon: "workspace-isolated", label: "settings.tab.workspaces" },
{ value: "workspaces", icon: "outline-worktree", label: "settings.tab.workspaces" },
],
[
{ value: "providers", icon: "providers", label: "settings.providers.title" },
@@ -54,6 +55,7 @@ export const SettingsScreen: Component = () => {
const servers = useServers()
const tabs = useTabs()
const global = useGlobal()
const [state, setState] = createStore({ worktreeFilterReset: 0 })
let root: HTMLDivElement | undefined
onMount(() => {
@@ -146,7 +148,14 @@ export const SettingsScreen: Component = () => {
</Show>
<For each={group}>
{(section) => (
<Menu.RadioItem value={section.value} closeOnSelect>
<Menu.RadioItem
value={section.value}
closeOnSelect
onSelect={() => {
if (section.value === "workspaces")
setState("worktreeFilterReset", (value) => value + 1)
}}
>
<Icon name={section.icon} />
{language.t(section.label)}
</Menu.RadioItem>
@@ -172,7 +181,12 @@ export const SettingsScreen: Component = () => {
<div class="flex flex-col gap-1 w-full">
<For each={group}>
{(section) => (
<Tabs.Trigger value={section.value}>
<Tabs.Trigger
value={section.value}
onClick={() => {
if (section.value === "workspaces") setState("worktreeFilterReset", (value) => value + 1)
}}
>
<Icon name={section.icon} />
{language.t(section.label)}
</Tabs.Trigger>
@@ -208,7 +222,10 @@ export const SettingsScreen: Component = () => {
</Tabs.Content>
<SettingsServerScope directory={directory()}>
<Tabs.Content value="workspaces" class="settings-panel">
<SettingsWorkspaces activeDirectory={directory()} />
<SettingsWorkspaces
activeDirectory={directory()}
resetProjectFilter={() => state.worktreeFilterReset}
/>
</Tabs.Content>
<Tabs.Content value="providers" class="settings-panel">
<SettingsProviders directory={directory()} onBack={showProviders} />
@@ -42,7 +42,7 @@ export const SettingsProjects: Component = () => {
const name = () => displayName(props.project)
return (
<div
class="group flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] transition-all hover:bg-v2-background-bg-layer-01"
class="group mx-px flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] transition-all hover:bg-v2-background-bg-layer-01"
onClick={() => openProjectSettings(props.project, props.server)}
>
<div class="flex items-center gap-2.5 min-w-0 flex-1">
@@ -1,6 +1,8 @@
import type { Component } from "solid-js"
import { For, Show, createMemo } from "solid-js"
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Key } from "@solid-primitives/keyed"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { useQuery } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button"
@@ -46,7 +48,7 @@ type Workspace = {
project: Project
}
export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (props) => {
export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProjectFilter: () => number }> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const serverSDK = useServerSDK()
@@ -56,6 +58,12 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
const [store, setStore] = createStore({
project: "all",
transaction: undefined as "confirm" | "running" | undefined,
deleting: [] as string[],
removing: [] as string[],
})
createEffect(() => {
props.resetProjectFilter()
setStore("project", "all")
})
const projectQuery = useQuery(() => ({
@@ -111,6 +119,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
] as const,
queryFn: () => loadSessions(workspaceDirectories()),
enabled: serverSDK.connection.status() === "connected" && workspaceDirectories().length > 0,
placeholderData: (previous) => previous,
refetchOnMount: "always",
}))
const sessionsByWorkspace = createMemo(() => {
@@ -123,14 +132,29 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
)
})
const workspaceSessions = (workspace: Workspace) => sessionsByWorkspace().get(pathKey(workspace.directory)) ?? []
const workspacesWithoutSessions = createMemo(() => {
if (sessionQuery.isPending || sessionQuery.isError) return []
return filtered().filter((workspace) => workspaceSessions(workspace).length === 0)
})
const sessionCount = (workspace: Workspace) => {
if (sessionQuery.isPending) return language.t("session.messages.loading")
if (sessionQuery.isError) return language.t("common.requestFailed")
const count = workspaceSessions(workspace).length
return language.plural("settings.workspaces.sessions", count, {
if (selectedProject() !== "all") return language.plural("settings.workspaces.sessions.filtered", count, { count })
const project = projectName(workspace.project)
const label = language.plural("settings.workspaces.sessions", count, {
count,
project: projectName(workspace.project),
project,
})
const start = label.lastIndexOf(project)
if (start < 0) return label
return (
<>
{label.slice(0, start)}
<span class="settings-workspaces-meta-project">{project}</span>
{label.slice(start + project.length)}
</>
)
}
const lastActive = (workspace: Workspace) => {
const updated = workspaceSessions(workspace)[0]?.time.updated
@@ -158,54 +182,64 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
}
const inspectionMessages = (result: WorkspaceDeleteInspection) => {
const messages = [
result.active ? language.t("settings.workspaces.delete.blocked.active") : undefined,
result.linked ? language.t("settings.workspaces.delete.blocked.linked") : undefined,
result.dirty ? language.t("workspace.status.dirty") : undefined,
].filter((message): message is string => message !== undefined)
return messages.length > 0 ? messages : [language.t("workspace.status.clean")]
return messages
}
const blocked = (result: WorkspaceDeleteInspection) => {
showToast({
variant: "error",
title: language.t("workspace.delete.failed.title"),
description: inspectionMessages(result)[0],
description: result.active
? language.t("settings.workspaces.delete.blocked.active")
: inspectionMessages(result)[0],
})
}
const remove = async (workspace: Workspace, force = false, context = captureDeleteContext()) => {
const preflight = await inspect(workspace, context)
if (preflight.result.active || (!force && (preflight.result.linked || preflight.result.dirty))) {
blocked(preflight.result)
return
}
const removed = await context.sdk.api.worktree
.remove({
projectID: workspace.project.id,
directory: workspace.directory,
force,
})
.then(() => true)
.catch((error) => {
showToast({
variant: "error",
title: language.t("workspace.delete.failed.title"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
const key = String(pathKey(workspace.directory))
setStore("deleting", (items) => [...items, key])
try {
const preflight = await inspect(workspace, context)
if (!force && (preflight.result.active || preflight.result.linked || preflight.result.dirty)) {
blocked(preflight.result)
return
}
const removed = await context.sdk.api.worktree
.remove({
projectID: workspace.project.id,
directory: workspace.directory,
force,
})
.then(() => true)
.catch((error) => {
showToast({
variant: "error",
title: language.t("workspace.delete.failed.title"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})
return false
})
if (!removed) return
setStore("removing", (items) => [...items, key])
await new Promise((resolve) => setTimeout(resolve, 150))
tabs.store.forEach((tab) => {
if (tab.type !== "draft" || tab.server !== context.server) return
const directoryMatches = containsDirectory(workspace.directory, tab.directory)
const worktreeMatches = tab.worktree && containsDirectory(workspace.directory, tab.worktree)
if (!directoryMatches && !worktreeMatches) return
tabs.updateDraft(tab.draftID, {
directory: directoryMatches ? workspace.project.worktree : tab.directory,
worktree: undefined,
})
return false
})
if (!removed) return
tabs.store.forEach((tab) => {
if (tab.type !== "draft" || tab.server !== context.server) return
const directoryMatches = containsDirectory(workspace.directory, tab.directory)
const worktreeMatches = tab.worktree && containsDirectory(workspace.directory, tab.worktree)
if (!directoryMatches && !worktreeMatches) return
tabs.updateDraft(tab.draftID, {
directory: directoryMatches ? workspace.project.worktree : tab.directory,
worktree: undefined,
})
})
clearWorkspaceTerminals(workspace.directory, platform, context.sdk.scope)
await projectQuery.refetch()
clearWorkspaceTerminals(workspace.directory, platform, context.sdk.scope)
await projectQuery.refetch()
} finally {
setStore("deleting", (items) => items.filter((item) => item !== key))
setStore("removing", (items) => items.filter((item) => item !== key))
}
}
let inspectionID = 0
@@ -253,13 +287,30 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
if (store.transaction) return
const context = captureDeleteContext()
const inventory = [...filtered()]
const project = projectOptions().find((option) => option.id === selectedProject())?.label ?? selectedProject()
setStore("transaction", "confirm")
void dialog.push(
() => (
<DialogDeleteAllWorkspaces
count={inventory.length}
project={project}
<DialogDeleteWorkspaces
title={language.t("settings.workspaces.deleteAll")}
confirmation={language.plural("settings.workspaces.deleteAll.confirm", inventory.length)}
warning={language.t("settings.workspaces.deleteAll.warning")}
onDelete={() => transact(() => removeAll(inventory, context))}
/>
),
releaseConfirmation,
)
}
const confirmDeleteWithoutSessions = () => {
if (store.transaction || workspacesWithoutSessions().length === 0) return
const context = captureDeleteContext()
const inventory = [...workspacesWithoutSessions()]
setStore("transaction", "confirm")
void dialog.push(
() => (
<DialogDeleteWorkspaces
title={language.t("settings.workspaces.deleteWithoutSessions")}
confirmation={language.plural("settings.workspaces.deleteWithoutSessions.confirm", inventory.length)}
warning={language.t("settings.workspaces.deleteWithoutSessions.warning")}
onDelete={() => transact(() => removeAll(inventory, context))}
/>
),
@@ -271,44 +322,47 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
<>
<div class="settings-tab-header settings-workspaces-header">
<div class="settings-tab-header-row">
<h2 class="settings-tab-title">{language.t("settings.tab.workspaces")}</h2>
<div class="flex flex-col gap-1">
<h2 class="settings-tab-title">{language.t("settings.tab.workspaces")}</h2>
<span class="text-11-regular text-v2-text-text-muted">
{language.t("settings.workspaces.description")}
</span>
</div>
<InlineServerSelect />
</div>
</div>
<div class="settings-tab-body settings-workspaces">
<div class="settings-workspaces-toolbar">
<span class="settings-workspaces-count">
<Show when={!projectQuery.isPending && !projectQuery.isError}>
<Show when={filtered().length > 0}>
<div class="settings-workspaces-toolbar">
<span class="settings-workspaces-count">
{language.plural("settings.workspaces.count", filtered().length)}
</Show>
</span>
<div class="settings-workspaces-toolbar-actions">
<Show when={projects().length > 1}>
<Menu placement="bottom-end" gutter={6}>
<Menu.Trigger class="flex h-6 max-w-48 items-center gap-1 rounded-sm px-2 text-13-medium hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed">
<span class="min-w-0 truncate">
{projectOptions().find((option) => option.id === selectedProject())?.label}
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content>
<For each={projectOptions()}>
{(option) => (
<Menu.Item onSelect={() => setStore("project", option.id)}>
<span class="min-w-0 flex-1 truncate">{option.label}</span>
<Show when={selectedProject() === option.id}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</Menu.Item>
)}
</For>
</Menu.Content>
</Menu.Portal>
</Menu>
</Show>
<Show when={filtered().length > 0}>
</span>
<div class="settings-workspaces-toolbar-actions">
<Show when={projects().length > 1}>
<Menu placement="bottom-end" gutter={6}>
<Menu.Trigger as={Button} size="small" variant="ghost-muted" class="max-w-48">
<span class="min-w-0 truncate">
{projectOptions().find((option) => option.id === selectedProject())?.label}
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content>
<For each={projectOptions()}>
{(option) => (
<Menu.Item onSelect={() => setStore("project", option.id)}>
<span class="min-w-0 flex-1 truncate">{option.label}</span>
<Show when={selectedProject() === option.id}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</Menu.Item>
)}
</For>
</Menu.Content>
</Menu.Portal>
</Menu>
</Show>
<Menu placement="bottom-end" gutter={4}>
<Menu.Trigger
as={IconButton}
@@ -321,78 +375,85 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
/>
<Menu.Portal>
<Menu.Content>
<Show when={workspacesWithoutSessions().length > 0}>
<Menu.Item onSelect={confirmDeleteWithoutSessions}>
{language.t("settings.workspaces.deleteWithoutSessions")}
</Menu.Item>
<Menu.Separator />
</Show>
<Menu.Item onSelect={confirmDeleteAll}>
<span class="settings-workspaces-delete-all">{language.t("settings.workspaces.deleteAll")}</span>
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
</Show>
</div>
</div>
</div>
</Show>
<div class="settings-workspaces-inventory">
<Show
when={filtered().length > 0}
fallback={
<div class="settings-workspaces-inventory" data-empty={filtered().length === 0}>
<SettingsList>
<div class="settings-workspaces-empty-motion" data-visible={filtered().length === 0}>
<div class="settings-workspaces-empty">
{language.t(
projectQuery.isPending
? "common.loading"
: projectQuery.isError
? "common.requestFailed"
: "settings.workspaces.empty",
)}
<Show
when={!projectQuery.isPending && !projectQuery.isError}
fallback={language.t(projectQuery.isPending ? "common.loading" : "common.requestFailed")}
>
<span class="settings-workspaces-empty-title">{language.t("settings.workspaces.empty")}</span>
<span>{language.t("settings.workspaces.empty.description")}</span>
</Show>
</div>
}
>
<SettingsList>
<For each={filtered()}>
{(workspace) => {
const linked = () => workspaceSessions(workspace)
return (
</div>
<Key each={filtered()} by={(workspace) => `${workspace.project.id}:${pathKey(workspace.directory)}`}>
{(workspace) => {
const linked = () => workspaceSessions(workspace())
const key = () => String(pathKey(workspace().directory))
const deleting = () => store.deleting.includes(key())
return (
<div class="settings-workspaces-row-motion" data-removing={store.removing.includes(key())}>
<div class="settings-workspaces-row">
<div class="settings-workspaces-row-header">
<div class="settings-workspaces-copy">
<div class="settings-workspaces-main">
<Tooltip
value={workspace.directory}
placement="top-start"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<span
tabIndex={0}
dir="ltr"
aria-label={workspace.directory}
class="settings-workspaces-path"
>
{workspace.directory}
</span>
</Tooltip>
<WorkspacePath directory={workspace().directory} />
</div>
<span class="settings-workspaces-meta">{sessionCount(workspace)}</span>
<span class="settings-workspaces-meta">{sessionCount(workspace())}</span>
</div>
<div class="settings-workspaces-row-actions">
<Show when={lastActive(workspace)}>
{(value) => (
<Tooltip value={language.t("settings.workspaces.lastActiveSession")} placement="top-end">
<span tabIndex={0} class="settings-workspaces-active">
{value()}
</span>
</Tooltip>
)}
<Show
when={deleting()}
fallback={
<>
<Show when={lastActive(workspace())}>
{(value) => (
<Tooltip
value={language.t("settings.workspaces.lastActiveSession")}
placement="top-end"
>
<span tabIndex={0} class="settings-workspaces-active">
{value()}
</span>
</Tooltip>
)}
</Show>
<IconButton
type="button"
variant="ghost-muted"
size="small"
aria-label={language.t("workspace.delete.confirm", {
name: getFilename(workspace().directory),
})}
disabled={!!store.transaction}
icon={<Icon name="outline-trash" size="small" />}
onClick={() => confirmDelete(workspace())}
/>
</>
}
>
<span class="settings-workspaces-active">
{language.t("workspace.lifecycle.deleting")}
</span>
</Show>
<IconButton
type="button"
variant="ghost-muted"
size="small"
aria-label={language.t("workspace.delete.confirm", {
name: getFilename(workspace.directory),
})}
disabled={!!store.transaction}
icon={<Icon name="trash" size="small" />}
onClick={() => confirmDelete(workspace)}
/>
</div>
</div>
<Show when={linked().length > 0}>
@@ -401,7 +462,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
{(session) => (
<div class="settings-workspaces-session">
<span>{sessionLabel(session)}</span>
<Show when={sessionTime(session)}>
<Show when={linked().length > 1 ? sessionTime(session) : undefined}>
{(time) => <span class="settings-workspaces-session-time">{time()}</span>}
</Show>
</div>
@@ -410,18 +471,48 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
</div>
</Show>
</div>
)
}}
</For>
</SettingsList>
</Show>
</div>
)
}}
</Key>
</SettingsList>
</div>
</div>
</>
)
}
function DialogDeleteAllWorkspaces(props: { count: number; project: string; onDelete: () => Promise<void> }) {
function WorkspacePath(props: { directory: string }) {
const [truncated, setTruncated] = createSignal(false)
const name = () => getFilename(props.directory)
return (
<Tooltip
value={props.directory}
placement="top-start"
disabled={!truncated()}
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<span
ref={(element) => createResizeObserver(element, () => setTruncated(element.scrollWidth > element.clientWidth))}
tabIndex={truncated() ? 0 : undefined}
dir="ltr"
aria-label={props.directory}
class="settings-workspaces-path"
>
<span>{props.directory.slice(0, -name().length)}</span>
<span class="settings-workspaces-path-name">{name()}</span>
</span>
</Tooltip>
)
}
function DialogDeleteWorkspaces(props: {
title: string
confirmation: string
warning: string
onDelete: () => Promise<void>
}) {
const dialog = useDialog()
const language = useLanguage()
const remove = () => {
@@ -434,13 +525,12 @@ function DialogDeleteAllWorkspaces(props: { count: number; project: string; onDe
<Dialog fit>
<DialogHeader>
<DialogTitleGroup
title={language.t("settings.workspaces.deleteAll")}
title={props.title}
description={
<>
{language.t("settings.workspaces.deleteAll.confirm", { count: props.count })}
<br />
{language.t("settings.workspaces.deleteAll.warning", { count: props.count, project: props.project })}
</>
<div class="flex flex-col gap-2">
<div>{props.confirmation}</div>
<div>{props.warning}</div>
</div>
}
/>
</DialogHeader>
@@ -449,7 +539,7 @@ function DialogDeleteAllWorkspaces(props: { count: number; project: string; onDe
{language.t("common.cancel")}
</Button>
<Button type="button" variant="danger" onClick={remove}>
{language.t("settings.workspaces.deleteAll")}
{language.t("settings.workspaces.delete.button")}
</Button>
</DialogFooter>
</Dialog>
@@ -472,7 +562,7 @@ function DialogDeleteWorkspace(props: {
staleTime: 0,
}))
const descriptions = () => {
if (status.isPending) return [language.t("workspace.status.checking")]
if (status.isPending) return []
if (status.isError) return [language.t("workspace.status.error")]
if (!status.data) return []
return props.inspectionMessages(status.data.result)
@@ -487,18 +577,20 @@ function DialogDeleteWorkspace(props: {
<Dialog fit>
<DialogHeader>
<DialogTitleGroup
title={language.t("workspace.delete.title")}
title={language.t("workspace.delete.confirm", { name: getFilename(props.workspace.directory) })}
description={
<>
{language.t("workspace.delete.confirm", { name: getFilename(props.workspace.directory) })}
<br />
<code class="max-w-full rounded-[4px] bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-v2-text-text-base break-all">
{props.workspace.directory}
</code>
<br />
{language.t("settings.workspaces.delete.warning")}
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-1">
<span class="text-11-regular text-v2-text-text-faint">
{language.t(status.isPending ? "workspace.status.checking" : "workspace.delete.location")}
</span>
<code class="block w-fit max-w-full rounded-[4px] bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-v2-text-text-base break-all">
{props.workspace.directory}
</code>
</div>
<div>{language.t("settings.workspaces.delete.warning")}</div>
<For each={descriptions()}>{(description) => <div>{description}</div>}</For>
</>
</div>
}
/>
</DialogHeader>
@@ -509,7 +601,7 @@ function DialogDeleteWorkspace(props: {
<Button
type="button"
variant="danger"
disabled={status.isPending || status.isError || status.data?.result.active}
disabled={status.isPending || status.isError}
onClick={remove}
>
{language.t("workspace.delete.button")}
+1 -1
View File
@@ -64,7 +64,7 @@ export default function Layout(props: ParentProps) {
<aside
ref={(element) => setState("tabsMount", element)}
data-slot="vertical-tabs-sidebar"
class="relative flex h-full min-h-0 shrink-0 flex-col bg-v2-background-bg-deep px-2.5 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]"
class="relative flex h-full min-h-0 shrink-0 flex-col bg-v2-background-bg-deep pe-0.5 ps-2.5 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]"
style={{
width: `${state.tabsWidth}px`,
"padding-bottom": "max(10px, env(safe-area-inset-bottom, 0px))",
@@ -0,0 +1,36 @@
import { expect, test } from "bun:test"
import { render } from "solid-js/web"
import { createComposerAttachments } from "@/composer/attachments/attachments"
test("clears drag state on platform cancellation", async () => {
let cancel = () => {}
let subscribed = false
let dragging: "image" | "@mention" | null = "image"
const dispose = render(() => {
createComposerAttachments({
capture: () => ({ current: () => [], cursor: () => 0, set: () => {} }),
editor: () => undefined,
focusEditor: () => {},
addPart: () => false,
setDraggingType: (type) => (dragging = type),
directory: () => "",
isDialogActive: () => false,
warn: () => {},
duplicate: () => {},
onError: () => {},
onDragCancel: (callback) => {
cancel = callback
subscribed = true
return () => (subscribed = false)
},
})
return null
}, document.createElement("div"))
await Promise.resolve()
cancel()
expect(dragging).toBeNull()
dispose()
expect(subscribed).toBeFalse()
})
+1 -3
View File
@@ -128,9 +128,7 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
external: ["node-gyp"],
format: "esm",
minify: true,
// Bun 1.4.0 cross-compiled bytecode can crash on Windows (oven-sh/bun#40270).
// Re-enable after both the builder and embedded runtime move to Bun 1.4.1.
bytecode: false,
bytecode: true,
sourcemap: Script.channel === "dev" || Script.channel === "local" ? "inline" : "none",
splitting: true,
compile: {
+12
View File
@@ -6,6 +6,7 @@
import type {
AgentInfo,
CommandInfo,
ConfigEntry,
FormCancelInput,
FormInfo,
FormReplyInput,
@@ -84,6 +85,7 @@ type LocationData = {
vcs?: VcsInfo
agent?: AgentInfo[]
command?: CommandInfo[]
config?: ConfigEntry[]
integration?: IntegrationInfo[]
mcpServer?: McpServer[]
mcpResource?: McpResource[]
@@ -1215,6 +1217,11 @@ export function createData(config: CreateDataInput) {
)
break
case "config.updated":
result.location.config.invalidate(location)
if (result.location.config.list(location) !== undefined || sync.has(`location.config:${locationKey(location)}`))
refresh(() => result.location.config.sync(location))
refresh(() => result.location.websearch.refresh(location))
break
case "websearch.updated":
refresh(() => result.location.websearch.refresh(location))
break
@@ -1790,6 +1797,7 @@ export function createData(config: CreateDataInput) {
result.location.vcs.invalidate(location)
result.location.agent.invalidate(location)
result.location.command.invalidate(location)
result.location.config.invalidate(location)
result.location.integration.invalidate(location)
result.location.mcp.server.invalidate(location)
result.location.mcp.resource.invalidate(location)
@@ -1803,6 +1811,10 @@ export function createData(config: CreateDataInput) {
vcs: { info: vcs.list, sync: vcs.sync, invalidate: vcs.invalidate },
agent: locationResource("agent", (location) => api().agent.list({ location })),
command: locationResource("command", (location) => api().command.list({ location })),
config: locationResource("config", async (location) => ({
location: { directory: location.directory, workspaceID: location.workspace },
data: await api().config.get({ location }),
})),
integration: locationResource("integration", (location) => api().integration.list({ location })),
mcp: {
server: locationResource("mcpServer", (location) => api().mcp.list({ location })),
@@ -3,6 +3,56 @@ import { createRoot } from "solid-js"
import { createData, type CreateDataInput } from "../src/solid"
import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise"
test("config reads and update refreshes are opt-in", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const requests: string[] = []
const location = { directory: "/project" }
let model = "provider/first"
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
const url = new URL(request.url)
requests.push(url.pathname)
if (url.pathname === "/api/location") return Response.json(location)
if (url.pathname === "/api/config") return Response.json([{ type: "document", info: { model } }])
if (url.pathname === "/api/mcp/resource")
return Response.json({ location, data: { resources: [], templates: [] } })
return Response.json({ location, data: [] })
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: location.directory,
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
}),
dispose,
}))
const event: OpenCodeEvent = { id: "evt_config", created: 1, type: "config.updated", location, data: {} }
try {
await setup.data.location.sync()
listeners.forEach((listener) => listener({ name: event.type, details: event }))
expect(requests).not.toContain("/api/config")
await setup.data.location.config.sync()
expect(requests.filter((path) => path === "/api/config")).toHaveLength(1)
model = "provider/second"
listeners.forEach((listener) => listener({ name: event.type, details: event }))
await setup.data.location.config.sync()
expect(requests.filter((path) => path === "/api/config")).toHaveLength(2)
expect(setup.data.location.config.list()).toEqual([{ type: "document", info: { model } }])
} finally {
setup.dispose()
}
})
test("event refreshes report failures, remain retryable, and preserve explicit read errors", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const reported = Promise.withResolvers<unknown>()
+1 -1
View File
@@ -4,7 +4,7 @@ FROM ${REGISTRY}/build/base:24.04
SHELL ["/bin/bash", "-lc"]
ARG NODE_VERSION=24.4.0
ARG BUN_VERSION=1.3.14
ARG BUN_VERSION=1.4.1
ENV BUN_INSTALL=/opt/bun
ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
-34
View File
@@ -41,37 +41,3 @@ const result = await Bun.build({
},
})
if (!result.success) throw new AggregateError(result.logs, "Failed to build Core")
// Bun's Node target eagerly creates its shared require helper, so every split
// entry evaluates import.meta.url even when it never requires a module. Keep
// the helper lazy until Bun stops hoisting it into workerd-reachable chunks.
// https://github.com/oven-sh/bun/issues/12615
const eagerRequire = "var __require = /* @__PURE__ */ createRequire(import.meta.url);"
const lazyRequire = `var __require = (specifier) => createRequire(import.meta.url ?? "file:///worker.js")(specifier);
__require.resolve = (specifier, options) => createRequire(import.meta.url ?? "file:///worker.js").resolve(specifier, options);`
const rewritten = await Promise.all(
result.outputs.map(async (output) => {
if (!output.path.endsWith(".js")) return false
const source = await output.text()
const generatedUses = source
.replace(/import\s*\{[^}]*\b__require\b[^}]*\}\s*from\s*["'][^"']+["'];/g, "")
.replace(/export\s*\{[^}]*\b__require\b[^}]*\};/g, "")
.replace(eagerRequire, "")
if (/\bnew\s+__require\s*\(/.test(generatedUses))
throw new Error(`Unsupported generated require constructor in ${output.path}`)
const unsupported = generatedUses.replace(/\b__require\.resolve\s*\(/g, "").replace(/\b__require\s*\(/g, "")
if (/\b__require\b/.test(unsupported)) throw new Error(`Unsupported generated require usage in ${output.path}`)
if (!source.includes(eagerRequire)) return false
if (source.indexOf(eagerRequire) !== source.lastIndexOf(eagerRequire))
throw new Error(`Multiple eager require helpers in ${output.path}`)
const rewrittenSource = source.replace(eagerRequire, lazyRequire)
if (rewrittenSource.includes(eagerRequire))
throw new Error(`Failed to rewrite eager require helper in ${output.path}`)
await Bun.write(output.path, rewrittenSource)
return true
}),
)
if (rewritten.filter(Boolean).length !== 1)
throw new Error("Expected exactly one eager require helper; Bun may have fixed #12615 and made this shim removable")
@@ -44,7 +44,7 @@ export function lockLinux(fd: number): LockResult {
}
}
function errorCode(pointer: Pointer | null) {
function errorCode(pointer: Pointer | bigint | null) {
if (pointer === null) throw new Error("Failed to read process lock error code")
return read.i32(pointer, 0)
}
@@ -19,6 +19,7 @@ export interface WebSocketServerFixture {
}
export interface WebSocketServerOptions {
readonly http?: (request: Request) => Response | Promise<Response>
readonly upgrade?: (request: Request) => boolean
readonly open?: (socket: Bun.ServerWebSocket<ConnectionData>) => void
readonly message?: (socket: Bun.ServerWebSocket<ConnectionData>, message: string | Buffer) => void
@@ -34,6 +35,7 @@ export const makeWebSocketServer = (options: WebSocketServerOptions = {}) =>
port: 0,
fetch(request, server) {
state.headers.push(Object.fromEntries(request.headers.entries()))
if (request.headers.get("upgrade") !== "websocket" && options.http) return options.http(request)
if ((options.upgrade?.(request) ?? true) && server.upgrade(request, { data: { id: connection++ } }))
return undefined
return new Response("WebSocket upgrade required", {
@@ -0,0 +1,172 @@
import { expect } from "bun:test"
import { LLM, LLMRequest, Message } from "@opencode-ai/ai"
import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
import { configure } from "@opencode-ai/ai/providers/openai"
import { SessionModelTransport } from "../src/session/model-transport"
import { WebSocketConstructor } from "../src/effect/websocket-constructor"
import { Session } from "@opencode-ai/schema/session"
import { Effect, Layer, Schema } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { testEffect } from "./lib/effect"
import { makeWebSocketServer } from "./lib/websocket-server"
const runtime = Layer.mergeAll(
LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer), Layer.provide(FetchHttpClient.layer)),
SessionModelTransport.layer.pipe(Layer.provide(WebSocketConstructor.layer)),
)
const it = testEffect(runtime)
const sessionID = Session.ID.make("ses_checkpoint_transport")
const checkpoint = { type: "compaction", encrypted_content: "opaque" }
const fixture = (mode: "success" | "invalid" | "fallback") =>
Effect.gen(function* () {
const requests: Array<Record<string, unknown>> = []
const http: Array<Record<string, unknown>> = []
let disconnect: (() => void) | undefined
const server = yield* makeWebSocketServer({
upgrade: () => mode !== "fallback" || disconnect === undefined,
async http(request) {
http.push(Schema.decodeUnknownSync(Schema.Record(Schema.String, Schema.Unknown))(await request.json()))
return new Response(
`data: ${JSON.stringify({
type: "response.completed",
response: {
id: "resp_http",
output: [checkpoint],
usage: { input_tokens: 70, output_tokens: 7 },
},
})}\n\n`,
{ headers: { "content-type": "text/event-stream", "x-response": "http" } },
)
},
open(socket) {
disconnect = () => socket.close()
},
message(socket, message) {
const body = JSON.parse(message.toString())
requests.push(body)
const id = `resp_${requests.length}`
const send = (event: unknown) => socket.send(JSON.stringify(event))
if (body.input.at(-1)?.type !== "compaction_trigger") {
const item = {
type: "message",
id: `msg_${requests.length}`,
role: "assistant",
content: [{ type: "output_text", text: "Hello" }],
}
send({ type: "response.created", response: { id } })
send({ type: "response.output_item.added", output_index: 0, item })
send({ type: "response.output_text.delta", item_id: item.id, delta: "Hello" })
send({ type: "response.output_item.done", output_index: 0, item })
send({ type: "response.completed", response: { id, output: [item] } })
return
}
send({ type: "response.created", response: { id } })
send({ type: "response.output_item.done", output_index: 0, item: checkpoint })
send({
type: "response.completed",
response: {
id,
output: mode === "invalid" ? [checkpoint, { ...checkpoint, encrypted_content: "second" }] : [checkpoint],
usage: { input_tokens: 100, output_tokens: 10 },
},
})
},
})
return {
requests,
headers: server.state.headers,
http,
opens: () => server.state.opens,
disconnect: () => disconnect?.(),
request: LLM.request({
model: configure({
apiKey: "fixture",
baseURL: server.url.replace(/^ws/, "http").replace(/responses$/, ""),
headers: { "chatgpt-account-id": "account", "x-codex-beta-features": "remote_compaction_v2" },
providerOptions: { parallelToolCalls: true },
}).responses("fixture"),
prompt: "First",
promptCacheKey: "session-key",
}),
}
})
it.live("trigger reuses the append baseline and clears it before the next generation", () =>
Effect.gen(function* () {
const server = yield* fixture("success")
const transport = yield* SessionModelTransport.Service
const webSocket = transport.bind(sessionID)
const first = yield* LLMClient.generate(server.request, { webSocket })
const compacted = yield* LLMClient.compact(
LLMRequest.update(server.request, {
messages: [...server.request.messages, first.message],
}),
{ mechanism: "trigger", webSocket },
)
expect(server.requests[1]).toMatchObject({
previous_response_id: "resp_1",
input: [{ type: "compaction_trigger" }],
})
expect(compacted.responseID).toBe("resp_2")
expect(compacted.usage).toMatchObject({ inputTokens: 100, outputTokens: 10 })
expect(compacted.checkpoint.encrypted).toBe("opaque")
const messages = [Message.assistant(compacted.checkpoint), Message.user("Continue")]
yield* LLMClient.generate(LLMRequest.update(server.request, { messages }), { webSocket })
expect(server.requests[2]?.previous_response_id).toBeUndefined()
expect(server.requests[2]?.input).toMatchObject([
{ type: "compaction", encrypted_content: "opaque" },
{ role: "user", content: [{ type: "input_text", text: "Continue" }] },
])
expect(server.requests[1]?.stream).toBeUndefined()
expect(server.requests[1]?.store).toBe(false)
expect(server.headers[0]).toMatchObject({
authorization: "Bearer fixture",
"chatgpt-account-id": "account",
"x-codex-beta-features": "remote_compaction_v2",
})
expect(server.opens()).toBe(1)
expect(server.http).toHaveLength(0)
}),
)
it.live("invalid trigger output does not retry or commit a continuation checkpoint", () =>
Effect.gen(function* () {
const server = yield* fixture("invalid")
const transport = yield* SessionModelTransport.Service
const webSocket = transport.bind(sessionID)
const first = yield* LLMClient.generate(server.request, { webSocket })
const input = LLMRequest.update(server.request, { messages: [...server.request.messages, first.message] })
const error = yield* LLMClient.compact(input, { mechanism: "trigger", webSocket }).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(server.requests).toHaveLength(2)
expect(server.http).toHaveLength(0)
yield* LLMClient.generate(input, { webSocket })
expect(server.requests[2]?.previous_response_id).toBeUndefined()
expect(server.requests[2]?.input).toHaveLength(2)
}),
)
it.live("trigger recovers over SSE with complete input after the old socket closes and reconnect is rejected", () =>
Effect.gen(function* () {
const server = yield* fixture("fallback")
const transport = yield* SessionModelTransport.Service
const webSocket = transport.bind(sessionID)
const first = yield* LLMClient.generate(server.request, { webSocket })
server.disconnect()
// Let the real close event reach the connector before the next send.
yield* Effect.sleep("20 millis")
const result = yield* LLMClient.compact(
LLMRequest.update(server.request, {
messages: [...server.request.messages, first.message],
}),
{ mechanism: "trigger", webSocket },
)
expect(result.responseID).toBe("resp_http")
expect(result.usage).toMatchObject({ inputTokens: 70, outputTokens: 7 })
expect(server.http).toHaveLength(1)
expect(server.http[0]?.previous_response_id).toBeUndefined()
expect(server.http[0]?.stream).toBe(true)
expect(server.http[0]?.input).toHaveLength(3)
expect(server.requests).toHaveLength(1)
}),
)
@@ -74,23 +74,34 @@ describe("SessionModelTransport local WebSocket server", () => {
const index = requests.length
const id = `msg_${index}`
const text = index === 1 ? "Hello" : "Brief"
const reasoning = { type: "reasoning", id: `rs_${index}`, summary: [], encrypted_content: "stream-encrypted" }
const item = {
type: "message",
id,
status: "completed",
role: "assistant",
content: [{ type: "output_text", text, annotations: [], logprobs: [] }],
}
socket.send(JSON.stringify({ type: "response.created", response: { id: `resp_${index}` } }))
socket.send(JSON.stringify({ type: "response.output_item.done", item: reasoning }))
socket.send(JSON.stringify({ type: "response.output_item.added", item: { type: "message", id } }))
socket.send(JSON.stringify({ type: "response.output_text.delta", item_id: id, delta: text }))
socket.send(JSON.stringify({ type: "response.output_text.done", item_id: id, text }))
socket.send(
JSON.stringify({
type: "response.output_item.done",
item: {
type: "message",
id,
status: "completed",
role: "assistant",
content: [{ type: "output_text", text }],
item,
}),
)
socket.send(
JSON.stringify({
type: "response.completed",
response: {
id: `resp_${index}`,
output: [{ ...reasoning, encrypted_content: "terminal-encrypted" }, item],
},
}),
)
socket.send(JSON.stringify({ type: "response.completed", response: { id: `resp_${index}` } }))
},
},
(server) =>
@@ -119,7 +130,7 @@ describe("SessionModelTransport local WebSocket server", () => {
llm.generate(
LLM.request({
model,
messages: [Message.user("First"), Message.assistant("Hello"), Message.user("Be brief")],
messages: [Message.user("First"), first.message, Message.user("Be brief")],
}),
{ webSocket: executor },
),
+5 -1
View File
@@ -4,7 +4,7 @@ import { app, BrowserWindow, MessageChannelMain } from "electron"
import { Effect, Layer } from "effect"
import { RpcServer } from "effect/unstable/rpc"
import { DesktopRpcs } from "../shared/ipc-rpc"
import { IpcTransportPort } from "../shared/ipc-transport"
import { DragCancelEvent, IpcTransportPort } from "../shared/ipc-transport"
import { DesktopFiles, openExternalURL } from "./files"
import { appHandlers } from "./ipc-handlers/app"
import { eventHandlers } from "./ipc-handlers/events"
@@ -59,6 +59,10 @@ export const registerIpcHandlers = Effect.gen(function* () {
relaunch: lifecycle.relaunch,
}
const wire = (_event: Electron.Event, win: BrowserWindow) => {
win.webContents.on("before-input-event", (_event, input) => {
if (input.type !== "keyDown" || input.key !== "Escape") return
win.webContents.send(DragCancelEvent)
})
win.webContents.on("did-finish-load", () => {
if (win.isDestroyed() || win.webContents.isDestroyed()) return
const channel = new MessageChannelMain()
+3 -1
View File
@@ -1,5 +1,5 @@
import { contextBridge, ipcRenderer, webUtils } from "electron"
import { IpcTransportPort } from "../shared/ipc-transport"
import { DragCancelEvent, IpcTransportPort } from "../shared/ipc-transport"
import { windowIDFromArguments } from "../shared/window-bootstrap"
ipcRenderer.on(IpcTransportPort, (event) => {
@@ -7,6 +7,8 @@ ipcRenderer.on(IpcTransportPort, (event) => {
if (port) window.postMessage(IpcTransportPort, "*", [port])
})
ipcRenderer.on(DragCancelEvent, () => window.dispatchEvent(new Event(DragCancelEvent)))
contextBridge.exposeInMainWorld("electron", {
windowID: windowIDFromArguments(process.argv),
getPathForFile: (file: File) => webUtils.getPathForFile(file),
@@ -7,6 +7,7 @@ import {
import type { ElectronAPI } from "../api-types"
import { setPinchZoomEnabled, webviewZoom } from "../window/zoom"
import { windowFullscreen } from "../window/fullscreen"
import { DragCancelEvent } from "../../shared/ipc-transport"
import { createDesktopFiles } from "./files"
import { createDesktopMenuAction } from "./menu"
import { createDesktopNotify } from "./notifications"
@@ -53,6 +54,10 @@ export function createDesktopPlatform(
windowFullscreen,
getPinchZoomEnabled: () => api.getPinchZoomEnabled(),
setPinchZoomEnabled,
onDragCancel: (callback) => {
window.addEventListener(DragCancelEvent, callback)
return () => window.removeEventListener(DragCancelEvent, callback)
},
runDesktopMenuAction: createDesktopMenuAction(api),
checkAppExists: async (appName) => {
return api.checkAppExists(appName)
@@ -1 +1,2 @@
export const IpcTransportPort = "desktop-rpc-port"
export const DragCancelEvent = "opencode:drag-cancel"
+2 -3
View File
@@ -216,9 +216,6 @@ for (const module of modules) {
const transpiler = new Bun.Transpiler({ loader: "js" })
const bundled = await Bun.file(join(consumer, "dist/worker.js")).text()
if (/createRequire\s*\(\s*import\.meta\.url\s*\)/.test(bundled)) {
throw new Error("Packed workerd bundle contains Bun's eager Node require initializer")
}
const bunGlobals = Array.from(new Set(bundled.match(/\bBun\.[A-Za-z_$][\w$]*/g) ?? []))
if (bunGlobals.length > 0) throw new Error(`Packed workerd bundle references Bun globals: ${bunGlobals.join(", ")}`)
const leaked = [
@@ -230,6 +227,8 @@ for (const module of modules) {
].filter((specifier) => specifier === "bun" || specifier.startsWith("bun:"))
if (leaked.length > 0) throw new Error(`Packed workerd bundle statically imports Bun builtins: ${leaked.join(", ")}`)
// Boot in workerd to catch eager Node initializers; lazy createRequire calls
// in native-only code are valid and must not fail a bundle-wide text check.
await $`node boot.mjs`.cwd(consumer)
console.log("packed SDK consumer OK")
} finally {
@@ -0,0 +1,76 @@
import { expect, story } from "../../storybook/playwright/story"
for (const theme of ["light", "dark"]) {
story(`file tools share Patch's upfront file list in ${theme}`, async ({ mount }, info) => {
const root = await mount("current-session-research-agents--agent-research", {
args: { scenario: "workflow" },
globals: { theme },
})
const group = root.locator('[data-component="collapsed-tool-group"]').filter({ hasText: "Patch" })
const disclosure = group.getByRole("button", { name: /^Used .*Edit.*Write.*Patch$/ })
await disclosure.click()
for (const name of ["edit", "write", "patch"]) {
const tool = group.locator(`[data-timeline-part-id="tool_family_${name}"]`)
const file = tool.locator('[data-slot="accordion-trigger"]')
await expect(file).toHaveAttribute("aria-expanded", "false")
await expect(tool.locator('[data-component="file"]')).toHaveCount(0)
await file.click()
await expect(file).toHaveAttribute("aria-expanded", "true")
await expect(tool.locator('[data-component="file"]')).toBeVisible()
await expect(file).toBeFocused()
await file.press("Space")
await expect(file).toHaveAttribute("aria-expanded", "false")
await file.press("Enter")
await expect(file).toHaveAttribute("aria-expanded", "true")
}
await disclosure.click()
await disclosure.click()
for (const name of ["edit", "write", "patch"]) {
const tool = group.locator(`[data-timeline-part-id="tool_family_${name}"]`)
await expect(tool.locator('[data-slot="accordion-trigger"]')).toHaveAttribute("aria-expanded", "true")
await expect(tool.locator('[data-component="file"]')).toBeVisible()
}
await root.screenshot({ path: info.outputPath(`file-tools-${theme}.png`) })
})
}
for (const tool of ["edit", "write"]) {
for (const controlled of [false, true]) {
story(`${tool} supports forceOpen with ${controlled ? "controlled" : "local"} disclosure`, async ({ mount }) => {
const root = await mount("current-session-file-changes--file-tool-fallbacks", {
args: { tool, controlled, forceOpen: true },
})
await expect(root.getByRole("button", { name: /example\.ts/ })).toHaveAttribute("aria-expanded", "true")
await expect(root.locator('[data-component="file"]')).toBeVisible()
})
}
story(`${tool} preserves input fallback and disclosure through completion`, async ({ mount }) => {
const root = await mount("current-session-file-changes--file-tool-fallbacks", { args: { tool } })
const file = root.getByRole("button", { name: /example\.ts/ })
await expect(file).toBeVisible()
await expect(file).toHaveAttribute("aria-expanded", "false")
await expect(root.getByText("1 file", { exact: true })).toBeVisible()
await file.click()
await expect(root.locator('[data-component="file"]')).toContainText(tool === "edit" ? "after" : "written")
await root.getByRole("button", { name: "Complete file tool" }).click()
await expect(root.getByText("Example diagnostic")).toBeVisible()
await expect(file).toHaveAttribute("aria-expanded", "true")
await file.click()
await expect(file).toHaveAttribute("aria-expanded", "false")
await expect(root.getByText("Example diagnostic")).toBeVisible()
})
}
story("empty writes still show a file row", async ({ mount }) => {
const root = await mount("current-session-file-changes--file-tool-fallbacks", {
args: { tool: "write", empty: true },
})
await root.getByRole("button", { name: "Complete file tool" }).click()
const file = root.getByRole("button", { name: /example\.ts/ })
await expect(file).toHaveAttribute("aria-expanded", "false")
await file.click()
await expect(file).toHaveAttribute("aria-expanded", "true")
await expect(root.locator('[data-component="file"]')).toBeAttached()
await expect(file).toBeVisible()
})
@@ -25,18 +25,17 @@ story("renders every admitted tool family and hides timeline-only exclusions", a
]) {
await expect(timeline.locator(`[data-timeline-part-id="tool_family_${id}"]`), id).toBeVisible()
}
const patch = timeline.locator('[data-timeline-part-id="tool_family_patch"]')
await expect(patch.getByText("1 file", { exact: true })).toBeVisible()
await expect(patch.getByRole("button", { name: "Patch 1 file", exact: true })).toHaveCount(0)
await expect(patch.getByRole("button")).toHaveCount(1)
await expect(patch.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "false")
await expect(patch.locator('[data-slot="message-part-title-filename"]')).toHaveCount(0)
await expect(patch.locator('[data-slot="message-part-actions"]')).toHaveCount(0)
await expect(patch.locator('[data-slot="basic-tool-tool-title"]')).toHaveCSS("font-size", "13px")
const edit = timeline.locator('[data-timeline-part-id="tool_family_edit"]')
await expect(edit).toContainText("Edit")
await expect(edit.locator('[data-slot="message-part-title"]')).toHaveCSS("font-size", "13px")
await expect(edit.locator('[data-slot="message-part-title"]')).toHaveCSS("line-height", "16px")
for (const name of ["edit", "write", "patch"]) {
const tool = timeline.locator(`[data-timeline-part-id="tool_family_${name}"]`)
await expect(tool.getByText("1 file", { exact: true })).toBeVisible()
await expect(tool.getByRole("button")).toHaveCount(1)
await expect(tool.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "false")
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("data-locked", "")
await expect(tool.locator('[data-slot="message-part-title-filename"]')).toHaveCount(0)
await expect(tool.locator('[data-slot="message-part-actions"]')).toHaveCount(0)
await expect(tool.locator('[data-slot="basic-tool-tool-title"]')).toHaveCSS("font-size", "13px")
await expect(tool.locator('[data-slot="basic-tool-tool-title"]')).toHaveCSS("line-height", "16px")
}
await expect(timeline.locator('[data-timeline-part-id="tool_family_todo"]')).toHaveCount(0)
})
@@ -466,102 +466,6 @@
--tool-content-gap: 8px;
}
[data-component="edit-trigger"],
[data-component="write-trigger"] {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
[data-slot="message-part-title-area"] {
flex-grow: 1;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
[data-slot="message-part-title"] {
flex: 1 1 auto;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
font-family: var(--font-family-sans);
font-size: 13px;
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-compact);
letter-spacing: var(--letter-spacing-normal);
color: var(--v2-text-text-muted);
}
[data-slot="message-part-title-spinner"] {
margin-left: 4px;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--v2-text-text-muted);
[data-component="spinner"] {
width: 16px;
height: 16px;
}
}
[data-slot="message-part-title-text"] {
flex-shrink: 0;
text-transform: capitalize;
color: var(--v2-text-text-base);
}
[data-slot="message-part-title-filename"] {
/* No text-transform - preserve original filename casing */
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: var(--font-weight-regular);
direction: ltr;
unicode-bidi: isolate;
}
[data-slot="message-part-path"] {
display: flex;
flex-grow: 1;
min-width: 0;
font-weight: var(--font-weight-regular);
direction: ltr;
unicode-bidi: isolate;
}
[data-slot="message-part-directory"] {
color: var(--v2-text-text-muted);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
direction: rtl;
text-align: left;
}
[data-slot="message-part-filename"] {
color: var(--v2-text-text-base);
flex-shrink: 0;
}
[data-slot="message-part-actions"] {
display: flex;
gap: 16px;
align-items: center;
justify-content: flex-end;
flex-shrink: 0;
}
}
[data-component="edit-content"] {
border-radius: inherit;
border-top: 0.5px solid var(--v2-border-border-muted);
@@ -11,6 +11,7 @@ import {
} from "../storybook/current-session-fixtures"
import { storyDocument, storyPatchFile, storyTool } from "../storybook/current-session-scenarios"
import { SessionTimeline } from "./session-timeline"
import { ToolDisplay } from "../tools/tool-renderer"
export default {
title: "OpenCode/Work/File changes",
@@ -261,3 +262,48 @@ export const CreatedANewFile = {
/>
),
}
export const FileToolFallbacks = {
args: { tool: "edit", empty: false, forceOpen: false, controlled: true },
argTypes: {
tool: { control: "select", options: ["edit", "write"] },
empty: { control: "boolean" },
forceOpen: { control: "boolean" },
controlled: { control: "boolean" },
},
render: (args: { tool: string; empty: boolean; forceOpen: boolean; controlled: boolean }) => {
const [state, setState] = createStore({ completed: false, open: false })
return (
<section class="mx-auto flex w-full max-w-[860px] flex-col gap-4 p-6">
<button type="button" onClick={() => setState("completed", true)}>
Complete file tool
</button>
<CurrentSessionProviders document={storyDocument([])}>
<ToolDisplay
id="tool_file_fallback"
tool={args.tool}
status={state.completed ? "completed" : "running"}
input={{
path: "src/example.ts",
oldString: "export const before = true\n",
newString: "export const after = true\n",
content: args.empty ? "" : "export const written = true\n",
}}
metadata={{
diagnostics: state.completed
? {
"src/example.ts": [
{ severity: 1, message: "Example diagnostic", range: { start: { line: 0, character: 0 } } },
],
}
: {},
}}
open={args.controlled ? state.open : undefined}
onOpenChange={(open) => setState("open", open)}
forceOpen={args.forceOpen}
/>
</CurrentSessionProviders>
</section>
)
},
}
+76 -91
View File
@@ -528,12 +528,12 @@ export function CurrentContextToolGroup(props: {
.join(", "),
)
const label = createMemo(() => {
const notices = props.parts.filter((part) => part.type === "notice").length
const title =
[names(), notices ? i18n.plural("ui.messagePart.context.notice", notices) : undefined]
.filter(Boolean)
.join(", ") ||
i18n.plural("ui.messagePart.context.thought", props.parts.filter((part) => part.type === "reasoning").length)
const thoughts = props.parts.filter((part) => part.type === "reasoning").length
if (!names() && !thoughts) {
const title = i18n.t("ui.messagePart.context.details")
return { text: title, title, before: "", after: "" }
}
const title = names() || i18n.plural("ui.messagePart.context.thought", thoughts)
const text = i18n.t("ui.messagePart.tools.used", { tools: title })
const index = text.indexOf(title)
return { text, title, before: text.slice(0, index).trim(), after: text.slice(index + title.length).trim() }
@@ -951,20 +951,66 @@ export const ToolRegistry = {
render: getTool,
}
function FileTool(props: ToolProps & { title: string; count: number; children: JSX.Element }) {
const i18n = useI18n()
return (
<BasicTool
{...props}
open
onOpenChange={undefined}
locked
icon="code-lines"
defer={false}
rail={false}
trigger={{
title: props.title,
subtitle: props.count > 0 ? `${props.count} ${i18n.plural("ui.common.file", props.count)}` : "",
}}
>
{props.children}
</BasicTool>
)
}
function ToolFileAccordion(props: {
path: string
actions?: JSX.Element
children: JSX.Element
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
forceOpen?: boolean
defer?: boolean
}) {
const value = createMemo(() => props.path || "tool-file")
const [expanded, setExpanded] = createSignal(props.defaultOpen ?? false)
const [visible, setVisible] = createSignal(false)
const open = () => props.open ?? expanded()
const change = (value: boolean) => {
if (props.open === undefined) setExpanded(value)
props.onOpenChange?.(value)
}
createEffect(() => {
if (props.forceOpen && !open()) change(true)
})
createEffect(() => {
if (!open()) {
setVisible(false)
return
}
const frame = requestAnimationFrame(() => setVisible(true))
onCleanup(() => cancelAnimationFrame(frame))
})
return (
<Accordion
multiple
data-scope="apply-patch"
style={{ "--sticky-accordion-offset": "calc(32px + var(--tool-content-gap))" }}
defaultValue={props.defaultOpen === false ? [] : [value()]}
value={open() ? [value()] : []}
onChange={(next) => change(next.includes(value()))}
>
<Accordion.Item value={value()}>
<StickyAccordionHeader>
@@ -986,7 +1032,9 @@ function ToolFileAccordion(props: {
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content>{props.children}</Accordion.Content>
<Accordion.Content>
<Show when={!props.defer || visible()}>{props.children}</Show>
</Accordion.Content>
</Accordion.Item>
</Accordion>
)
@@ -1709,7 +1757,6 @@ ToolRegistry.register({
const value = diff()
return typeof value?.file === "string" ? value.file : inputPath()
})
const filename = () => getFilename(inputPath())
const pending = () => props.status === "streaming" || props.status === "running"
const diffSource = createMemo(
() => {
@@ -1749,39 +1796,15 @@ ToolRegistry.register({
return (
<div data-component="edit-tool">
<BasicTool
{...props}
icon="code-lines"
rail={false}
defer={props.deferContent !== false}
trigger={
<div data-component="edit-trigger">
<div data-slot="message-part-title-area">
<div data-slot="message-part-title">
<span data-slot="message-part-title-text">
<TextShimmer text={i18n.t("ui.messagePart.title.edit")} active={pending()} />
</span>
<Show when={!pending()}>
<span data-slot="message-part-title-filename">{filename()}</span>
</Show>
</div>
<Show when={!pending() && inputPath().includes("/")}>
<div data-slot="message-part-path">
<span data-slot="message-part-directory">{displayDirectory(inputPath())}</span>
</div>
</Show>
</div>
<div data-slot="message-part-actions">
<Show when={!pending() ? diff() : undefined}>
{(diff) => <DiffChanges appearance="standard" changes={diff()} />}
</Show>
</div>
</div>
}
>
<FileTool {...props} title={i18n.t("ui.messagePart.title.edit")} count={path() ? 1 : 0}>
<Show when={path()}>
<ToolFileAccordion
path={path()}
defaultOpen={props.defaultOpen}
open={props.open}
onOpenChange={props.onOpenChange}
forceOpen={props.forceOpen}
defer={props.deferContent !== false}
actions={
<Show when={!pending() ? diff() : undefined}>
{(diff) => <DiffChanges appearance="standard" changes={diff()} />}
@@ -1800,7 +1823,7 @@ ToolRegistry.register({
</ToolFileAccordion>
</Show>
<DiagnosticsDisplay diagnostics={diagnostics()} />
</BasicTool>
</FileTool>
</div>
)
},
@@ -1814,38 +1837,18 @@ ToolRegistry.register({
const path = createMemo(() => (typeof props.input.path === "string" ? props.input.path : ""))
const content = createMemo(() => (typeof props.input.content === "string" ? props.input.content : ""))
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, path()))
const filename = () => getFilename(path())
const pending = () => props.status === "streaming" || props.status === "running"
return (
<div data-component="write-tool">
<BasicTool
{...props}
icon="code-lines"
rail={false}
defer={props.deferContent !== false}
trigger={
<div data-component="write-trigger">
<div data-slot="message-part-title-area">
<div data-slot="message-part-title">
<span data-slot="message-part-title-text">
<TextShimmer text={i18n.t("ui.messagePart.title.write")} active={pending()} />
</span>
<Show when={!pending()}>
<span data-slot="message-part-title-filename">{filename()}</span>
</Show>
</div>
<Show when={!pending() && path().includes("/")}>
<div data-slot="message-part-path">
<span data-slot="message-part-directory">{displayDirectory(path())}</span>
</div>
</Show>
</div>
<div data-slot="message-part-actions">{/* <DiffChanges diff={diff} /> */}</div>
</div>
}
>
<Show when={content() && path()}>
<ToolFileAccordion path={path()}>
<FileTool {...props} title={i18n.t("ui.messagePart.title.write")} count={path() ? 1 : 0}>
<Show when={path()}>
<ToolFileAccordion
path={path()}
defaultOpen={props.defaultOpen}
open={props.open}
onOpenChange={props.onOpenChange}
forceOpen={props.forceOpen}
defer={props.deferContent !== false}
>
<div data-component="write-content">
<Dynamic
component={fileComponent}
@@ -1862,7 +1865,7 @@ ToolRegistry.register({
</ToolFileAccordion>
</Show>
<DiagnosticsDisplay diagnostics={diagnostics()} />
</BasicTool>
</FileTool>
</div>
)
},
@@ -1891,27 +1894,9 @@ ToolRegistry.register({
files().forEach((file) => props.onFileOpenChange?.(file.path, next.includes(file.path)))
}
const subtitle = createMemo(() => {
const count = files().length
if (count === 0) return ""
return `${count} ${i18n.plural("ui.common.file", count)}`
})
return (
<div data-component="apply-patch-tool">
<BasicTool
{...props}
open
onOpenChange={undefined}
locked
icon="code-lines"
defer={false}
rail={false}
trigger={{
title: title(),
subtitle: subtitle(),
}}
>
<FileTool {...props} title={title()} count={files().length}>
<Show when={files().length > 0}>
<Accordion
multiple
@@ -2000,7 +1985,7 @@ ToolRegistry.register({
</Index>
</Accordion>
</Show>
</BasicTool>
</FileTool>
</div>
)
},
+1 -1
View File
@@ -1,4 +1,4 @@
FROM oven/bun:1.3.14-alpine AS base
FROM oven/bun:1.4.1-alpine AS base
WORKDIR /app
+2
View File
@@ -737,6 +737,7 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
slash: { name: "new", aliases: ["clear"] },
run: () => {
const model = local.model.current()
const agent = local.agent.current()
const current =
route.data.type === "session"
? (data.session.get(route.data.sessionID)?.location ?? location.ref)
@@ -750,6 +751,7 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
location.error?.location,
),
})
if (agent) local.agent.set(agent.id)
if (model) local.model.set(model)
dialog.clear()
},
+19 -8
View File
@@ -7,22 +7,33 @@ export function DialogVariant() {
const local = useLocal()
const dialog = useDialog()
const options = createMemo(() =>
local.model.variant.list().map((variant) => ({
value: variant,
title: variant,
const options = createMemo(() => [
{
value: "default",
title: "Default",
onSelect: () => {
dialog.clear()
local.model.variant.set(variant)
local.model.variant.set(undefined)
},
})),
)
},
...local.model.variant
.list()
.filter((variant) => variant !== "default")
.map((variant) => ({
value: variant,
title: variant,
onSelect: () => {
dialog.clear()
local.model.variant.set(variant)
},
})),
])
return (
<DialogSelect<string>
options={options()}
title={"Select variant"}
current={local.model.variant.current()}
current={local.model.variant.current() ?? "default"}
flat={true}
/>
)
+29 -34
View File
@@ -48,7 +48,6 @@ import { useConnected } from "../use-connected"
import { useToast } from "../../ui/toast"
import { createFadeIn } from "../../util/signal"
import { DialogSkill } from "../dialog-skill"
import { useArgs } from "../../context/args"
import { useConfig } from "../../config"
import { usePromptMove } from "./move"
import { resolvePastedAttachments } from "./local-attachment"
@@ -190,7 +189,6 @@ export function Prompt(props: PromptProps) {
const leader = Keymap.useLeaderActive()
const muted = () => leader() || props.muted
const local = useLocal()
const args = useArgs()
const paths = useTuiPaths()
const terminalEnvironment = useTuiTerminalEnvironment()
const clipboard = useClipboard()
@@ -412,18 +410,6 @@ export function Prompt(props: PromptProps) {
),
)
// Initialize agent/model/variant from the durable V2 Session state.
let syncedSessionID: string | undefined
createEffect(() => {
const sessionID = props.sessionID
if (!sessionID || sessionID === syncedSessionID || !local.model.ready) return
const session = data.session.get(sessionID)
if (!session) return
const agent = session.agent && local.agent.list().find((agent) => agent.id === session.agent)
if (agent && !args.agent) local.agent.set(agent.id)
syncedSessionID = sessionID
})
const promptCommands = createMemo(() =>
[
{
@@ -1265,6 +1251,23 @@ export function Prompt(props: PromptProps) {
}
const target = sessionID
const prepareAgent = async () => {
if (!session) {
await data.session.sync(target)
session = data.session.get(target)
}
if (session?.agent !== agent.id) {
await client.api.session.switchAgent({ sessionID: target, agent: agent.id })
}
}
const commitModel = () => {
const model = { providerID: selection.providerID, id: selection.modelID, variant }
const cancelCommit = local.model.trackSessionCommit(target, model, agent.id)
return client.api.session.switchModel({ sessionID: target, model }).catch((error) => {
cancelCommit()
throw new Error(`Failed to switch model: ${errorMessage(error)}`, { cause: error })
})
}
history.append(entry)
const dispatch = (send: () => Promise<unknown>) => {
const setup = newSession
@@ -1276,8 +1279,12 @@ export function Prompt(props: PromptProps) {
dispatch(() => client.api.session.shell({ sessionID: target, command: inputText }))
setStore("mode", "normal")
} else if (slashHead && isCommand) {
const send = () =>
client.api.session.command({
const send = async () => {
await prepareAgent()
// Commands inherit the composer selection; command-specific overrides
// remain server-owned and run after this preparation.
await commitModel()
return client.api.session.command({
sessionID: target,
command: slashHead.name,
text: slashHead.arguments,
@@ -1286,6 +1293,7 @@ export function Prompt(props: PromptProps) {
skills: entry.skills?.length ? entry.skills : undefined,
delivery,
})
}
const setup = newSession
void (setup ? setup.gate.then(send) : send()).catch((error) => {
if (setup) return setup.recover(error)
@@ -1298,19 +1306,12 @@ export function Prompt(props: PromptProps) {
} else {
move.startSubmit()
try {
if (!session) {
await data.session.sync(target)
session = data.session.get(target)
}
if (session?.agent !== agent.id) {
await client.api.session.switchAgent({ sessionID: target, agent: agent.id })
}
await prepareAgent()
} catch (error) {
toast.show({ title: "Failed to prepare session", message: errorMessage(error), variant: "error" })
restoreEntry()
return true
}
const model = { providerID: selection.providerID, id: selection.modelID, variant }
if (session?.revert) {
const error = await client.api.session.revert.commit({ sessionID: target }).then(
() => undefined,
@@ -1359,16 +1360,10 @@ export function Prompt(props: PromptProps) {
skills: entry.skills?.length ? entry.skills : undefined,
delivery,
gate: newSession?.gate,
prepare: () => {
// Commit the captured selection after earlier admissions, including
// compaction setup. Cached state may still precede their SSE echoes;
// the server makes an unchanged selection a no-op.
const cancelCommit = local.model.trackSessionCommit(target, model)
return client.api.session.switchModel({ sessionID: target, model }).catch((error) => {
cancelCommit()
throw new Error(`Failed to switch model: ${errorMessage(error)}`, { cause: error })
})
},
// Commit the captured selection after earlier admissions, including
// compaction setup. Cached state may still precede their SSE echoes;
// the server makes an unchanged selection a no-op.
prepare: commitModel,
})
.catch((error) => {
if (newSession) return newSession.recover(error)
+8
View File
@@ -1,4 +1,5 @@
import { createData } from "@opencode-ai/client/solid"
import type { LocationRef } from "@opencode-ai/client"
import type { Plugin } from "@opencode-ai/plugin/tui"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "./helper"
@@ -21,6 +22,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
const [generatingTitles, setGeneratingTitles] = createStore<Record<string, boolean | undefined>>({})
return {
...data,
location: {
...data.location,
async sync(ref?: LocationRef) {
await data.location.syncInfo(ref)
await Promise.all([data.location.sync(ref), data.location.config.sync(ref)])
},
},
session: {
...data.session,
title: {
+139 -51
View File
@@ -6,7 +6,6 @@ import { useEvent } from "./event"
import path from "path"
import { useTuiPaths } from "./runtime"
import { useArgs } from "./args"
import { useClient } from "./client"
import { RGBA } from "@opentui/core"
import { readJson, writeJsonAtomic } from "../util/persistence"
import {
@@ -23,14 +22,7 @@ import { useRoute } from "./route"
import { useData } from "./data"
import { usePermission } from "./permission"
import { useLocation } from "./location"
export function parseModel(model: string) {
const [providerID, ...rest] = model.split("/")
return {
providerID: providerID,
modelID: rest.join("/"),
}
}
import { parse } from "../util/model"
export function recentModels(model: ModelPreferenceModel, recent: ModelPreferenceModel[]) {
const seen = new Set<string>()
@@ -49,7 +41,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local",
init: () => {
const data = useData()
const client = useClient()
const toast = useToast()
const theme = useTheme()
const { mode } = useThemes()
@@ -83,7 +74,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
)
const [agentStore, setAgentStore] = createStore({
current: undefined as string | undefined,
draftBySession: {} as Record<string, { agent?: string } | undefined>,
})
onCleanup(event.on("session.deleted", (evt) => setAgentStore("draftBySession", evt.data.sessionID, undefined)))
onCleanup(
event.on("session.agent.selected", (evt) => {
// Keep an entry after acknowledgment: CLI defaults must not override a user's later choice.
if (agentStore.draftBySession[evt.data.sessionID]?.agent === evt.data.agent)
setAgentStore("draftBySession", evt.data.sessionID, { agent: undefined })
}),
)
const colors = createMemo(() => {
const step = mode() === "light" ? 800 : 200
return dedupeWith(
@@ -96,7 +96,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return agents()
},
current() {
return agents().find((agent) => agent.id === agentStore.current) ?? agents().at(0)
const draft = route.data.type === "session" ? agentStore.draftBySession[route.data.sessionID] : undefined
const selected =
route.data.type === "session"
? (draft?.agent ??
(draft ? undefined : args.agent) ??
data.session.get(route.data.sessionID)?.agent ??
agentStore.current)
: agentStore.current
return agents().find((agent) => agent.id === selected) ?? agents().at(0)
},
set(id: string) {
if (!agents().some((agent) => agent.id === id))
@@ -105,7 +113,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
message: `Agent not found: ${id}`,
duration: 3000,
})
setAgentStore("current", id)
batch(() => {
const changed = this.current()?.id !== id
if (changed) model.remember()
setAgentStore("current", id)
if (route.data.type === "session") setAgentStore("draftBySession", route.data.sessionID, { agent: id })
// Retain both selections while agent and model commits arrive separately.
const selected = changed && route.data.type === "session" ? model.current() : undefined
if (selected) model.set(selected)
})
},
move(direction: 1 | -1) {
batch(() => {
@@ -115,7 +131,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (next < 0) next = agents().length - 1
if (next >= agents().length) next = 0
const value = agents()[next]
setAgentStore("current", value.id)
this.set(value.id)
})
},
color(id: string) {
@@ -141,14 +157,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
const [selectionState, setSelectionState] = createStore<{
newSessionModelByLocationAgent: Record<string, ModelPreferenceModel | undefined>
draftBySession: Record<string, ModelSelection | undefined>
selectionBySessionAgent: Record<string, Record<string, ModelSelection | undefined> | undefined>
}>({
newSessionModelByLocationAgent: {},
draftBySession: {},
selectionBySessionAgent: {},
})
const repository = createModelPreferenceRepository(path.join(paths.state, "model.json"))
const pendingSelectionCommits = new Map<string, string>()
const pendingSelectionCommits = new Map<string, { agentID: string; selection: string }>()
const selectionKey = (value: ModelSelection) =>
`${modelPreferenceKey(value)}:${normalizeModelVariant(value.variant) ?? "default"}`
const saveState = {
@@ -183,9 +199,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (saveState.pending) savePreferences()
})
const configuredModel = createMemo(() => {
const entry = data.location.config
.list(location.ref)
?.findLast((entry) => entry.type === "document" && entry.info.model !== undefined)
const configured = entry?.type === "document" ? entry.info.model : undefined
if (!configured) return
return typeof configured === "string"
? { ...parse(configured), variant: undefined }
: { providerID: configured.providerID, modelID: configured.model, variant: configured.variant }
})
const fallbackModel = createMemo(() => {
if (args.model) {
const { providerID, modelID } = parseModel(args.model)
const { providerID, modelID } = parse(args.model)
if (isModelValid({ providerID, modelID })) {
return {
providerID,
@@ -194,6 +221,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
}
const configured = configuredModel()
if (configured && isModelValid(configured)) return configured
for (const item of preferences.recent) {
if (isModelValid(item)) {
return item
@@ -221,7 +251,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (route.data.type === "session") return sessionSelection(route.data.sessionID)
const model = newSessionModel()
if (!model) return
return { ...model, variant: normalizeModelVariant(preferences.variant[modelPreferenceKey(model)]) }
return preferredSelection(model)
})
const currentModel = createMemo(() => {
@@ -235,6 +265,23 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return `${JSON.stringify([ref.directory, ref.workspaceID])}:${agentID}`
}
function preferredSelection(model: ModelPreferenceModel): ModelSelection {
const configured = agent.current()?.model
const fallback = configuredModel()
const preferred = preferences.variant[modelPreferenceKey(model)]
const variant = normalizeModelVariant(
preferred ??
(configured?.providerID === model.providerID && configured.id === model.modelID
? configured.variant
: undefined) ??
(fallback?.providerID === model.providerID && fallback.modelID === model.modelID
? fallback.variant
: undefined),
)
const info = models()?.find((item) => item.providerID === model.providerID && item.id === model.modelID)
return { ...model, variant: info?.variants.some((item) => item.id === variant) ? variant : undefined }
}
function durableSelection(sessionID: string): ModelSelection | undefined {
const model = data.session.get(sessionID)?.model
if (!model) return
@@ -246,15 +293,44 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
function sessionSelection(sessionID: string) {
return selectionState.draftBySession[sessionID] ?? durableSelection(sessionID)
const current = agent.current()
if (!current) return
const session = data.session.get(sessionID)
const selected = [
selectionState.selectionBySessionAgent[sessionID]?.[current.id],
!session?.agent || session.agent === current.id ? durableSelection(sessionID) : undefined,
].find((selection) => selection && isModelValid(selection))
if (selected) {
const info = models()?.find((item) => item.providerID === selected.providerID && item.id === selected.modelID)
return {
...selected,
variant: info?.variants.some((variant) => variant.id === selected.variant) ? selected.variant : undefined,
}
}
const model = newSessionModel()
return model && preferredSelection(model)
}
function setSessionSelection(sessionID: string, agentID: string, selection: ModelSelection | undefined) {
setSelectionState("selectionBySessionAgent", sessionID, {
...selectionState.selectionBySessionAgent[sessionID],
[agentID]: selection,
})
}
function setSessionDraft(sessionID: string, selection: ModelSelection) {
const current = agent.current()
if (!current) return
const durable = durableSelection(sessionID)
setSelectionState(
"draftBySession",
const session = data.session.get(sessionID)
setSessionSelection(
sessionID,
durable && selectionKey(durable) === selectionKey(selection) ? undefined : selection,
current.id,
(!session?.agent || session.agent === current.id) &&
durable &&
selectionKey(durable) === selectionKey(selection)
? undefined
: selection,
)
}
@@ -262,14 +338,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (route.data.type === "session") {
const sessionID = route.data.sessionID
const current = sessionSelection(sessionID)
const preferred = normalizeModelVariant(
setSessionDraft(
sessionID,
current?.providerID === model.providerID && current.modelID === model.modelID
? current.variant
: preferences.variant[modelPreferenceKey(model)],
? current
: preferredSelection(model),
)
const info = models()?.find((item) => item.providerID === model.providerID && item.id === model.modelID)
const variant = preferred && info?.variants?.some((item) => item.id === preferred) ? preferred : undefined
setSessionDraft(sessionID, { ...model, variant })
return true
}
const current = agent.current()
@@ -278,33 +352,43 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return true
}
onCleanup(
event.on("session.model.selected", (evt) => {
const expected = pendingSelectionCommits.get(evt.data.sessionID)
if (!expected) return
const committed = selectionKey({
providerID: evt.data.model.providerID,
modelID: evt.data.model.id,
variant: evt.data.model.variant,
})
if (committed !== expected) return
pendingSelectionCommits.delete(evt.data.sessionID)
const draft = selectionState.draftBySession[evt.data.sessionID]
if (draft && selectionKey(draft) === committed)
setSelectionState("draftBySession", evt.data.sessionID, undefined)
}),
)
function reconcileSessionSelection(sessionID: string) {
const expected = pendingSelectionCommits.get(sessionID)
const durable = durableSelection(sessionID)
if (!expected || !durable || data.session.get(sessionID)?.agent !== expected.agentID) return
if (selectionKey(durable) !== expected.selection) return
pendingSelectionCommits.delete(sessionID)
// Inactive agents keep their remembered choices after another agent commits.
if (
route.data.type !== "session" ||
route.data.sessionID !== sessionID ||
agent.current()?.id !== expected.agentID
)
return
const draft = selectionState.selectionBySessionAgent[sessionID]?.[expected.agentID]
if (draft && selectionKey(draft) === expected.selection)
setSessionSelection(sessionID, expected.agentID, undefined)
}
onCleanup(event.on("session.model.selected", (evt) => reconcileSessionSelection(evt.data.sessionID)))
onCleanup(event.on("session.agent.selected", (evt) => reconcileSessionSelection(evt.data.sessionID)))
onCleanup(
event.on("session.deleted", (evt) => {
pendingSelectionCommits.delete(evt.data.sessionID)
setSelectionState("draftBySession", evt.data.sessionID, undefined)
setSelectionState("selectionBySessionAgent", evt.data.sessionID, undefined)
}),
)
return {
current: currentModel,
selection: currentSelection,
remember() {
const current = agent.current()
const selection = currentSelection()
if (route.data.type !== "session" || !current || !selection) return
setSessionSelection(route.data.sessionID, current.id, { ...selection })
},
available(model = currentModel()) {
return model ? isModelValid(model) : false
},
@@ -315,9 +399,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
id: string
variant?: string
},
agentID: string,
) {
const committed = selectionKey({ providerID: value.providerID, modelID: value.id, variant: value.variant })
const committed = {
agentID,
selection: selectionKey({ providerID: value.providerID, modelID: value.id, variant: value.variant }),
}
pendingSelectionCommits.set(sessionID, committed)
// An unchanged model emits no event; the agent may be the only durable change.
reconcileSessionSelection(sessionID)
return () => {
if (pendingSelectionCommits.get(sessionID) === committed) pendingSelectionCommits.delete(sessionID)
}
@@ -354,7 +444,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
cycle(direction: 1 | -1) {
const current = currentSelection()
if (!current) return
const recent = recentModels(current, preferences.recent).filter(isModelValid)
const recent = preferences.recent.filter(isModelValid)
const index = recent.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID)
let next = index === -1 ? (direction === 1 ? 0 : recent.length - 1) : index + direction
if (next < 0) next = recent.length - 1
@@ -422,9 +512,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return currentSelection()?.variant
},
current() {
const v = this.selected()
if (v && this.list().includes(v)) return v
return undefined
return this.selected()
},
list() {
const m = currentSelection()
@@ -438,7 +526,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (route.data.type === "session") {
setSessionDraft(route.data.sessionID, { ...m, variant: normalizeModelVariant(value) })
}
setPreferences("variant", modelPreferenceKey(m), normalizeModelVariant(value))
setPreferences("variant", modelPreferenceKey(m), value ?? "default")
savePreferences()
},
cycle() {
+5 -10
View File
@@ -29,8 +29,8 @@ function variants(value: unknown) {
return Object.fromEntries(
Object.entries(value).flatMap(([key, item]) => {
if (key.length === 0 || typeof item !== "string" || item.length === 0) return []
const variant = normalizeModelVariant(item)
return variant === undefined ? [] : ([[key, variant]] as const)
// Preserve explicit "default" so it can override an agent's configured variant.
return [[key, item]] as const
}),
)
}
@@ -108,17 +108,12 @@ export function createModelPreferenceRepository(filePath: string) {
return update(() => value)
},
async resolveVariant(model: ModelPreferenceModel) {
return (await load()).variant[modelPreferenceKey(model)]
return normalizeModelVariant((await load()).variant[modelPreferenceKey(model)])
},
saveVariant(model: ModelPreferenceModel, value: string | undefined) {
const key = modelPreferenceKey(model)
const next = normalizeModelVariant(value)
return update((current) => {
const variant = { ...current.variant }
if (next === undefined) delete variant[key]
if (next !== undefined) variant[key] = next
return { variant }
})
const next = normalizeModelVariant(value) ?? "default"
return update((current) => ({ variant: { ...current.variant, [key]: next } }))
},
}
}
+2 -54
View File
@@ -5,10 +5,9 @@ import { Effect, FileSystem } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/util/global"
import path from "node:path"
import { createEventStream, createFetch, directory, json, type FetchHandler } from "./fixture/tui-client"
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
import type { TuiInput } from "../src/app"
import type { Config } from "../src/config"
import { createAppFixture } from "./fixture/app"
import type { PluginInfo } from "@opencode-ai/client"
test.each([100, 44])("Ctrl-O is immediate, dismissible, and prunes cached deletions at width %s", async (width) => {
@@ -1530,54 +1529,3 @@ test("server plugin failures share one notice and use source names before an ID
expect(setup.captureCharFrame()).toContain("/fixture/broken.ts")
expect(setup.captureCharFrame()).toContain("Open plugins")
})
async function createAppFixture(
input: {
width?: number
height?: number
state?: string
config?: Config.Info
args?: TuiInput["args"]
fetch?: FetchHandler
} = {},
) {
const { run } = await import("../src/app")
const setup = await createTestRenderer({
width: input.width ?? 100,
height: input.height ?? 30,
useThread: false,
kittyKeyboard: true,
})
setup.renderer.start()
const ready = Promise.withResolvers<void>()
const events = createEventStream()
const calls = createFetch(input.fetch, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => input.config ?? { animations: false }, update: async () => ({}) },
packages: { prepare: async () => ({ directory: "" }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: input.args ?? {},
log: () => {},
}).pipe(
Effect.provide(input.state ? Global.layerWith({ state: input.state }) : AppNodeBuilder.build(Global.node)),
Effect.provide(FileSystem.layerNoop({})),
),
)
return {
...setup,
events,
ready: ready.promise,
async [Symbol.asyncDispose]() {
try {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await task
} finally {
await server.stop()
}
},
}
}
@@ -0,0 +1,108 @@
import { expect, test } from "bun:test"
import { InputRenderable, TextareaRenderable } from "@opentui/core"
import { directory, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
import { createAppFixture } from "./fixture/app"
test("custom commands commit the captured agent, model and variant before execution", async () => {
await using state = await tmpdir()
const agent = Promise.withResolvers<Response>()
const model = Promise.withResolvers<Response>()
const mutations: { type: string; body: unknown }[] = []
const location = { directory, project: { id: "project", directory, canonical: directory } }
const session = {
id: `ses_${crypto.randomUUID()}`,
projectID: "project",
title: "Command selection fixture",
agent: "build",
model: { providerID: "demo", id: "first" },
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
await using setup = await createAppFixture({
state: state.path,
config: {
animations: false,
keybinds: { "agent.cycle": "f6", "variant.cycle": "f7", "model.list": "f8" },
},
args: { sessionID: session.id },
fetch: async (url, request) => {
if (url.pathname === "/api/location") return json(location)
if (url.pathname === "/api/agent")
return json({
location,
data: ["build", "plan"].map((id) => ({ id, mode: "primary", hidden: false, permissions: [] })),
})
if (url.pathname === "/api/provider") return json({ location, data: [{ id: "demo", name: "Demo" }] })
if (url.pathname === "/api/model")
return json({
location,
data: ["first", "second"].map((id) => ({
id,
providerID: "demo",
name: `${id} model`,
variants: [{ id: "low" }, { id: "high" }],
cost: [],
time: { released: 0 },
})),
})
if (url.pathname === "/api/command")
return json({ location, data: [{ name: "review", description: "Review the input" }] })
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
if (/^\/api\/session\/[^/]+\/(message|inbox|permission)$/.test(url.pathname))
return json({ data: [], cursor: {} })
const type = url.pathname.match(/^\/api\/session\/[^/]+\/(agent|model|command)$/)?.[1]
if (!type) return
mutations.push({ type, body: await request.json() })
return type === "agent" ? agent.promise : type === "model" ? model.promise : new Response(null, { status: 204 })
},
})
try {
await setup.ready
await setup.waitForFrame((frame) => frame.includes("Build · first model"))
setup.mockInput.pressKey("F6")
await setup.waitForFrame((frame) => frame.includes("Plan ·"))
setup.mockInput.pressKey("F8")
await setup.waitForFrame(
(frame) => frame.includes("Select model") && setup.renderer.currentFocusedRenderable instanceof InputRenderable,
)
await setup.mockInput.typeText("second")
await setup.renderOnce()
setup.mockInput.pressEnter()
await setup.waitForFrame((frame) => frame.includes("Select variant") && frame.includes("low"))
await setup.mockInput.typeText("low")
await setup.renderOnce()
setup.mockInput.pressEnter()
await setup.waitForFrame(
(frame) =>
frame.includes("Plan · second model Demo · low") &&
setup.renderer.currentFocusedRenderable instanceof TextareaRenderable,
)
await setup.mockInput.typeText("/review selected input")
setup.mockInput.pressEscape()
setup.mockInput.pressEnter()
await setup.waitFor(() => mutations.length > 0)
expect(mutations).toEqual([{ type: "agent", body: { agent: "plan" } }])
// A later local edit must not change the in-flight command's selection.
setup.mockInput.pressKey("F7")
await setup.waitForFrame((frame) => frame.includes("high"))
agent.resolve(new Response(null, { status: 204 }))
await setup.waitFor(() => mutations.length === 2)
expect(mutations[1]).toEqual({
type: "model",
body: { model: { providerID: "demo", id: "second", variant: "low" } },
})
model.resolve(new Response(null, { status: 204 }))
await setup.waitFor(() => mutations.length === 3)
expect(mutations[2]).toEqual({
type: "command",
body: { command: "review", text: "selected input", files: [], agents: [], delivery: "steer" },
})
} finally {
agent.resolve(new Response(null, { status: 204 }))
model.resolve(new Response(null, { status: 204 }))
}
})
@@ -0,0 +1,18 @@
import { expect, test } from "bun:test"
import { DialogVariant } from "../../src/component/dialog-variant"
import { agent, model, renderLocal } from "../fixture/local"
test("variant picker can explicitly reset an agent variant", async () => {
await using setup = await renderLocal({
models: [model("first", ["low", "high"])],
agents: [agent("build", { providerID: "provider", id: "first", variant: "high" })],
})
expect(setup.local.model.variant.current()).toBe("high")
setup.dialog.replace(() => <DialogVariant />)
await setup.waitForFrame((frame) => frame.includes("Select variant") && frame.includes("Default"))
await setup.mockInput.typeText("Default")
await setup.renderOnce()
setup.mockInput.pressEnter()
await setup.waitFor(() => setup.dialog.stack.length === 0)
expect(setup.local.model.variant.current()).toBeUndefined()
})
@@ -0,0 +1,189 @@
import { expect, test } from "bun:test"
import { agent, model, renderLocal, session } from "../fixture/local"
import { json } from "../fixture/tui-client"
test("cycles all recent models in a stable order in both directions", async () => {
await using setup = await renderLocal({
models: [model("first"), model("second"), model("third")],
preferences: { recent: ["first", "second", "third"].map((modelID) => ({ providerID: "provider", modelID })) },
})
expect(setup.local.model.current()?.modelID).toBe("first")
for (const id of ["second", "third", "first"]) {
setup.local.model.cycle(1)
expect(setup.local.model.current()?.modelID).toBe(id)
}
for (const id of ["third", "second", "first"]) {
setup.local.model.cycle(-1)
expect(setup.local.model.current()?.modelID).toBe(id)
}
})
test("uses the last configured model and variant ahead of recents", async () => {
await using setup = await renderLocal({
models: [model("first"), model("second", ["low", "high"]), model("third")],
preferences: { recent: [{ providerID: "provider", modelID: "third" }] },
fetch: (url) => {
if (url.pathname === "/api/config")
return json([
{ type: "document", info: { model: "provider/first" } },
{ type: "document", info: { model: { providerID: "provider", model: "second", variant: "high" } } },
{ type: "document", info: {} },
])
},
})
expect(setup.local.model.selection()).toEqual({ providerID: "provider", modelID: "second", variant: "high" })
})
test("switching agents restores their model and variant within the session", async () => {
await using setup = await renderLocal({
models: [model("first", ["low", "high"]), model("second", ["low", "high"]), model("third", ["low", "high"])],
agents: [
agent("build", { providerID: "provider", id: "first", variant: "high" }),
agent("plan", { providerID: "provider", id: "second", variant: "low" }),
],
sessions: [session("ses_first", { providerID: "provider", id: "first", variant: "low" })],
})
await setup.data.session.sync("ses_first")
setup.route.navigate({ type: "session", sessionID: "ses_first" })
expect(setup.local.model.selection()).toEqual({ providerID: "provider", modelID: "first", variant: "low" })
setup.local.agent.move(1)
expect(setup.local.agent.current()?.id).toBe("plan")
expect(setup.local.model.selection()).toEqual({ providerID: "provider", modelID: "second", variant: "low" })
setup.local.model.set({ providerID: "provider", modelID: "third" })
setup.local.model.variant.set("high")
setup.local.agent.move(-1)
expect(setup.local.model.selection()).toEqual({ providerID: "provider", modelID: "first", variant: "low" })
setup.local.agent.set("plan")
expect(setup.local.model.selection()).toEqual({ providerID: "provider", modelID: "third", variant: "high" })
})
test("agent and model drafts are isolated across sessions and survive navigation", async () => {
await using setup = await renderLocal({
models: [model("first", ["low", "high"]), model("second", ["low", "high"])],
agents: [agent("build"), agent("plan", { providerID: "provider", id: "second" })],
sessions: [
session("ses_first", { providerID: "provider", id: "first", variant: "low" }),
session("ses_second", { providerID: "provider", id: "second", variant: "high" }, "plan"),
],
})
await Promise.all([setup.data.session.sync("ses_first"), setup.data.session.sync("ses_second")])
setup.route.navigate({ type: "session", sessionID: "ses_first" })
setup.local.agent.set("plan")
setup.local.model.variant.set("low")
setup.route.navigate({ type: "session", sessionID: "ses_second" })
expect(setup.local.agent.current()?.id).toBe("plan")
expect(setup.local.model.variant.current()).toBe("high")
setup.route.navigate({ type: "session", sessionID: "ses_first" })
expect(setup.local.agent.current()?.id).toBe("plan")
expect(setup.local.model.variant.current()).toBe("low")
setup.local.agent.set("build")
expect(setup.local.model.selection()).toEqual({ providerID: "provider", modelID: "first", variant: "low" })
})
test("falls back from an unavailable session model without changing durable state", async () => {
const selected = { providerID: "provider", id: "missing", variant: "high" }
await using setup = await renderLocal({
models: [model("first", ["low", "high"]), model("second")],
agents: [agent("build", { providerID: "provider", id: "first", variant: "low" })],
sessions: [session("ses_first", selected)],
})
await setup.data.session.sync("ses_first")
setup.route.navigate({ type: "session", sessionID: "ses_first" })
expect(setup.local.model.selection()).toEqual({ providerID: "provider", modelID: "first", variant: "low" })
expect(setup.local.model.available()).toBe(true)
expect(setup.data.session.get("ses_first")?.model).toEqual(selected)
})
test("a manual agent switch supersedes the CLI agent after its commit", async () => {
await using setup = await renderLocal({
args: { agent: "build" },
agents: [agent("build"), agent("plan")],
sessions: [session("ses_first", { providerID: "provider", id: "first" })],
fetch: selectionMessage,
})
await setup.data.session.sync("ses_first")
setup.route.navigate({ type: "session", sessionID: "ses_first" })
setup.local.agent.set("plan")
await publishSelection(setup, "plan", "first")
expect(setup.local.agent.current()?.id).toBe("plan")
setup.route.navigate({ type: "home" })
setup.route.navigate({ type: "session", sessionID: "ses_first" })
expect(setup.local.agent.current()?.id).toBe("plan")
})
test("a late inactive-agent acknowledgment preserves its choice after the active agent commits", async () => {
await using setup = await renderLocal({
models: [model("first"), model("second"), model("third")],
agents: [agent("build"), agent("plan", { providerID: "provider", id: "second" })],
sessions: [session("ses_first", { providerID: "provider", id: "first" })],
fetch: selectionMessage,
})
await setup.data.session.sync("ses_first")
setup.route.navigate({ type: "session", sessionID: "ses_first" })
setup.local.agent.set("plan")
setup.local.model.set({ providerID: "provider", modelID: "third" })
setup.local.model.trackSessionCommit("ses_first", { providerID: "provider", id: "third" }, "plan")
setup.local.agent.set("build")
await publishSelection(setup, "plan", "third")
setup.local.model.trackSessionCommit("ses_first", { providerID: "provider", id: "first" }, "build")
await publishSelection(setup, "build", "first")
setup.local.agent.set("plan")
expect(setup.local.model.current()?.modelID).toBe("third")
})
test("same-model agent switches clear drafts without a model acknowledgment", async () => {
await using setup = await renderLocal({
models: [model("first"), model("second")],
agents: [agent("build"), agent("plan")],
sessions: [session("ses_first", { providerID: "provider", id: "first" })],
fetch: selectionMessage,
})
await setup.data.session.sync("ses_first")
setup.route.navigate({ type: "session", sessionID: "ses_first" })
setup.local.agent.set("plan")
setup.local.model.trackSessionCommit("ses_first", { providerID: "provider", id: "first" }, "plan")
await publishSelection(setup, "plan", "first", false)
await publishSelection(setup, "plan", "second")
expect(setup.local.model.current()?.modelID).toBe("second")
})
async function publishSelection(
setup: Awaited<ReturnType<typeof renderLocal>>,
agent: string,
modelID: string,
changed = true,
) {
setup.events.emit({
id: `evt_${crypto.randomUUID()}`,
type: "session.agent.selected",
created: 1,
durable: { aggregateID: "ses_first", seq: 1, version: 1 },
data: { sessionID: "ses_first", agent },
})
if (changed)
setup.events.emit({
id: `evt_${crypto.randomUUID()}_${modelID}`,
type: "session.model.selected",
created: 2,
durable: { aggregateID: "ses_first", seq: 2, version: 1 },
data: { sessionID: "ses_first", model: { providerID: "provider", id: modelID } },
})
await setup.waitFor(async () => {
await Bun.sleep(10)
const session = setup.data.session.get("ses_first")
return session?.agent === agent && session.model?.id === modelID
})
}
function selectionMessage(url: URL) {
if (!url.pathname.includes("/message/")) return
const id = url.pathname.split("/").at(-1)!
return json({
data: {
id,
type: "model-switched",
model: { providerID: "provider", id: id.split("_").at(-1) },
time: { created: 2 },
},
})
}
+1 -8
View File
@@ -1,12 +1,5 @@
import { expect, test } from "bun:test"
import { parseModel, recentModels } from "../../src/context/local"
test("parses model IDs containing slashes", () => {
expect(parseModel("provider/family/model")).toEqual({
providerID: "provider",
modelID: "family/model",
})
})
import { recentModels } from "../../src/context/local"
test("moves a model to the front, deduplicates, and limits recents", () => {
const recent = Array.from({ length: 12 }, (_, index) => ({
+58
View File
@@ -0,0 +1,58 @@
import { createTestRenderer } from "@opentui/core/testing"
import { Effect, FileSystem } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/util/global"
import type { TuiInput } from "../../src/app"
import type { Config } from "../../src/config"
import { createEventStream, createFetch, type FetchHandler } from "./tui-client"
export async function createAppFixture(
input: {
width?: number
height?: number
state?: string
config?: Config.Info
args?: TuiInput["args"]
fetch?: FetchHandler
} = {},
) {
const { run } = await import("../../src/app")
const setup = await createTestRenderer({
width: input.width ?? 100,
height: input.height ?? 30,
useThread: false,
kittyKeyboard: true,
})
setup.renderer.start()
const ready = Promise.withResolvers<void>()
const events = createEventStream()
const calls = createFetch(input.fetch, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => input.config ?? { animations: false }, update: async () => ({}) },
packages: { prepare: async () => ({ directory: "" }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: input.args ?? {},
log: () => {},
}).pipe(
Effect.provide(input.state ? Global.layerWith({ state: input.state }) : AppNodeBuilder.build(Global.node)),
Effect.provide(FileSystem.layerNoop({})),
),
)
return {
...setup,
events,
ready: ready.promise,
async [Symbol.asyncDispose]() {
try {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await task
} finally {
await server.stop()
}
},
}
}
+149
View File
@@ -0,0 +1,149 @@
import { testRender } from "@opentui/solid"
import type { AgentInfo, ModelInfo, SessionInfo } from "@opencode-ai/client"
import path from "node:path"
import { ConfigProvider } from "../../src/config"
import { ArgsProvider, type Args } from "../../src/context/args"
import { ClientProvider } from "../../src/context/client"
import { DataProvider, useData } from "../../src/context/data"
import { LocalProvider, useLocal } from "../../src/context/local"
import { Keymap } from "../../src/context/keymap"
import { LocationProvider, useLocation } from "../../src/context/location"
import { PermissionProvider } from "../../src/context/permission"
import { RouteProvider, useRoute } from "../../src/context/route"
import { ThemeProvider } from "../../src/context/theme"
import { ToastProvider } from "../../src/ui/toast"
import { DialogProvider, useDialog } from "../../src/ui/dialog"
import type { ModelPreference } from "../../src/model-preference"
import { tmpdir } from "./fixture"
import { createApi, createEventStream, createFetch, directory, json, type FetchHandler } from "./tui-client"
import { TestTuiContexts } from "./tui-environment"
import { createTuiResolvedConfig } from "./tui-runtime"
export async function renderLocal(
input: {
models?: ModelInfo[]
agents?: AgentInfo[]
sessions?: SessionInfo[]
preferences?: Partial<ModelPreference>
args?: Args
fetch?: FetchHandler
} = {},
) {
const temporary = await tmpdir()
await Bun.write(path.join(temporary.path, "model.json"), JSON.stringify(input.preferences ?? {}))
const events = createEventStream()
const calls = createFetch(async (url, request) => {
const response = await input.fetch?.(url, request)
if (response) return response
const location = { directory: url.searchParams.get("location[directory]") ?? directory }
if (url.pathname === "/api/agent") return json({ location, data: input.agents ?? [agent("build")] })
if (url.pathname === "/api/model") return json({ location, data: input.models ?? [model("first")] })
const session = input.sessions?.find((session) => url.pathname === `/api/session/${session.id}`)
if (session) return json({ data: session })
}, events)
let local!: ReturnType<typeof useLocal>
let route!: ReturnType<typeof useRoute>
let data!: ReturnType<typeof useData>
let location!: ReturnType<typeof useLocation>
let dialog!: ReturnType<typeof useDialog>
function Probe() {
local = useLocal()
route = useRoute()
data = useData()
location = useLocation()
dialog = useDialog()
return <box />
}
const setup = await testRender(
() => (
<TestTuiContexts paths={{ state: temporary.path }}>
<ArgsProvider {...input.args}>
<ConfigProvider config={createTuiResolvedConfig()}>
<Keymap.Provider>
<ThemeProvider mode="dark" source={{ discover: async () => ({}) }}>
<ToastProvider>
<RouteProvider initialRoute={{ type: "home" }}>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider directory={directory}>
<LocationProvider>
<PermissionProvider>
<LocalProvider>
<DialogProvider>
<Probe />
</DialogProvider>
</LocalProvider>
</PermissionProvider>
</LocationProvider>
</DataProvider>
</ClientProvider>
</RouteProvider>
</ToastProvider>
</ThemeProvider>
</Keymap.Provider>
</ConfigProvider>
</ArgsProvider>
</TestTuiContexts>
),
{ width: 100, height: 30, kittyKeyboard: true },
)
await setup.waitFor(() => local !== undefined && local.model.ready)
await data.location.sync()
return {
...setup,
local,
route,
data,
location,
dialog,
events,
state: temporary.path,
async [Symbol.asyncDispose]() {
setup.renderer.destroy()
await temporary[Symbol.asyncDispose]()
},
}
}
export function model(id: string, variants: string[] = []): ModelInfo {
return {
id,
modelID: id,
providerID: "provider",
name: id,
status: "active",
enabled: true,
capabilities: { input: ["text"], output: ["text"], tools: true },
cost: [],
limit: { context: 10000, output: 1000 },
time: { released: 0 },
variants: variants.map((id) => ({ id })),
}
}
export function agent(id: string, selected?: AgentInfo["model"]): AgentInfo {
return {
id,
name: id,
model: selected,
mode: "primary",
hidden: false,
permissions: [],
request: { settings: {}, headers: {}, body: {} },
}
}
export function session(id: string, selected?: SessionInfo["model"], agent = "build"): SessionInfo {
return {
id,
agent,
model: selected,
title: id,
location: { directory },
projectID: "project",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
}
+1
View File
@@ -134,6 +134,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
data: { resources: [], templates: [] },
})
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
if (url.pathname === "/api/config") return json([])
if (url.pathname === "/api/session/active") return json({ data: {} })
if (request.method === "POST" && /^\/api\/session\/[^/]+\/model$/.test(url.pathname))
return new Response(null, { status: 204 })
+5 -2
View File
@@ -15,7 +15,7 @@ test("repairs known model preferences and preserves unrelated fields", () => {
unrelated: { keep: true },
recent: [{ providerID: "openai", modelID: "gpt-5" }],
favorite: [],
variant: { "openai/gpt-5": "high" },
variant: { "openai/gpt-5": "high", default: "default" },
})
})
@@ -41,5 +41,8 @@ test("atomically serializes patches and variant updates", async () => {
await repository.saveVariant(openai, "default")
expect(await repository.resolveVariant(openai)).toBeUndefined()
expect((await Bun.file(file).json()).variant).toEqual({ "anthropic/claude/sonnet": "low" })
expect((await Bun.file(file).json()).variant).toEqual({
"openai/org/gpt-5": "default",
"anthropic/claude/sonnet": "low",
})
})
@@ -231,6 +231,7 @@
[data-component="button-v2"][data-variant="ghost-muted"] {
background-color: transparent;
color: var(--v2-text-text-muted);
font-weight: 440;
}
[data-component="button-v2"][data-variant="ghost-muted"] [data-slot="icon-svg"] {
+5 -1
View File
@@ -104,6 +104,7 @@ const source = {
"ui.messagePart.review.title": "Review your answers",
"ui.messagePart.questions.dismissed": "Questions dismissed",
"ui.messagePart.compaction": "Session compacted",
"ui.messagePart.context.details": "Details",
"ui.messagePart.context.read.one": "{{count}} read",
"ui.messagePart.context.read.other": "{{count}} reads",
"ui.messagePart.context.search.one": "{{count}} search",
@@ -133,7 +134,10 @@ const source = {
"ui.promptInput.noMatchingItems": "No matching items",
"ui.promptInput.commands": "Commands",
"ui.promptInput.dropFiles": "Drop files to attach",
"ui.promptInput.dropFiles": "Drop files to add",
"ui.promptInput.dropFiles.image": "Drop images or files to add",
"ui.promptInput.dropFiles.pdf": "Drop PDFs or files to add",
"ui.promptInput.dropFiles.imagePdf": "Drop images, PDFs, or files to add",
"ui.promptInput.removeAttachment": "Remove attachment",
"ui.promptInput.label": "Prompt",
"ui.promptInput.placeholder.shell": "Enter shell command…",
+8
View File
@@ -100,6 +100,14 @@ const icons = {
viewBox: "0 0 16 16",
body: `<path d="M2 10.668V14.0013H10.6667M13.9974 10.6667V2H2.66406M13.9974 10.668V14.0013H10.6641M2 10V2H5.33333" stroke="currentColor" stroke-miterlimit="10" stroke-linecap="square"/><path d="M10.6693 10.6654V5.33203H5.33594V10.6654H10.6693Z" fill="currentColor"/>`,
},
"outline-worktree": {
viewBox: "0 0 16 16",
body: `<path d="M12 3.11133L14.3885 5.49977L12 7.88822M14.5 5.49972H10.058L5.13456 11.8139M14.5 11.6606L8.5 11.6606M5.50012 11.6597H1.5" stroke="currentColor"/>`,
},
"outline-trash": {
viewBox: "0 0 16 16",
body: `<path d="M2.44434 4.22224H13.5554M5.99994 4.22224V2.44446C5.99994 1.95557 5.99992 1.55557 5.99992 1.55557H9.99989C9.99989 1.55557 9.99994 1.95557 9.99994 2.44446V4.22224M6.55545 7.77779L6.7485 11.7778M9.44437 7.77779L9.2513 11.7778M12.1756 6.88891L11.8666 12.7556C11.8168 13.7068 11.7748 14.4445 11.7748 14.4445H4.22511C4.22511 14.4445 4.18392 13.7067 4.13414 12.7556L3.82509 6.88891" stroke="currentColor"/>`,
},
close: {
viewBox: "0 0 20 20",
body: `<path d="M14.4446 5.55566L5.55566 14.4446M5.55566 5.55566L14.4446 14.4446" stroke="currentColor" stroke-linejoin="round"/>`,

Some files were not shown because too many files have changed in this diff Show More