mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-18 14:56:27 +00:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21197e4e6f | ||
|
|
db80806651 | ||
|
|
47f66de8dd | ||
|
|
c076066c33 | ||
|
|
5c09d051e5 | ||
|
|
0a6111291e | ||
|
|
609044ef0a | ||
|
|
4f32bfca57 | ||
|
|
90112f52db | ||
|
|
d4303a9ca5 | ||
|
|
735556eab8 | ||
|
|
10cac9ab5d | ||
|
|
2d0ce64111 | ||
|
|
3355c93efd | ||
|
|
469e1c035e | ||
|
|
b2e3569add | ||
|
|
0ac458b3b3 | ||
|
|
b278ef6b82 | ||
|
|
3e3a4ae46b | ||
|
|
fe0d9579a7 | ||
|
|
a2594ddefb | ||
|
|
3555cef1c8 | ||
|
|
0fa881efd0 | ||
|
|
37ff501cc6 | ||
|
|
fa126d68e8 | ||
|
|
a0571a8b14 |
@@ -184,6 +184,7 @@ const table = sqliteTable("session", {
|
||||
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. A write-ahead execution claim marks a process-local busy period for restart recovery: terminal completion, failure, or user interruption releases it, while shutdown interruption and process death preserve it. Startup recovery resumes claimed top-level Sessions with durable per-execution attempt accounting. The claim is a recovery marker, not clustered ownership, fencing, or an exactly-once guarantee.
|
||||
- Keep native compaction mechanisms out of `SessionCompaction`. Plugins register `native` strategies through the `SessionCompaction` editor that turn a prepared request into a replacement window (the built-in `NativeCompactionPlugin` handles `@opencode/ai` compaction operations); later registrations win. Core owns the provider-mode decision, route provenance, the retry policy, overflow recovery, interruption, usage accounting, and checkpoint persistence.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default. At safe step boundaries, steered compaction takes priority up to the first steered move control; other steers retain enqueue order. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
|
||||
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
|
||||
- Keep event replay ownership separate from clustered Session execution ownership.
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -54,7 +54,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -113,7 +113,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"bin": {
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
},
|
||||
@@ -177,7 +177,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -203,7 +203,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -217,7 +217,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -253,7 +253,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -280,7 +280,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -297,7 +297,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -321,7 +321,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -341,7 +341,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -409,7 +409,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "4.1.2",
|
||||
@@ -456,12 +456,11 @@
|
||||
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-win32-x64": "1.2.0-beta.12",
|
||||
"msgpackr-extract": "3.0.4",
|
||||
},
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -498,7 +497,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -514,7 +513,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
@@ -533,7 +532,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -546,7 +545,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -560,7 +559,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -575,7 +574,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -614,7 +613,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -644,7 +643,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -659,7 +658,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -683,7 +682,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -704,7 +703,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -726,7 +725,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -761,7 +760,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -781,7 +780,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -815,7 +814,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -834,7 +833,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -880,7 +879,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -894,7 +893,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -929,7 +928,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -964,7 +963,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -997,7 +996,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1038,7 +1037,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-U9IuP/ev6w4urvogOwQyl3rdumY6W4YaY18NkFaOVHU=",
|
||||
"aarch64-linux": "sha256-Wc8OT2DRZpVo56KaoGE0Hsj1NDknakbWXO9w2qy6j+0=",
|
||||
"aarch64-darwin": "sha256-wAea8+jajnMDxZ6XJL+Hsrf0621hwtBtWyD1+dS45dE=",
|
||||
"x86_64-darwin": "sha256-g8PCNBSV6rO+VQjKU9AtYqj+r18o+fhLDXEQq+X2EZ4="
|
||||
"x86_64-linux": "sha256-TlCPtuIvTNF01fLI0yHCLzVkx53yF9xxvAObDMGeo8M=",
|
||||
"aarch64-linux": "sha256-aEjyPIXHeA5w1iZXbPKJAOIJQBfXg+LAx/y7C5uFz4g=",
|
||||
"aarch64-darwin": "sha256-lthMTio2qNy+H2tL9cAPmqC41O+wdSADI61cnW4dIb0=",
|
||||
"x86_64-darwin": "sha256-ONFbRQnwzOYHo0Pyh327QfJG2uzqzNEn/SS2vbYYMdM="
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -485,7 +485,7 @@ export function stepStarted(message: SessionMessageAssistant) {
|
||||
assistantMessageID: message.id,
|
||||
agent: message.agent,
|
||||
model: message.model,
|
||||
started: 1700000002000,
|
||||
started: message.time.created,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +108,15 @@ test("non-Git folders show their status without offering worktree actions", asyn
|
||||
).toBeEnabled()
|
||||
})
|
||||
|
||||
test("submits locally after changing a new worktree draft to Local", async ({ page }) => {
|
||||
const mock = await openDraft(page, "create", { currentDirectory: workspace })
|
||||
await page.getByRole("button", { name: "New worktree", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "Local repository", exact: true }).click()
|
||||
await page.locator('[data-component="composer-editor"]').fill("Run locally")
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect.poll(() => mock.calls.find((call) => call.type === "session")?.directory).toBe(directory)
|
||||
})
|
||||
|
||||
test("new worktree MCP choices persist per draft and apply before the first prompt", async ({ page }, testInfo) => {
|
||||
const mock = await openDraft(page, "create")
|
||||
await page.locator('[data-component="composer-editor"]').fill("Use my selected MCPs")
|
||||
@@ -296,7 +305,12 @@ test("new worktree sign-in completes before the draft can send", async ({ page,
|
||||
expect(attempts).toHaveLength(1)
|
||||
})
|
||||
|
||||
async function openDraft(page: Page, worktree = "main", options: { git?: boolean; direction?: "ltr" | "rtl" } = {}) {
|
||||
async function openDraft(
|
||||
page: Page,
|
||||
worktree = "main",
|
||||
options: { git?: boolean; direction?: "ltr" | "rtl"; currentDirectory?: string } = {},
|
||||
) {
|
||||
const currentDirectory = options.currentDirectory ?? directory
|
||||
const project = {
|
||||
id: "proj_new_summary",
|
||||
worktree: directory,
|
||||
@@ -315,7 +329,7 @@ async function openDraft(page: Page, worktree = "main", options: { git?: boolean
|
||||
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
|
||||
const state: { fail: boolean; hold?: Promise<void>; holdDirectory?: string } = { fail: false }
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
directory: currentDirectory,
|
||||
project,
|
||||
sessions,
|
||||
provider: {
|
||||
@@ -442,7 +456,7 @@ async function openDraft(page: Page, worktree = "main", options: { git?: boolean
|
||||
},
|
||||
)
|
||||
await page.addInitScript(
|
||||
({ directory, server, draftID, secondDraftID, worktree }) => {
|
||||
({ directory, currentDirectory, server, draftID, secondDraftID, worktree }) => {
|
||||
if (!localStorage.getItem("opencode.global.dat:server"))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
@@ -455,12 +469,12 @@ async function openDraft(page: Page, worktree = "main", options: { git?: boolean
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
{ type: "draft", draftID, server, directory, worktree },
|
||||
{ type: "draft", draftID: secondDraftID, server, directory, worktree },
|
||||
{ type: "draft", draftID, server, directory: currentDirectory, worktree },
|
||||
{ type: "draft", draftID: secondDraftID, server, directory: currentDirectory, worktree },
|
||||
]),
|
||||
)
|
||||
},
|
||||
{ directory, server, draftID, secondDraftID, worktree },
|
||||
{ directory, currentDirectory, server, draftID, secondDraftID, worktree },
|
||||
)
|
||||
if (options.direction) await openWithDirection(page, draftPath, options.direction)
|
||||
if (!options.direction) await page.goto(draftPath)
|
||||
|
||||
@@ -284,7 +284,7 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
await expect(thinking).toHaveCount(0)
|
||||
|
||||
// The next assistant step still belongs to U1: U2 has been admitted, not delivered.
|
||||
mock.emit("session.step.started", { sessionID, assistantMessageID: assistantID, agent: "build", model })
|
||||
mock.emit("session.step.started", { sessionID, assistantMessageID: assistantID, agent: "build", model, started: Date.now() })
|
||||
for (const tool of [
|
||||
{ id: "tool_queue_read", name: "read", input: { path: "src/queue.ts" } },
|
||||
{ id: "tool_queue_grep", name: "grep", input: { pattern: "retry", path: "src" } },
|
||||
@@ -341,7 +341,7 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
)
|
||||
|
||||
const later = { sessionID, assistantMessageID: "msg_queue_follow_up_assistant" }
|
||||
mock.emit("session.step.started", { ...later, agent: "build", model })
|
||||
mock.emit("session.step.started", { ...later, agent: "build", model, started: Date.now() })
|
||||
mock.emit("session.text.started", { ...later, ordinal: 0 })
|
||||
mock.emit("session.text.ended", { ...later, ordinal: 0, text: "A3: Now checking the retry path for U2." })
|
||||
const response = transcript
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -16,6 +16,86 @@
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="upload-row"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
& + & {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
[data-slot="upload-row-label"] {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-base);
|
||||
letter-spacing: -0.04px;
|
||||
}
|
||||
|
||||
[data-slot="upload-row-name"] {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--v2-text-text-base);
|
||||
font-weight: 530;
|
||||
}
|
||||
|
||||
[data-slot="upload-row-percent"] {
|
||||
flex-shrink: 0;
|
||||
color: var(--v2-text-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
[data-slot="upload-row-track"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
[data-component="upload-progress"] {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: var(--v2-background-bg-layer-02);
|
||||
}
|
||||
|
||||
[data-slot="upload-progress-bar"] {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--v2-icon-icon-base);
|
||||
transition: width 160ms ease-out;
|
||||
}
|
||||
|
||||
[data-slot="upload-row-cancel"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--v2-icon-icon-muted);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
color: var(--v2-icon-icon-base);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="composer-attachments"] {
|
||||
timeline-scope: --composer-attachments-scroll;
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createBlobReference } from "@/runtime/persistence/drafts"
|
||||
import { uuid } from "@/runtime/persistence/uuid"
|
||||
import type { ComposerAttachment, ComposerPrompt } from "../types"
|
||||
import type { ComposerPrompt } from "../types"
|
||||
import type { ImageAttachmentPart, PathAttachmentPart } from "../state"
|
||||
import type { AttachmentDestination } from "./destination"
|
||||
import { uploads } from "./uploads"
|
||||
|
||||
type PromptTarget = {
|
||||
current: () => ComposerPrompt
|
||||
@@ -16,9 +20,11 @@ export type ComposerAttachmentConfig = {
|
||||
onFile: (file: File) => Promise<unknown>,
|
||||
) => Promise<void>
|
||||
directory: () => string
|
||||
destination: () => AttachmentDestination
|
||||
isDialogActive: () => boolean
|
||||
duplicate: () => void
|
||||
onError: (error: unknown) => void
|
||||
onUploadError: (error: unknown) => void
|
||||
readClipboardImage?: () => Promise<File | null>
|
||||
getPathForFile?: (file: File) => string
|
||||
onDragCancel?: (callback: () => void) => () => void
|
||||
@@ -43,9 +49,23 @@ export function createComposerAttachments(
|
||||
if (!editor) return
|
||||
return { prompt, cursor: prompt.cursor() ?? cursorPosition(editor) }
|
||||
}
|
||||
// Uploads this composer started; they finish (or fail) even if the composer unmounts.
|
||||
const [pending, setPending] = createStore<{ ids: string[] }>({ ids: [] })
|
||||
|
||||
// Media the model reads natively travels inline with the prompt, so its bytes live in the draft
|
||||
// store. Everything else, including text, reaches the model as a path on the server that its
|
||||
// tools open; those bytes never enter the store, and never get base64-encoded into the request.
|
||||
const add = async (file: File, target = capture(), clipboard = false) => {
|
||||
if (!target) return false
|
||||
const mime = await attachmentMime(file)
|
||||
const destination = input.destination()
|
||||
if (native(mime, destination.input) && file.size <= MAX_INLINE_BYTES) return addInline(file, mime, target, clipboard)
|
||||
const sourcePath = input.getPathForFile?.(file) || undefined
|
||||
if (destination.local && sourcePath) return addPath(target, { filename: file.name, mime, path: sourcePath })
|
||||
void stage(file, mime, target, destination)
|
||||
return true
|
||||
}
|
||||
const addInline = async (file: File, mime: string, target: NonNullable<ReturnType<typeof capture>>, clipboard: boolean) => {
|
||||
const blob = input.store ? await input.store(file) : await createBlobReference(file)
|
||||
const sourcePath = input.getPathForFile?.(file) || undefined
|
||||
// Native clipboard images arrive with a fresh timestamped filename on every paste, so identical
|
||||
@@ -64,17 +84,40 @@ export function createComposerAttachments(
|
||||
input.duplicate()
|
||||
return true
|
||||
}
|
||||
const attachment: ComposerAttachment = {
|
||||
type: "image",
|
||||
id: uuid(),
|
||||
filename: file.name,
|
||||
sourcePath,
|
||||
mime,
|
||||
blob,
|
||||
}
|
||||
const attachment: ImageAttachmentPart = { type: "image", id: uuid(), filename: file.name, sourcePath, mime, blob }
|
||||
target.prompt.set([...target.prompt.current(), attachment], target.cursor)
|
||||
return true
|
||||
}
|
||||
const addPath = (
|
||||
target: NonNullable<ReturnType<typeof capture>>,
|
||||
attachment: Pick<PathAttachmentPart, "filename" | "mime" | "path">,
|
||||
) => {
|
||||
if (target.prompt.current().some((part) => part.type === "path" && part.path === attachment.path)) {
|
||||
input.duplicate()
|
||||
return true
|
||||
}
|
||||
target.prompt.set([...target.prompt.current(), { type: "path", id: uuid(), ...attachment }], target.prompt.cursor())
|
||||
return true
|
||||
}
|
||||
const stage = async (
|
||||
file: File,
|
||||
mime: string,
|
||||
target: NonNullable<ReturnType<typeof capture>>,
|
||||
destination: AttachmentDestination,
|
||||
) => {
|
||||
const id = uuid()
|
||||
setPending("ids", (ids) => [...ids, id])
|
||||
const path = await uploads
|
||||
.track({ id, filename: file.name, mime, size: file.size }, (report, signal) =>
|
||||
destination.upload(file, report, signal),
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
input.onUploadError(error)
|
||||
return undefined
|
||||
})
|
||||
.finally(() => setPending("ids", (ids) => ids.filter((item) => item !== id)))
|
||||
if (path) addPath(target, { filename: file.name, mime, path })
|
||||
}
|
||||
const addAttachments = async (files: File[], target = capture()) => {
|
||||
return files.reduce(async (result, file) => {
|
||||
const previous = await result
|
||||
@@ -153,6 +196,11 @@ export function createComposerAttachments(
|
||||
addAttachments,
|
||||
handlePaste,
|
||||
handleDrop,
|
||||
/** Uploads still in flight for this composer; sending waits for them. */
|
||||
pending: () => uploads.items().filter((item) => pending.ids.includes(item.id)),
|
||||
cancel(id: string) {
|
||||
uploads.items().find((item) => item.id === id)?.cancel()
|
||||
},
|
||||
pick(fallback: () => void) {
|
||||
if (!input.picker) {
|
||||
fallback()
|
||||
@@ -165,6 +213,16 @@ export function createComposerAttachments(
|
||||
|
||||
const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
|
||||
|
||||
// The server rejects inline attachments above this size, so larger media takes the path route.
|
||||
const MAX_INLINE_BYTES = 20 * 1024 * 1024
|
||||
|
||||
// Mirrors the media the server forwards to the model as message content.
|
||||
function native(mime: string, input: AttachmentDestination["input"]) {
|
||||
if (imageMimes.has(mime)) return input.image
|
||||
if (mime === "application/pdf") return input.pdf
|
||||
return false
|
||||
}
|
||||
|
||||
const imageExtensions = new Map([
|
||||
["gif", "image/gif"],
|
||||
["jpeg", "image/jpeg"],
|
||||
@@ -182,8 +240,8 @@ const textMimes = new Set([
|
||||
"application/yaml",
|
||||
])
|
||||
|
||||
// Text-like files normalize to text/plain so the server inlines their content; every other
|
||||
// file keeps a binary type and is delivered to the model by path or as native media.
|
||||
// Text-like files normalize to text/plain so the chip labels them as text; every other file keeps
|
||||
// a binary type. Delivery is decided separately: native media inline, everything else by path.
|
||||
async function attachmentMime(file: File) {
|
||||
const type = file.type.split(";", 1)[0]?.trim().toLowerCase() ?? ""
|
||||
if (imageMimes.has(type) || type === "application/pdf") return type
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import type { Accessor } from "solid-js"
|
||||
import { blobBytes, blobDataUrl } from "@/runtime/persistence/drafts"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import type { ComposerControls } from "../adapter"
|
||||
import type { ImageAttachmentPart } from "../state"
|
||||
|
||||
// Where a prompt is headed: the model that reads it and the server that runs its tools.
|
||||
export type AttachmentDestination = {
|
||||
/** Input modalities the selected model reads natively. */
|
||||
input: { image: boolean; pdf: boolean }
|
||||
/** The server shares the client's filesystem, so an attachment's source path resolves as-is. */
|
||||
local: boolean
|
||||
/** Copies a file into the server's temporary directory and returns its absolute path there. */
|
||||
upload: (file: { name: string; data: Uint8Array }) => Promise<string>
|
||||
}
|
||||
|
||||
export type DeliveredAttachment =
|
||||
| { type: "inline"; attachment: ImageAttachmentPart; dataUrl: string }
|
||||
| { type: "path"; attachment: ImageAttachmentPart; path: string }
|
||||
|
||||
// An attachment travels inline when the model reads its bytes natively. Anything else reaches
|
||||
// the model as a path on the server, which its tools can open, instead of being rejected.
|
||||
export function deliverAttachments(attachments: ImageAttachmentPart[], destination: AttachmentDestination) {
|
||||
return Promise.all(attachments.map((attachment) => deliver(attachment, destination)))
|
||||
}
|
||||
|
||||
async function deliver(
|
||||
attachment: ImageAttachmentPart,
|
||||
destination: AttachmentDestination,
|
||||
): Promise<DeliveredAttachment> {
|
||||
if (native(attachment.mime, destination.input)) {
|
||||
return { type: "inline", attachment, dataUrl: await blobDataUrl(attachment.blob, attachment.mime) }
|
||||
}
|
||||
if (destination.local && attachment.sourcePath) return { type: "path", attachment, path: attachment.sourcePath }
|
||||
const path = await destination.upload({ name: attachment.filename, data: await blobBytes(attachment.blob) })
|
||||
return { type: "path", attachment, path }
|
||||
}
|
||||
|
||||
const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
|
||||
|
||||
// Mirrors the attachment kinds the server forwards to the model as message content.
|
||||
function native(mime: string, input: AttachmentDestination["input"]) {
|
||||
if (mime === "text/plain") return true
|
||||
if (imageMimes.has(mime)) return input.image
|
||||
if (mime === "application/pdf") return input.pdf
|
||||
return false
|
||||
}
|
||||
|
||||
export function useAttachmentDestination(controls: Accessor<ComposerControls>) {
|
||||
const server = useServer()
|
||||
const sdk = useServerSDK()
|
||||
const location = useWorkspaceLocation()
|
||||
return (): AttachmentDestination => ({
|
||||
input: controls().model.selection.current()?.capabilities.input ?? { image: false, pdf: false },
|
||||
local: server.isLocal,
|
||||
upload: async (file) => {
|
||||
const info = await sdk.api.server.info()
|
||||
// One directory per upload keeps the original filename without collisions; the server
|
||||
// normalizes the separators and returns the resolved path.
|
||||
const written = await sdk.api.file.write({
|
||||
location: { directory: location().directory },
|
||||
path: `${info.paths.tmp}/uploads/${crypto.randomUUID()}/${file.name}`,
|
||||
payload: file.data,
|
||||
})
|
||||
return written.data.path
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Accessor } from "solid-js"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { authTokenFromCredentials } from "@/runtime/server/api"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import type { ComposerControls } from "../adapter"
|
||||
|
||||
// Where a prompt is headed: the model that reads it and the server that runs its tools.
|
||||
export type AttachmentDestination = {
|
||||
/** Input modalities the selected model reads natively. */
|
||||
input: { image: boolean; pdf: boolean }
|
||||
/** The server shares the client's filesystem, so an attachment's source path resolves as-is. */
|
||||
local: boolean
|
||||
/** Streams a file into the server's temporary directory and returns its absolute path there. */
|
||||
upload: (file: File, report: (loaded: number) => void, signal: AbortSignal) => Promise<string>
|
||||
}
|
||||
|
||||
export function useAttachmentDestination(controls: Accessor<ComposerControls>) {
|
||||
const server = useServer()
|
||||
const sdk = useServerSDK()
|
||||
const location = useWorkspaceLocation()
|
||||
return (): AttachmentDestination => ({
|
||||
input: controls().model.selection.current()?.capabilities.input ?? { image: false, pdf: false },
|
||||
local: server.isLocal,
|
||||
upload: async (file, report, signal) => {
|
||||
const info = await sdk.api.server.info({ signal })
|
||||
// One directory per upload keeps the original filename without collisions; the server
|
||||
// normalizes the separators and returns the resolved path.
|
||||
const url = new URL("/api/experimental/fs/write", server.conn.http.url)
|
||||
url.searchParams.set("location[directory]", location().directory)
|
||||
url.searchParams.set("path", `${info.paths.tmp}/uploads/${crypto.randomUUID()}/${file.name}`)
|
||||
return write(url, file, server.conn.http.password, report, signal)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// fetch cannot report upload progress and Chromium only streams request bodies over HTTP/2, so
|
||||
// the one request that needs both goes through XMLHttpRequest. The browser streams the File
|
||||
// from disk; nothing is buffered in the renderer.
|
||||
function write(url: URL, file: File, password: string | undefined, report: (loaded: number) => void, signal: AbortSignal) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open("POST", url)
|
||||
xhr.responseType = "json"
|
||||
xhr.setRequestHeader("content-type", "application/octet-stream")
|
||||
if (password) xhr.setRequestHeader("authorization", `Basic ${authTokenFromCredentials({ password })}`)
|
||||
xhr.upload.addEventListener("progress", (event) => report(event.loaded))
|
||||
xhr.addEventListener("load", () => {
|
||||
if (xhr.status !== 200) return reject(new Error(`Upload failed with status ${xhr.status}`))
|
||||
resolve((xhr.response as { data: { path: string } }).data.path)
|
||||
})
|
||||
xhr.addEventListener("error", () => reject(new Error("Upload failed")))
|
||||
xhr.addEventListener("abort", () => reject(new DOMException("Upload aborted", "AbortError")))
|
||||
signal.addEventListener("abort", () => xhr.abort(), { once: true })
|
||||
xhr.send(file)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { createEffect, createRoot, For, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Toast, toaster } from "@opencode/ui/toast"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
|
||||
export type Upload = {
|
||||
id: string
|
||||
filename: string
|
||||
mime: string
|
||||
size: number
|
||||
loaded: number
|
||||
cancel: () => void
|
||||
}
|
||||
|
||||
// Uploads outlive the composer that started them, so one process-wide list feeds every chip
|
||||
// and the single progress toast.
|
||||
const [state, setState] = createStore<{ items: Upload[] }>({ items: [] })
|
||||
|
||||
export const uploads = {
|
||||
items: () => state.items,
|
||||
/** Runs `work` while the upload is listed. Resolves to undefined when the user cancels it. */
|
||||
async track<T>(
|
||||
input: Pick<Upload, "id" | "filename" | "mime" | "size">,
|
||||
work: (report: (loaded: number) => void, signal: AbortSignal) => Promise<T>,
|
||||
): Promise<T | undefined> {
|
||||
const controller = new AbortController()
|
||||
setState("items", (items) => [...items, { ...input, loaded: 0, cancel: () => controller.abort() }])
|
||||
try {
|
||||
return await work((loaded) => setState("items", (item) => item.id === input.id, "loaded", loaded), controller.signal)
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return undefined
|
||||
throw error
|
||||
} finally {
|
||||
setState("items", (items) => items.filter((item) => item.id !== input.id))
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Sonner builds toast content outside the app's Solid tree: no context and no owner. This host
|
||||
// lives inside the providers, lends the toast its language instance, and gives the content a
|
||||
// root of its own so progress stays reactive.
|
||||
export function UploadToastHost() {
|
||||
const language = useLanguage()
|
||||
let active: { id: number; dispose: () => void } | undefined
|
||||
const dismiss = () => {
|
||||
if (!active) return
|
||||
toaster.dismiss(active.id)
|
||||
active.dispose()
|
||||
active = undefined
|
||||
}
|
||||
createEffect(
|
||||
on(
|
||||
() => state.items.length > 0,
|
||||
(uploading) => {
|
||||
if (!uploading) return dismiss()
|
||||
if (active) return
|
||||
const id = toaster.show(
|
||||
(props) =>
|
||||
createRoot((dispose) => {
|
||||
active = { id: props.toastId, dispose }
|
||||
return <UploadToast toastId={props.toastId} language={language} />
|
||||
}),
|
||||
{ persistent: true, resize: () => state.items.length },
|
||||
)
|
||||
active ??= { id, dispose: () => {} }
|
||||
},
|
||||
),
|
||||
)
|
||||
onCleanup(dismiss)
|
||||
return null
|
||||
}
|
||||
|
||||
function UploadToast(props: { toastId: number; language: ReturnType<typeof useLanguage> }) {
|
||||
const percent = (item: Upload) => (item.size === 0 ? 100 : Math.floor((item.loaded / item.size) * 100))
|
||||
return (
|
||||
<Toast toastId={props.toastId}>
|
||||
<Toast.Content>
|
||||
<For each={state.items}>
|
||||
{(item) => (
|
||||
<div data-component="upload-row">
|
||||
<div data-slot="upload-row-label">
|
||||
<span data-slot="upload-row-name" title={item.filename}>
|
||||
{item.filename}
|
||||
</span>
|
||||
<span data-slot="upload-row-percent">
|
||||
{props.language.t("prompt.toast.uploading.percent", { percent: percent(item) })}
|
||||
</span>
|
||||
</div>
|
||||
<div data-slot="upload-row-track">
|
||||
<div
|
||||
data-component="upload-progress"
|
||||
role="progressbar"
|
||||
aria-label={item.filename}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={percent(item)}
|
||||
>
|
||||
<div data-slot="upload-progress-bar" style={{ width: `${percent(item)}%` }} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="upload-row-cancel"
|
||||
aria-label={props.language.t("prompt.toast.uploading.cancel")}
|
||||
onClick={() => item.cancel()}
|
||||
>
|
||||
<Icon name="outline-xmark" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Toast.Content>
|
||||
</Toast>
|
||||
)
|
||||
}
|
||||
@@ -167,7 +167,7 @@ function ComposerStory(props: {
|
||||
? buildPromptRequest({
|
||||
prompt: draft.prompt,
|
||||
context: draft.context.items,
|
||||
attachments: [],
|
||||
images: [],
|
||||
text: value,
|
||||
sessionDirectory: "C:/repo",
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
ComposerPersistedState,
|
||||
ComposerPrompt,
|
||||
} from "../types"
|
||||
import { promptLength } from "../prompt-parts"
|
||||
import { isAttachment, promptLength } from "../prompt-parts"
|
||||
|
||||
export type ComposerStateStore = [
|
||||
Store<ComposerPersistedState> | Accessor<Store<ComposerPersistedState>>,
|
||||
@@ -48,7 +48,7 @@ export function createComposerEditorActions(input: ComposerStateStoreInput) {
|
||||
setStore()((state) => ({
|
||||
prompt: [
|
||||
{ type: "text", content, start: 0, end: content.length },
|
||||
...state.prompt.filter((part) => part.type === "image"),
|
||||
...state.prompt.filter(isAttachment),
|
||||
],
|
||||
cursor: content.length,
|
||||
retry: undefined,
|
||||
@@ -83,7 +83,7 @@ export function createComposerEditorActions(input: ComposerStateStoreInput) {
|
||||
clearRetry()
|
||||
},
|
||||
removeAttachment(id: string) {
|
||||
setStore()("prompt", (parts) => parts.filter((part) => part.type !== "image" || part.id !== id))
|
||||
setStore()("prompt", (parts) => parts.filter((part) => !isAttachment(part) || part.id !== id))
|
||||
clearRetry()
|
||||
},
|
||||
}
|
||||
@@ -93,7 +93,7 @@ function insertText(prompt: ComposerPrompt, cursor: number, content: string): Co
|
||||
let position = 0
|
||||
let inserted = false
|
||||
const parts = prompt.flatMap<ComposerPrompt[number]>((part) => {
|
||||
if (part.type === "image") return [part]
|
||||
if (isAttachment(part)) return [part]
|
||||
const start = position
|
||||
position += part.content.length
|
||||
if (inserted) return [part]
|
||||
@@ -121,7 +121,7 @@ function insertMention(
|
||||
}
|
||||
let position = 0
|
||||
const parts = prompt.flatMap<ComposerPrompt[number]>((part) => {
|
||||
if (part.type === "image") return [part]
|
||||
if (isAttachment(part)) return [part]
|
||||
const partStart = position
|
||||
position += part.content.length
|
||||
if (part.type !== "text" || start < partStart || end > position) return [part]
|
||||
@@ -139,7 +139,7 @@ function insertMention(
|
||||
function withOffsets(prompt: ComposerPrompt): ComposerPrompt {
|
||||
let offset = 0
|
||||
return prompt.map((part) => {
|
||||
if (part.type === "image") return part
|
||||
if (isAttachment(part)) return part
|
||||
const next = { ...part, start: offset, end: offset + part.content.length }
|
||||
offset = next.end
|
||||
return next
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
|
||||
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { FileIcon } from "@opencode/ui/file-icon"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
import { resolveBlobUrl } from "@/runtime/persistence/drafts"
|
||||
import { ProviderIcon } from "@opencode/ui/provider-icon"
|
||||
import { useI18n } from "@opencode/ui/context/i18n"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
@@ -12,6 +13,8 @@ import { Menu } from "@opencode/ui/menu"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { ScrollView } from "@opencode/ui/scroll-view"
|
||||
import { AttachmentCard } from "@opencode/session-ui/attachment-card"
|
||||
import { ProgressCircle } from "@opencode/ui/progress-circle"
|
||||
import type { Upload } from "../attachments/uploads"
|
||||
import { CommentCard } from "@opencode/session-ui/comment-card"
|
||||
import { typeLabel } from "@opencode/session-ui/message-file"
|
||||
import { Skill } from "@opencode/schema/skill"
|
||||
@@ -24,6 +27,7 @@ import type {
|
||||
ComposerSuggestion,
|
||||
} from "../types"
|
||||
import type { ComposerEditorModel, ComposerSelectControl } from "./interaction"
|
||||
import { isAttachment } from "../prompt-parts"
|
||||
import "../attachments/attachments.css"
|
||||
import "./editor.css"
|
||||
|
||||
@@ -148,11 +152,13 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
<Show when={state.mode === "normal"}>
|
||||
<ComposerAttachments
|
||||
attachments={props.controller.attachments()}
|
||||
uploads={props.controller.uploads()}
|
||||
comments={props.controller.comments()}
|
||||
activeCommentID={state.activeContextID}
|
||||
removeLabel={i18n.t("ui.promptInput.removeAttachment")}
|
||||
onAttachmentClick={props.controller.openAttachment}
|
||||
onAttachmentRemove={(attachment) => props.controller.removeAttachment(attachment.id)}
|
||||
onUploadCancel={(upload) => props.controller.cancelUpload(upload.id)}
|
||||
onCommentClick={(comment) => props.controller.toggleContext(comment.key)}
|
||||
onCommentRemove={(comment) => props.controller.removeContext(comment.key)}
|
||||
/>
|
||||
@@ -191,9 +197,9 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
onInput={(event) => {
|
||||
const cursor = composerCursor(event.currentTarget)
|
||||
const prompt = parseComposerEditor(event.currentTarget)
|
||||
const images = props.controller.parts().filter((part) => part.type === "image")
|
||||
const attachments = props.controller.parts().filter(isAttachment)
|
||||
localInput = true
|
||||
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor)
|
||||
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...attachments], cursor)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!view.draftOnly && props.controller.onKeyDown(event)) return
|
||||
@@ -348,7 +354,7 @@ function renderComposerEditor(editor: HTMLDivElement, prompt: ComposerPrompt) {
|
||||
const active = document.activeElement === editor
|
||||
editor.replaceChildren(
|
||||
...prompt.flatMap<Node>((part) => {
|
||||
if (part.type === "image") return []
|
||||
if (isAttachment(part)) return []
|
||||
if (part.type === "text") return [document.createTextNode(part.content)]
|
||||
const mention = document.createElement("span")
|
||||
mentionParts.set(mention, part)
|
||||
@@ -475,17 +481,22 @@ function composerCursor(editor: HTMLDivElement) {
|
||||
|
||||
export function ComposerAttachments(props: {
|
||||
attachments: ComposerAttachment[]
|
||||
uploads?: Upload[]
|
||||
comments?: ComposerComment[]
|
||||
activeCommentID?: string
|
||||
removeLabel: string
|
||||
onAttachmentClick?: (attachment: ComposerAttachment) => void
|
||||
onAttachmentRemove: (attachment: ComposerAttachment) => void
|
||||
onUploadCancel?: (upload: Upload) => void
|
||||
onCommentClick?: (comment: ComposerComment) => void
|
||||
onCommentRemove?: (comment: ComposerComment) => void
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
const percent = (upload: Upload) => (upload.size === 0 ? 100 : Math.floor((upload.loaded / upload.size) * 100))
|
||||
return (
|
||||
<Show when={props.attachments.length > 0 || (props.comments?.length ?? 0) > 0}>
|
||||
<Show
|
||||
when={props.attachments.length > 0 || (props.uploads?.length ?? 0) > 0 || (props.comments?.length ?? 0) > 0}
|
||||
>
|
||||
<div data-component="composer-attachments" data-slot="composer-attachments" class="relative">
|
||||
<div
|
||||
data-slot="composer-attachments-scroll"
|
||||
@@ -522,22 +533,34 @@ export function ComposerAttachments(props: {
|
||||
<For each={props.attachments}>
|
||||
{(attachment) => (
|
||||
<div class="relative group shrink-0">
|
||||
<Tooltip value={attachment.filename} placement="top" contentClass="break-all">
|
||||
<Tooltip
|
||||
value={attachment.type === "path" ? attachment.path : attachment.filename}
|
||||
placement="top"
|
||||
contentClass="break-all"
|
||||
>
|
||||
<Show
|
||||
when={attachment.mime.startsWith("image/")}
|
||||
when={attachment.type === "image" && attachment.mime.startsWith("image/") ? attachment : undefined}
|
||||
fallback={
|
||||
<AttachmentCard title={attachment.filename}>
|
||||
{typeLabel(attachment.filename, attachment.mime, i18n.t("ui.common.file"))}
|
||||
</AttachmentCard>
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={attachment.blob.url}
|
||||
alt={attachment.filename}
|
||||
class="w-[58px] h-[46px] rounded-[6px] object-cover"
|
||||
onClick={() => props.onAttachmentClick?.(attachment)}
|
||||
/>
|
||||
<div class="absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none" />
|
||||
{(image) => {
|
||||
// Restored drafts and history carry image ids only; bytes load when shown.
|
||||
const [url] = createResource(() => image().blob, resolveBlobUrl)
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
src={url() ?? ""}
|
||||
alt={attachment.filename}
|
||||
class="w-[58px] h-[46px] rounded-[6px] object-cover"
|
||||
onClick={() => props.onAttachmentClick?.(attachment)}
|
||||
/>
|
||||
<div class="absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none" />
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</Tooltip>
|
||||
<button
|
||||
@@ -551,6 +574,28 @@ export function ComposerAttachments(props: {
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<For each={props.uploads ?? []}>
|
||||
{(upload) => (
|
||||
<div class="relative group shrink-0" data-slot="composer-upload">
|
||||
<Tooltip value={upload.filename} placement="top" contentClass="break-all">
|
||||
<AttachmentCard title={upload.filename}>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<ProgressCircle percentage={percent(upload)} />
|
||||
{i18n.t("ui.promptInput.uploading", { percent: percent(upload) })}
|
||||
</span>
|
||||
</AttachmentCard>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onUploadCancel?.(upload)}
|
||||
class="absolute -top-1 -end-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
aria-label={i18n.t("ui.promptInput.cancelUpload")}
|
||||
>
|
||||
<Icon name="outline-xmark" class="text-v2-icon-icon-contrast" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div
|
||||
data-slot="composer-attachments-fade-left"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createEffect, type Accessor } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { useFilteredList } from "@opencode/ui/hooks"
|
||||
import { createComposerAttachments, type ComposerAttachmentConfig } from "../attachments/attachments"
|
||||
import type { Upload } from "../attachments/uploads"
|
||||
import { createComposerEditorActions, type ComposerStateStoreInput } from "./actions"
|
||||
import type {
|
||||
ComposerAttachment,
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
type ComposerInteractionCommand,
|
||||
type ComposerInteractionEvent,
|
||||
} from "../suggestions/machine"
|
||||
import { clonePrompt, promptLength } from "../prompt-parts"
|
||||
import { clonePrompt, isAttachment, promptLength } from "../prompt-parts"
|
||||
import type { ComposerQueue } from "../adapter"
|
||||
|
||||
export type ComposerSelectControl = {
|
||||
@@ -74,7 +75,7 @@ export function createComposerEditor(input: {
|
||||
const draft = createComposerEditorActions(input.store)
|
||||
const [state, setState] = input.state ?? createComposerEditorState(draft.state.mode)
|
||||
function addPart(part: ComposerPersistedState["prompt"][number]) {
|
||||
if (part.type === "image") return false
|
||||
if (isAttachment(part)) return false
|
||||
if (part.type === "file" || part.type === "agent") {
|
||||
draft.addMention(part)
|
||||
return true
|
||||
@@ -169,7 +170,7 @@ export function createComposerEditor(input: {
|
||||
if (!action || state.popover.type !== "command-menu") result.commands.forEach(execute)
|
||||
if (action && event.item.kind === "command" && state.popover.type !== "command-menu") {
|
||||
draft.setPrompt(
|
||||
draft.state.prompt.filter((part): part is ComposerAttachment => part.type === "image"),
|
||||
draft.state.prompt.filter(isAttachment),
|
||||
0,
|
||||
)
|
||||
}
|
||||
@@ -315,7 +316,13 @@ export function createComposerEditor(input: {
|
||||
return draft.state.context.items.filter((item) => !!item.comment?.trim())
|
||||
},
|
||||
attachments(): ComposerAttachment[] {
|
||||
return draft.state.prompt.filter((part): part is ComposerAttachment => part.type === "image")
|
||||
return draft.state.prompt.filter(isAttachment)
|
||||
},
|
||||
uploads(): Upload[] {
|
||||
return attachments?.pending() ?? []
|
||||
},
|
||||
cancelUpload(id: string) {
|
||||
attachments?.cancel(id)
|
||||
},
|
||||
toggleContext(id: string) {
|
||||
dispatch({ type: "context.active", id })
|
||||
@@ -336,11 +343,12 @@ export function createComposerEditor(input: {
|
||||
canSubmit() {
|
||||
if (input.view.submit.available?.() === false) return false
|
||||
if (input.view.draftOnly) return false
|
||||
if (attachments?.pending().length) return false
|
||||
const persisted = draft.state
|
||||
if (state.mode === "shell") {
|
||||
return persisted.prompt.some((part) => "content" in part && !!part.content.trim())
|
||||
}
|
||||
if (persisted.prompt.some((part) => part.type === "image")) return true
|
||||
if (persisted.prompt.some(isAttachment)) return true
|
||||
if (persisted.context.items.some((item) => !!item.comment?.trim())) return true
|
||||
return persisted.prompt.some((part) => "content" in part && !!part.content.trim())
|
||||
},
|
||||
@@ -369,6 +377,7 @@ export function createComposerEditor(input: {
|
||||
submit(options?: { alternate?: boolean }) {
|
||||
if (input.view.submit.available?.() === false) return
|
||||
if (input.view.draftOnly) return
|
||||
if (attachments?.pending().length) return
|
||||
input.view.submit.onSubmit(options)
|
||||
dispatch({ type: "popover.close" })
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Prompt } from "@/composer/state"
|
||||
import type { SelectedLineRange } from "@/workspaces/files/model"
|
||||
import { clonePrompt } from "../prompt-parts"
|
||||
import { clonePrompt, isAttachment } from "../prompt-parts"
|
||||
import type { PromptHistoryComment, PromptHistoryEntry } from "../schema"
|
||||
|
||||
export type { PromptHistoryComment, PromptHistoryEntry } from "../schema"
|
||||
@@ -35,9 +35,9 @@ export function prependHistoryEntry(
|
||||
.map((part) => ("content" in part ? part.content : ""))
|
||||
.join("")
|
||||
.trim()
|
||||
const hasImages = prompt.some((part) => part.type === "image")
|
||||
const hasAttachments = prompt.some(isAttachment)
|
||||
const hasComments = comments.some((comment) => !!comment.comment.trim())
|
||||
if (!text && !hasImages && !hasComments) return entries
|
||||
if (!text && !hasAttachments && !hasComments) return entries
|
||||
|
||||
const entry = {
|
||||
prompt: clonePrompt(prompt),
|
||||
@@ -86,7 +86,7 @@ function isPromptEqual(entryA: PromptHistoryStoredEntry, entryB: PromptHistorySt
|
||||
if (partA.type === "skill") {
|
||||
if (partB.type !== "skill" || partA.id !== partB.id || partA.name !== partB.name) return false
|
||||
}
|
||||
if (partA.type === "image" && partA.id !== (partB.type === "image" ? partB.id : "")) return false
|
||||
if (isAttachment(partA) && partA.id !== (isAttachment(partB) ? partB.id : "")) return false
|
||||
}
|
||||
if (entryA.comments.length !== entryB.comments.length) return false
|
||||
for (let i = 0; i < entryA.comments.length; i++) {
|
||||
|
||||
@@ -11,18 +11,19 @@ import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { resolveBlobUrl } from "@/runtime/persistence/drafts"
|
||||
import { useData, useServer } from "@/runtime/server/current"
|
||||
import { createSessionTabs } from "@/session/helpers"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { formatServerError } from "@/runtime/server/errors"
|
||||
import { Skill } from "@opencode/schema/skill"
|
||||
import type { ComposerAdapter, ComposerControls, ComposerQueue } from "./adapter"
|
||||
import type { ImageAttachmentPart } from "./state"
|
||||
import { isAttachment } from "./prompt-parts"
|
||||
import type { PromptHistoryComment } from "./history/entry"
|
||||
import { createComposerHistory } from "./history/store"
|
||||
import { composerPlaceholder } from "./placeholder"
|
||||
import { createComposerSubmit } from "./submit"
|
||||
import { useAttachmentDestination } from "./attachments/deliver"
|
||||
import { useAttachmentDestination } from "./attachments/destination"
|
||||
|
||||
export type ComposerModel = ComposerEditorModel & {
|
||||
readonly model: ComposerControls["model"]
|
||||
@@ -73,7 +74,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
}, [])
|
||||
})
|
||||
const attachments = createMemo(() =>
|
||||
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
||||
prompt.current().filter(isAttachment),
|
||||
)
|
||||
const commentCount = createMemo(() => {
|
||||
if (mode() === "shell") return 0
|
||||
@@ -266,7 +267,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
resetHistory: () => controller.resetHistory(),
|
||||
setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }),
|
||||
closePopover: () => controller.dispatch({ type: "popover.close" }),
|
||||
destination: useAttachmentDestination(adapter.controls),
|
||||
delivery: (alternate) => {
|
||||
const queue = options?.queue
|
||||
if (!queue) return "steer"
|
||||
@@ -321,8 +321,12 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
onContextRemove(item) {
|
||||
if (item?.commentID) comments.remove(item.path, item.commentID)
|
||||
},
|
||||
openAttachment: (attachment) =>
|
||||
dialog.show(() => createComponent(ImagePreview, { src: attachment.blob.url, alt: attachment.filename })),
|
||||
openAttachment: (attachment) => {
|
||||
if (attachment.type !== "image") return
|
||||
void resolveBlobUrl(attachment.blob).then((src) => {
|
||||
if (src) dialog.show(() => createComponent(ImagePreview, { src, alt: attachment.filename }))
|
||||
})
|
||||
},
|
||||
openContext(key) {
|
||||
const item = controller.contextItem(key)
|
||||
if (item) openComment(item, adapter.controls(), layout, files, comments)
|
||||
@@ -340,8 +344,15 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
attachments: {
|
||||
picker: platform.openAttachmentPickerDialog,
|
||||
directory: () => sdk().directory,
|
||||
destination: useAttachmentDestination(adapter.controls),
|
||||
isDialogActive: () => !!dialog.active,
|
||||
duplicate: () => showToast({ title: language.t("prompt.toast.attachmentDuplicate.title") }),
|
||||
onUploadError: (error) =>
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("prompt.toast.uploadFailed.title"),
|
||||
description: composerErrorMessage(language, error),
|
||||
}),
|
||||
onError: (error) =>
|
||||
showToast({
|
||||
variant: "error",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { Prompt } from "./state"
|
||||
import type { ContentPart, ImageAttachmentPart, PathAttachmentPart, Prompt } from "./state"
|
||||
|
||||
/** Parts that sit beside the text rather than inside it. */
|
||||
export function isAttachment(part: ContentPart): part is ImageAttachmentPart | PathAttachmentPart {
|
||||
return part.type === "image" || part.type === "path"
|
||||
}
|
||||
|
||||
export function clonePrompt(prompt: Prompt): Prompt {
|
||||
return prompt.map((part) =>
|
||||
@@ -17,7 +22,7 @@ export function appendPrompt(prompt: Prompt, following: Prompt): Prompt {
|
||||
...clonePrompt(prompt),
|
||||
{ type: "text", content: "\n\n", start, end: offset },
|
||||
...clonePrompt(following).map((part) =>
|
||||
part.type === "image" ? part : { ...part, start: part.start + offset, end: part.end + offset },
|
||||
isAttachment(part) ? part : { ...part, start: part.start + offset, end: part.end + offset },
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Skill } from "@opencode/schema/skill"
|
||||
import type { ImageAttachmentPart, Prompt } from "@/composer/state"
|
||||
import type { DeliveredAttachment } from "./attachments/deliver"
|
||||
import { buildPromptRequest } from "./request"
|
||||
|
||||
function inline(filename: string, mime: string, extra?: Partial<ImageAttachmentPart>): DeliveredAttachment {
|
||||
return {
|
||||
type: "inline",
|
||||
attachment: { type: "image", id: `img_${filename}`, filename, mime, blob: { id: filename, url: "" }, ...extra },
|
||||
dataUrl: `data:${mime};base64,AAA`,
|
||||
}
|
||||
function inline(filename: string, mime: string, extra?: Partial<ImageAttachmentPart>) {
|
||||
return { type: "image" as const, id: `img_${filename}`, filename, mime, dataUrl: `data:${mime};base64,AAA`, ...extra }
|
||||
}
|
||||
|
||||
describe("buildPromptRequest", () => {
|
||||
@@ -30,7 +25,7 @@ describe("buildPromptRequest", () => {
|
||||
const result = buildPromptRequest({
|
||||
prompt,
|
||||
context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
|
||||
attachments: [inline("a.png", "image/png")],
|
||||
images: [inline("a.png", "image/png")],
|
||||
text: "hello @src/foo.ts @planner",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
@@ -52,7 +47,7 @@ describe("buildPromptRequest", () => {
|
||||
const result = buildPromptRequest({
|
||||
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
|
||||
context: [],
|
||||
attachments: [inline("a.png", "image/png"), inline("b.pdf", "application/pdf")],
|
||||
images: [inline("a.png", "image/png"), inline("b.pdf", "application/pdf")],
|
||||
text: "check these",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
@@ -67,7 +62,7 @@ describe("buildPromptRequest", () => {
|
||||
const result = buildPromptRequest({
|
||||
prompt: [],
|
||||
context: [],
|
||||
attachments: [
|
||||
images: [
|
||||
inline("opencode.global.dat", "text/plain", {
|
||||
sourcePath: "C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
|
||||
}),
|
||||
@@ -95,7 +90,7 @@ describe("buildPromptRequest", () => {
|
||||
},
|
||||
],
|
||||
context: [],
|
||||
attachments: [],
|
||||
images: [],
|
||||
text: "@docs",
|
||||
sessionDirectory: "/repo/app",
|
||||
})
|
||||
@@ -117,7 +112,7 @@ describe("buildPromptRequest", () => {
|
||||
{ key: "ctx:dup", type: "file", path: "src/foo.ts" },
|
||||
{ key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" },
|
||||
],
|
||||
attachments: [],
|
||||
images: [],
|
||||
text: "@src/foo.ts",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
@@ -139,7 +134,7 @@ describe("buildPromptRequest", () => {
|
||||
comment: "Compare with @src/shared.ts and @src/review.ts.",
|
||||
},
|
||||
],
|
||||
attachments: [],
|
||||
images: [],
|
||||
text: "look",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
@@ -155,7 +150,7 @@ describe("buildPromptRequest", () => {
|
||||
const result = buildPromptRequest({
|
||||
prompt,
|
||||
context: [],
|
||||
attachments: [],
|
||||
images: [],
|
||||
text: "@src\\foo.ts",
|
||||
sessionDirectory: "D:\\projects\\myapp", // Windows path
|
||||
})
|
||||
@@ -176,7 +171,7 @@ describe("buildPromptRequest", () => {
|
||||
const result = buildPromptRequest({
|
||||
prompt,
|
||||
context: [],
|
||||
attachments: [],
|
||||
images: [],
|
||||
text: "@file#name.txt",
|
||||
sessionDirectory: "C:\\Users\\test\\Documents", // Windows path
|
||||
})
|
||||
@@ -197,7 +192,7 @@ describe("buildPromptRequest", () => {
|
||||
const result = buildPromptRequest({
|
||||
prompt,
|
||||
context: [],
|
||||
attachments: [],
|
||||
images: [],
|
||||
text: "@src/app.ts",
|
||||
sessionDirectory: "/home/user/project",
|
||||
})
|
||||
@@ -211,7 +206,7 @@ describe("buildPromptRequest", () => {
|
||||
const result = buildPromptRequest({
|
||||
prompt,
|
||||
context: [],
|
||||
attachments: [],
|
||||
images: [],
|
||||
text: "@README.md",
|
||||
sessionDirectory: "/Users/kelvin/Projects/opencode",
|
||||
})
|
||||
@@ -226,7 +221,7 @@ describe("buildPromptRequest", () => {
|
||||
{ key: "ctx:1", type: "file", path: "src\\utils\\helper.ts" },
|
||||
{ key: "ctx:2", type: "file", path: "test\\unit.test.ts", comment: "check tests" },
|
||||
],
|
||||
attachments: [],
|
||||
images: [],
|
||||
text: "test",
|
||||
sessionDirectory: "D:\\workspace\\app",
|
||||
})
|
||||
@@ -248,7 +243,7 @@ describe("buildPromptRequest", () => {
|
||||
const result = buildPromptRequest({
|
||||
prompt,
|
||||
context: [],
|
||||
attachments: [],
|
||||
images: [],
|
||||
text: "@D:\\other\\project\\file.ts",
|
||||
sessionDirectory: "C:\\current\\project",
|
||||
})
|
||||
@@ -275,7 +270,7 @@ describe("buildPromptRequest", () => {
|
||||
const result = buildPromptRequest({
|
||||
prompt,
|
||||
context: [],
|
||||
attachments: [],
|
||||
images: [],
|
||||
text: "@src\\App.tsx",
|
||||
sessionDirectory: "C:\\project",
|
||||
})
|
||||
@@ -300,7 +295,7 @@ describe("buildPromptRequest", () => {
|
||||
const result = buildPromptRequest({
|
||||
prompt,
|
||||
context: [],
|
||||
attachments: [],
|
||||
images: [],
|
||||
text: "@..\\..\\shared\\util.ts",
|
||||
sessionDirectory: "C:\\projects\\myapp\\src",
|
||||
})
|
||||
@@ -330,7 +325,7 @@ describe("buildPromptRequest", () => {
|
||||
},
|
||||
],
|
||||
context: [],
|
||||
attachments: [],
|
||||
images: [],
|
||||
text: "@review",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import type { FileSelection } from "@/workspaces/files/model"
|
||||
import { encodeFilePath } from "@/workspaces/files/path"
|
||||
import type { AgentPart, FileAttachmentPart, Prompt, SkillPart } from "@/composer/state"
|
||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, PathAttachmentPart, Prompt, SkillPart } from "@/composer/state"
|
||||
import {
|
||||
formatAttachmentReference,
|
||||
formatCommentNote,
|
||||
type PromptAttachmentReference,
|
||||
type PromptComment,
|
||||
} from "@/composer/comment-note"
|
||||
import type { DeliveredAttachment } from "@/composer/attachments/deliver"
|
||||
|
||||
// Network fields feed both boundaries; display fields keep desktop-only rendering details in the local echo.
|
||||
type PromptRequest = {
|
||||
@@ -35,7 +34,7 @@ type ContextFile = {
|
||||
type BuildPromptRequestInput = {
|
||||
prompt: Prompt
|
||||
context: ContextFile[]
|
||||
attachments: DeliveredAttachment[]
|
||||
images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[]
|
||||
text: string
|
||||
sessionDirectory: string
|
||||
}
|
||||
@@ -63,6 +62,7 @@ const parseCommentMentions = (comment: string) => {
|
||||
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
|
||||
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
|
||||
const isSkillAttachment = (part: Prompt[number]): part is SkillPart => part.type === "skill"
|
||||
const isPathAttachment = (part: Prompt[number]): part is PathAttachmentPart => part.type === "path"
|
||||
|
||||
export function buildPromptRequest(input: BuildPromptRequestInput): PromptRequest {
|
||||
const skills = input.prompt.filter(isSkillAttachment).map((attachment) => ({
|
||||
@@ -113,15 +113,15 @@ export function buildPromptRequest(input: BuildPromptRequestInput): PromptReques
|
||||
return [file, ...mentions]
|
||||
})
|
||||
|
||||
const inline = input.attachments.flatMap((item) =>
|
||||
item.type === "inline"
|
||||
? [{ uri: item.dataUrl, mime: item.attachment.mime, name: item.attachment.sourcePath ?? item.attachment.filename }]
|
||||
: [],
|
||||
)
|
||||
const inline = input.images.map((attachment) => ({
|
||||
uri: attachment.dataUrl,
|
||||
mime: attachment.mime,
|
||||
name: attachment.sourcePath ?? attachment.filename,
|
||||
}))
|
||||
// Like comments, path references reach the model as text and the message UI through metadata.
|
||||
const attachments = input.attachments.flatMap((item) =>
|
||||
item.type === "path" ? [{ name: item.attachment.filename, mime: item.attachment.mime, path: item.path }] : [],
|
||||
)
|
||||
const attachments = input.prompt
|
||||
.filter(isPathAttachment)
|
||||
.map((part) => ({ name: part.filename, mime: part.mime, path: part.path }))
|
||||
|
||||
return {
|
||||
text: [
|
||||
|
||||
@@ -136,7 +136,7 @@ describe("composer persistence schemas", () => {
|
||||
).toEqual(value)
|
||||
})
|
||||
|
||||
test("migrates inline images but never encodes dataUrl or unresolved references", () => {
|
||||
test("migrates inline images, keeps store references without a URL, and never encodes dataUrl", () => {
|
||||
const value = Schema.decodeUnknownSync(
|
||||
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
|
||||
)({
|
||||
@@ -149,12 +149,16 @@ describe("composer persistence schemas", () => {
|
||||
{ ...image, blob: { id: "missing" }, dataUrl: "data:image/png;base64,YQ==" },
|
||||
],
|
||||
})
|
||||
expect(value.prompt).toHaveLength(3)
|
||||
expect(value.prompt).toHaveLength(6)
|
||||
expect(value.prompt[0]).toEqual({
|
||||
...image,
|
||||
sourcePath: "/image.png",
|
||||
blob: { id: "data:image/png;base64,YQ==", url: "data:image/png;base64,YQ==" },
|
||||
})
|
||||
// Bytes still in the draft store resolve on use; a non-blob URL is discarded in favour of the id.
|
||||
expect(value.prompt[3]).toEqual({ ...image, blob: { id: "missing", url: "" } })
|
||||
expect(value.prompt[4]).toEqual({ ...image, blob: { id: "bad", url: "" } })
|
||||
expect(value.prompt[5]).toEqual({ ...image, blob: { id: "missing", url: "" } })
|
||||
const encoded = Schema.encodeSync(ComposerStore)(value)
|
||||
expect(JSON.stringify(encoded)).not.toContain("dataUrl")
|
||||
expect(
|
||||
|
||||
@@ -61,11 +61,12 @@ const ImageFields = {
|
||||
}
|
||||
const Image = Persistence.struct({
|
||||
...ImageFields,
|
||||
blob: Schema.Struct({ id: Schema.NonEmptyString, url: Schema.String.check(Schema.isPattern(/^(blob:|data:)/)) }),
|
||||
// An empty URL is an image whose bytes are still in the draft store; see `resolveBlobUrl`.
|
||||
blob: Schema.Struct({ id: Schema.NonEmptyString, url: Schema.String.check(Schema.isPattern(/^(blob:|data:|$)/)) }),
|
||||
})
|
||||
|
||||
// Draft storage hydrates content-addressed blobs before this codec runs. Legacy
|
||||
// inline data remains usable, but unresolved references are not renderable.
|
||||
// Draft storage keeps content-addressed blobs in the store until an image is shown or sent; a
|
||||
// reference without a URL resolves through `resolveBlobUrl`. Legacy inline data remains usable.
|
||||
export const ImageAttachmentPart = Schema.Struct({
|
||||
...ImageFields,
|
||||
blob: Persistence.optional(
|
||||
@@ -94,7 +95,24 @@ export const ImageAttachmentPart = Schema.Struct({
|
||||
)
|
||||
export type ImageAttachmentPart = typeof ImageAttachmentPart.Type
|
||||
|
||||
export const ContentPart = Schema.Union([TextPart, FileAttachmentPart, AgentPart, SkillPart, ImageAttachmentPart])
|
||||
// A file the model receives as a path on the server: its bytes never enter the draft store.
|
||||
export const PathAttachmentPart = Persistence.struct({
|
||||
type: Schema.Literal("path"),
|
||||
id: Schema.String,
|
||||
filename: Schema.String,
|
||||
mime: Schema.String,
|
||||
path: Schema.String,
|
||||
})
|
||||
export type PathAttachmentPart = typeof PathAttachmentPart.Type
|
||||
|
||||
export const ContentPart = Schema.Union([
|
||||
TextPart,
|
||||
FileAttachmentPart,
|
||||
AgentPart,
|
||||
SkillPart,
|
||||
ImageAttachmentPart,
|
||||
PathAttachmentPart,
|
||||
])
|
||||
export type ContentPart = typeof ContentPart.Type
|
||||
export const Prompt = Persistence.array(ContentPart)
|
||||
export type Prompt = typeof Prompt.Type
|
||||
|
||||
@@ -88,6 +88,15 @@ describe("prompt state initialization", () => {
|
||||
start: 5,
|
||||
end: 12,
|
||||
},
|
||||
// A reference without a usable URL keeps its id; the bytes resolve from the draft store on use.
|
||||
{
|
||||
type: "image",
|
||||
id: "missing-blob",
|
||||
filename: "missing.png",
|
||||
mime: "image/png",
|
||||
blob: { id: "content-hash-without-a-url", url: "" },
|
||||
},
|
||||
{ type: "image", id: "invalid-url", filename: "invalid.png", mime: "image/png", blob: { id: "hash", url: "" } },
|
||||
{
|
||||
type: "image",
|
||||
id: "legacy",
|
||||
|
||||
@@ -23,6 +23,7 @@ export type {
|
||||
FileAttachmentPart,
|
||||
FileContextItem,
|
||||
ImageAttachmentPart,
|
||||
PathAttachmentPart,
|
||||
Prompt,
|
||||
PromptModel,
|
||||
SkillPart,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ComposerState, ContextItem, Prompt } from "./state"
|
||||
import { appendPrompt, clonePrompt } from "./prompt-parts"
|
||||
import { appendPrompt, clonePrompt, isAttachment } from "./prompt-parts"
|
||||
|
||||
export type ComposerStateTarget = ReturnType<ComposerState["capture"]>
|
||||
|
||||
@@ -22,7 +22,7 @@ export function createComposerSubmission(input: {
|
||||
if (initial !== target) {
|
||||
initial.reset()
|
||||
// A preparing session may already have an unsent follow-up in its promoted composer.
|
||||
if (preserveDraft && target.current().some((part) => part.type === "image" || part.content.length > 0))
|
||||
if (preserveDraft && target.current().some((part) => isAttachment(part) || part.content.length > 0))
|
||||
following = clonePrompt(target.current())
|
||||
}
|
||||
if (!following) target.reset()
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { ModelSelection } from "@/providers/models/selection"
|
||||
import type { SessionMessageUser } from "@opencode/client/promise"
|
||||
import { Skill } from "@opencode/schema/skill"
|
||||
import type { ActiveComposerAdapter, ComposerControls, ComposerSession, NewSessionComposerAdapter } from "./adapter"
|
||||
import type { AttachmentDestination } from "./attachments/deliver"
|
||||
import { createMemoryComposerState } from "./state"
|
||||
import { createComposerSubmit } from "./submit"
|
||||
|
||||
@@ -49,14 +48,6 @@ function controls(): ComposerControls {
|
||||
}
|
||||
}
|
||||
|
||||
const destination: AttachmentDestination = {
|
||||
input: { image: true, pdf: true },
|
||||
local: false,
|
||||
upload: async () => {
|
||||
throw new Error("native attachments must not upload")
|
||||
},
|
||||
}
|
||||
|
||||
function submitInput(
|
||||
adapter: ActiveComposerAdapter | NewSessionComposerAdapter,
|
||||
notify = { missingSelection() {}, failed(_kind: "shell" | "command" | "prompt", _error: unknown) {} },
|
||||
@@ -73,7 +64,6 @@ function submitInput(
|
||||
resetHistory() {},
|
||||
setMode() {},
|
||||
closePopover() {},
|
||||
destination: () => destination,
|
||||
notify,
|
||||
comments: { capture: () => [], clear() {}, restore() {} },
|
||||
})
|
||||
|
||||
@@ -8,7 +8,8 @@ import type { ComposerAdapter, ComposerDelivery, ComposerSelection, ComposerSess
|
||||
import { createComposerSubmission } from "./submission-state"
|
||||
import { buildPromptRequest } from "./request"
|
||||
import { setCursorPosition } from "./editor/dom"
|
||||
import { deliverAttachments, type AttachmentDestination } from "./attachments/deliver"
|
||||
import { blobDataUrl, resolveBlobUrl } from "@/runtime/persistence/drafts"
|
||||
import { isAttachment } from "./prompt-parts"
|
||||
import type { ModelSelection } from "@/providers/models/selection"
|
||||
|
||||
const submitting = new WeakSet<object>()
|
||||
@@ -34,7 +35,6 @@ type ComposerSubmitInput = {
|
||||
resetHistory: () => void
|
||||
setMode: (mode: "normal" | "shell") => void
|
||||
closePopover: () => void
|
||||
destination: () => AttachmentDestination
|
||||
delivery?: (alternate: boolean) => ComposerDelivery
|
||||
notify: {
|
||||
missingSelection: () => void
|
||||
@@ -59,12 +59,22 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
selection: item.selection ? { ...item.selection } : undefined,
|
||||
})),
|
||||
})
|
||||
const value = readSubmission(input, submission.prompt, submission.context, options?.alternate ?? false)
|
||||
if (!value) {
|
||||
const read = readSubmission(input, submission.prompt, submission.context, options?.alternate ?? false)
|
||||
if (!read) {
|
||||
if (input.adapter.working() && input.adapter.kind === "active-session") void input.adapter.interrupt()
|
||||
return
|
||||
}
|
||||
if (submitting.has(input.adapter.state)) return
|
||||
// Images restored from a draft or history carry ids only; the optimistic message shows their URLs.
|
||||
const value = {
|
||||
...read,
|
||||
images: await Promise.all(
|
||||
read.images.map(async (image) => ({
|
||||
...image,
|
||||
blob: { ...image.blob, url: (await resolveBlobUrl(image.blob)) ?? image.blob.url },
|
||||
})),
|
||||
),
|
||||
}
|
||||
submitting.add(input.adapter.state)
|
||||
const comments = input.comments.capture()
|
||||
// Capture command intent before starting a session in a worktree whose catalog has not loaded.
|
||||
@@ -87,16 +97,10 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
const optimisticBusy = !input.adapter.working()
|
||||
if (optimisticBusy && input.adapter.kind === "new-session")
|
||||
session.data.session.setStatus(session.id, "running")
|
||||
const sending = sendPrompt(
|
||||
session,
|
||||
value,
|
||||
input.destination(),
|
||||
input.adapter.controls().model.selection.trackSessionCommit,
|
||||
() => {
|
||||
if (optimisticBusy && input.adapter.kind === "active-session")
|
||||
session.data.session.setStatus(session.id, "running")
|
||||
},
|
||||
).then(
|
||||
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit, () => {
|
||||
if (optimisticBusy && input.adapter.kind === "active-session")
|
||||
session.data.session.setStatus(session.id, "running")
|
||||
}).then(
|
||||
() => ({ ok: true as const }),
|
||||
(error) => ({ ok: false as const, error }),
|
||||
)
|
||||
@@ -129,13 +133,9 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
|
||||
if (command) {
|
||||
clearSubmission(input, submission)
|
||||
void sendCommand(
|
||||
session,
|
||||
value,
|
||||
command,
|
||||
input.destination(),
|
||||
input.adapter.controls().model.selection.trackSessionCommit,
|
||||
).catch((error) => failSubmission(input, session, "command", error, restore, value.id))
|
||||
void sendCommand(session, value, command, input.adapter.controls().model.selection.trackSessionCommit).catch(
|
||||
(error) => failSubmission(input, session, "command", error, restore, value.id),
|
||||
)
|
||||
return
|
||||
}
|
||||
} finally {
|
||||
@@ -162,6 +162,9 @@ function handoffMessage(value: ComposerSubmission): SessionMessageUser {
|
||||
})),
|
||||
metadata: {
|
||||
displayText: value.text,
|
||||
attachments: value.prompt.flatMap((part) =>
|
||||
part.type === "path" ? [{ name: part.filename, mime: part.mime, path: part.path }] : [],
|
||||
),
|
||||
comments: value.context.flatMap((item) =>
|
||||
item.comment?.trim()
|
||||
? [
|
||||
@@ -196,7 +199,7 @@ function readSubmission(
|
||||
if (mode === "shell" && !text.trim()) return
|
||||
const images = prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
|
||||
const comments = context.filter((item) => !!item.comment?.trim()).length
|
||||
if (!text.trim() && images.length === 0 && comments === 0) return
|
||||
if (!text.trim() && !prompt.some(isAttachment) && comments === 0) return
|
||||
|
||||
const controls = input.adapter.controls()
|
||||
const model = controls.model.selection.current()
|
||||
@@ -304,10 +307,9 @@ async function sendCommand(
|
||||
session: ComposerSession,
|
||||
value: ComposerSubmission,
|
||||
command: { command: string; arguments: string },
|
||||
destination: AttachmentDestination,
|
||||
track?: ModelSelection["trackSessionCommit"],
|
||||
) {
|
||||
const request = await buildSubmissionRequest(session, value, destination)
|
||||
const request = await buildSubmissionRequest(session, value)
|
||||
// Like queued prompts, queued commands must not apply the composer's selection to active work.
|
||||
if (value.delivery === "steer") await applySelection(session, value.selection, track)
|
||||
await session.api.command({
|
||||
@@ -346,11 +348,10 @@ async function applySelection(
|
||||
async function sendPrompt(
|
||||
session: ComposerSession,
|
||||
value: ComposerSubmission,
|
||||
destination: AttachmentDestination,
|
||||
track: ModelSelection["trackSessionCommit"] | undefined,
|
||||
onAdmit: () => void,
|
||||
) {
|
||||
const request = await buildSubmissionRequest(session, value, destination)
|
||||
const request = await buildSubmissionRequest(session, value)
|
||||
// Switching agent or model reconfigures the session immediately, and with it
|
||||
// the remainder of a running turn. A steer targets that turn, so its
|
||||
// selection applies now; a queued follow-up must not reconfigure the turn it
|
||||
@@ -384,15 +385,14 @@ async function sendPrompt(
|
||||
await sending
|
||||
}
|
||||
|
||||
async function buildSubmissionRequest(
|
||||
session: ComposerSession,
|
||||
value: ComposerSubmission,
|
||||
destination: AttachmentDestination,
|
||||
) {
|
||||
async function buildSubmissionRequest(session: ComposerSession, value: ComposerSubmission) {
|
||||
const images = await Promise.all(
|
||||
value.images.map(async (attachment) => ({ ...attachment, dataUrl: await blobDataUrl(attachment.blob, attachment.mime) })),
|
||||
)
|
||||
return buildPromptRequest({
|
||||
prompt: value.prompt,
|
||||
context: value.context,
|
||||
attachments: await deliverAttachments(value.images, destination),
|
||||
images,
|
||||
text: value.text,
|
||||
sessionDirectory: session.directory,
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ComposerHistoryEntry, ComposerPersistedState, ComposerSuggestion } from "../types"
|
||||
import { isAttachment } from "../prompt-parts"
|
||||
|
||||
export type ComposerInteractionState = {
|
||||
mode: "normal" | "shell"
|
||||
@@ -236,7 +237,7 @@ function populated(persisted: ComposerPersistedState) {
|
||||
return (
|
||||
!!promptText(persisted).trim() ||
|
||||
persisted.context.items.length > 0 ||
|
||||
persisted.prompt.some((part) => part.type === "file" || part.type === "image")
|
||||
persisted.prompt.some((part) => part.type === "file" || isAttachment(part))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import type { AgentPart, ComposerStore, FileAttachmentPart, ImageAttachmentPart, Prompt, SkillPart } from "./state"
|
||||
import type {
|
||||
AgentPart,
|
||||
ComposerStore,
|
||||
FileAttachmentPart,
|
||||
ImageAttachmentPart,
|
||||
PathAttachmentPart,
|
||||
Prompt,
|
||||
SkillPart,
|
||||
} from "./state"
|
||||
|
||||
export type ComposerFilePart = FileAttachmentPart
|
||||
export type ComposerAgentPart = AgentPart
|
||||
export type ComposerSkillPart = SkillPart
|
||||
export type ComposerAttachment = ImageAttachmentPart
|
||||
export type ComposerAttachment = ImageAttachmentPart | PathAttachmentPart
|
||||
export type ComposerPrompt = Prompt
|
||||
export type ComposerComment = ComposerStore["context"]["items"][number]
|
||||
export type ComposerPersistedState = ComposerStore
|
||||
|
||||
@@ -48,7 +48,8 @@ export function createNewSessionComposerAdapter(props: {
|
||||
submitted: props.submitted,
|
||||
async start(selection, submission, message) {
|
||||
const draftID = props.draftID
|
||||
const projectDirectory = location().directory
|
||||
const currentDirectory = location().directory
|
||||
const projectDirectory = data.location.info({ directory: currentDirectory })?.project.canonical ?? currentDirectory
|
||||
const worktree = props.worktree()
|
||||
const branch = props.branch()
|
||||
const mcp = props.mcp.capture()
|
||||
|
||||
@@ -74,6 +74,7 @@ export const dict = {
|
||||
"command.category.workspace": "Worktree",
|
||||
"command.category.settings": "Settings",
|
||||
"command.logs.export": "Export logs",
|
||||
"command.debugBar.toggle": "Toggle debug bar",
|
||||
|
||||
"theme.scheme.system": "System",
|
||||
"theme.scheme.light": "Light",
|
||||
@@ -366,6 +367,9 @@ export const dict = {
|
||||
"prompt.action.stop": "Stop",
|
||||
|
||||
"prompt.toast.attachmentDuplicate.title": "This file has already been uploaded",
|
||||
"prompt.toast.uploading.percent": "{{percent}}%",
|
||||
"prompt.toast.uploading.cancel": "Cancel upload",
|
||||
"prompt.toast.uploadFailed.title": "Upload failed",
|
||||
"prompt.toast.modelAgentRequired.title": "Select an agent and model",
|
||||
"prompt.toast.modelAgentRequired.description": "Choose an agent and model before sending a prompt.",
|
||||
"prompt.toast.worktreeCreateFailed.title": "Failed to create worktree",
|
||||
@@ -963,7 +967,16 @@ export const dict = {
|
||||
"sidebar.empty.description": "Open a project to get started",
|
||||
|
||||
"debugBar.ariaLabel": "Development performance diagnostics",
|
||||
"debugBar.providerAriaLabel": "Provider performance diagnostics",
|
||||
"debugBar.na": "n/a",
|
||||
"debugBar.ttft.label": "TTFT",
|
||||
"debugBar.ttft.tip": "Time from provider request dispatch to the first model output.",
|
||||
"debugBar.ttfa.label": "TTFA",
|
||||
"debugBar.ttfa.tip": "Time from provider request dispatch to the first answer text.",
|
||||
"debugBar.tps.label": "TPS",
|
||||
"debugBar.tps.tip": "Output tokens per second after the first model output.",
|
||||
"debugBar.e2e.label": "E2E",
|
||||
"debugBar.e2e.tip": "Time from provider request dispatch until its response stream ended.",
|
||||
"debugBar.nav.label": "NAV",
|
||||
"debugBar.nav.tip":
|
||||
"Last completed route transition touching a session page, measured from router start until the first paint after it settles.",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createDraftStore, draftTextChunk, draftTextThreshold } from "./drafts"
|
||||
import { createDraftStore, draftTextChunk, draftTextThreshold, resolveBlobUrl } from "./drafts"
|
||||
|
||||
function memoryDriver() {
|
||||
const documents = new Map<string, string>()
|
||||
@@ -287,15 +287,34 @@ describe("draft store image retention", () => {
|
||||
expect(await released(store, 5, shared.url)).toBe(true)
|
||||
})
|
||||
|
||||
test("loading a document pins the images it references", async () => {
|
||||
test("loading a document pins the images it references without fetching their bytes", async () => {
|
||||
const { memory, store } = fresh()
|
||||
const reads: string[] = []
|
||||
const getBlob = memory.driver.getBlob
|
||||
memory.driver.getBlob = (id) => {
|
||||
reads.push(id)
|
||||
return getBlob(id)
|
||||
}
|
||||
const id = await memory.driver.putBlob(image(6))
|
||||
memory.documents.set("loaded", JSON.stringify({ prompt: [{ type: "image", blob: { id } }] }))
|
||||
const url = JSON.parse((await store.getItem("loaded"))!).prompt[0].blob.url
|
||||
const loaded = JSON.parse((await store.getItem("loaded"))!).prompt[0].blob
|
||||
expect(loaded).toEqual({ id })
|
||||
expect(reads).toEqual([])
|
||||
// The first consumer that shows or sends the image loads it; the pin from the load keeps it.
|
||||
const url = (await resolveBlobUrl(loaded))!
|
||||
expect(url.startsWith("blob:")).toBe(true)
|
||||
expect(reads).toEqual([id])
|
||||
expect(await resolveBlobUrl(loaded)).toBe(url)
|
||||
expect(reads).toEqual([id])
|
||||
await tick()
|
||||
expect(await released(store, 6, url)).toBe(false)
|
||||
await store.removeItem("loaded")
|
||||
await tick()
|
||||
expect(await released(store, 6, url)).toBe(true)
|
||||
})
|
||||
|
||||
test("a reference to bytes the store no longer holds resolves to nothing", async () => {
|
||||
fresh()
|
||||
expect(await resolveBlobUrl({ id: "gone" })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -42,6 +42,18 @@ const refs = new Map<string, Set<string>>()
|
||||
// Image ids that were restored under a different id (a store without WebCrypto assigns fresh
|
||||
// ones); live references still carry the original.
|
||||
const aliases = new Map<string, string>()
|
||||
// Fetches image bytes from the store created last. Documents load without their bytes; a consumer
|
||||
// that renders or sends an image resolves its URL through here, so a history full of large
|
||||
// attachments costs nothing at startup.
|
||||
let loader: ((id: string) => Promise<string | undefined>) | undefined
|
||||
|
||||
/** The object URL for an image reference, loading its bytes from the draft store on first use. */
|
||||
export function resolveBlobUrl(blob: { id: string; url?: string }) {
|
||||
if (blob.url) return Promise.resolve(blob.url)
|
||||
const existing = retained.get(aliases.get(blob.id) ?? blob.id)
|
||||
if (existing) return Promise.resolve(existing.url)
|
||||
return loader?.(blob.id) ?? Promise.resolve(undefined)
|
||||
}
|
||||
|
||||
function blobUrl(id: string, blob: Blob, grace?: number) {
|
||||
const existing = retained.get(id)
|
||||
@@ -120,7 +132,7 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
|
||||
const loading = new Map<string, Promise<string | undefined>>()
|
||||
const loadBlobUrl = (id: string) => {
|
||||
const existing = retained.get(id)
|
||||
if (existing) return existing.url
|
||||
if (existing) return Promise.resolve(existing.url)
|
||||
const pending = loading.get(id)
|
||||
if (pending) return pending
|
||||
const next = driver
|
||||
@@ -130,6 +142,7 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
|
||||
loading.set(id, next)
|
||||
return next
|
||||
}
|
||||
loader = loadBlobUrl
|
||||
const putBlob = async (blob: Blob) => {
|
||||
const id = await driver.putBlob(blob)
|
||||
return { id, url: blobUrl(id, blob, grace) }
|
||||
@@ -221,9 +234,11 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
|
||||
if (ref.kind === "text" && Array.isArray(ref.ids)) {
|
||||
return (await Promise.all(ref.ids.map((id) => loadChunk(String(id))))).join("")
|
||||
}
|
||||
// Bytes stay in the store until something renders or sends the image (see resolveBlobUrl);
|
||||
// only an image already pinned in this page gets its URL back immediately.
|
||||
if (typeof ref.id === "string") {
|
||||
const url = await loadBlobUrl(ref.id)
|
||||
if (url) return { ...item, blob: { id: ref.id, url } }
|
||||
const url = retained.get(aliases.get(ref.id) ?? ref.id)?.url
|
||||
return { ...item, blob: url ? { id: ref.id, url } : { id: ref.id } }
|
||||
}
|
||||
}
|
||||
return Object.fromEntries(
|
||||
@@ -421,17 +436,11 @@ function referenced(json: string) {
|
||||
return ids
|
||||
}
|
||||
|
||||
async function blobData(blob: BlobReference) {
|
||||
const kept = retained.get(aliases.get(blob.id) ?? blob.id)
|
||||
return kept ? kept.blob : await fetch(blob.url).then((response) => response.blob())
|
||||
}
|
||||
|
||||
export async function blobBytes(blob: BlobReference) {
|
||||
return new Uint8Array(await (await blobData(blob)).arrayBuffer())
|
||||
}
|
||||
|
||||
export async function blobDataUrl(blob: BlobReference, mime: string) {
|
||||
const data = await blobData(blob)
|
||||
const kept = retained.get(aliases.get(blob.id) ?? blob.id)
|
||||
const url = kept ? undefined : await resolveBlobUrl(blob)
|
||||
if (!kept && !url) throw new Error(`Attachment ${blob.id} has no stored bytes`)
|
||||
const data = kept ? kept.blob : await fetch(url!).then((response) => response.blob())
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.addEventListener("error", () => reject(reader.error))
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createResource } from "solid-js"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
|
||||
export function createCameraAvailability() {
|
||||
const platform = usePlatform()
|
||||
const supported = platform.platform === "web" && window.isSecureContext && !!navigator.mediaDevices?.getUserMedia
|
||||
const [available, actions] = createResource(
|
||||
async () => {
|
||||
if (!supported || !navigator.mediaDevices.enumerateDevices) return false
|
||||
const denied = await navigator.permissions?.query({ name: "camera" }).then(
|
||||
(permission) => permission.state === "denied",
|
||||
() => false,
|
||||
)
|
||||
if (denied) return false
|
||||
return navigator.mediaDevices.enumerateDevices().then(
|
||||
(devices) => devices.some((device) => device.kind === "videoinput"),
|
||||
() => false,
|
||||
)
|
||||
},
|
||||
{ initialValue: false },
|
||||
)
|
||||
return { supported, available, refetch: actions.refetch }
|
||||
}
|
||||
@@ -4,7 +4,17 @@ import { Divider } from "@opencode/ui/divider"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { type Component, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import {
|
||||
type Component,
|
||||
Show,
|
||||
Suspense,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
lazy,
|
||||
onCleanup,
|
||||
onMount,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import {
|
||||
createServerHealthPreview,
|
||||
@@ -18,8 +28,12 @@ import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { useCheckServerHealth } from "@/runtime/server/health"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { isMixedContent } from "./browser"
|
||||
import { createCameraAvailability } from "./camera"
|
||||
import { decodePairingCode } from "./pairing"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
const PairingScanner = lazy(() => import("./scanner").then((module) => ({ default: module.PairingScanner })))
|
||||
|
||||
type FormMode = "list" | "add" | "edit"
|
||||
|
||||
export const DialogServer: Component<{
|
||||
@@ -29,6 +43,8 @@ export const DialogServer: Component<{
|
||||
}> = (props) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const camera = createCameraAvailability()
|
||||
const form = createFormController({
|
||||
onSelect: (server) => {
|
||||
props.onSave?.(server)
|
||||
@@ -75,64 +91,112 @@ export const DialogServer: Component<{
|
||||
</DialogHeader>
|
||||
<Divider />
|
||||
<DialogBody class="flex w-full min-w-0 flex-1 flex-col px-4 pt-4 pb-2">
|
||||
<div class="flex w-full min-w-0 flex-col gap-6">
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<label class="settings-server-dialog-label">{language.t("dialog.server.add.url")}</label>
|
||||
<TextInput
|
||||
type="text"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={form.state.value()}
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
invalid={!!form.state.error()}
|
||||
disabled={form.state.busy()}
|
||||
autofocus
|
||||
aria-describedby={form.state.error() ? "dialog-server-error" : undefined}
|
||||
onInput={(event) => form.change.value(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
<Show when={form.state.error()}>
|
||||
<span id="dialog-server-error" class="settings-server-dialog-error" role="alert">
|
||||
{form.state.error()}
|
||||
</span>
|
||||
<Show
|
||||
when={!form.state.scanning()}
|
||||
fallback={
|
||||
<Suspense fallback={<p role="status">{language.t("server.connect.camera.starting")}</p>}>
|
||||
<PairingScanner
|
||||
onCancel={() => {
|
||||
form.scan.stop()
|
||||
void camera.refetch()
|
||||
}}
|
||||
onScan={form.scan.complete}
|
||||
/>
|
||||
</Suspense>
|
||||
}
|
||||
>
|
||||
<div class="flex w-full min-w-0 flex-col gap-6">
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<label class="settings-server-dialog-label">{language.t("dialog.server.add.url")}</label>
|
||||
<TextInput
|
||||
type="text"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={form.state.value()}
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
invalid={!!form.state.error()}
|
||||
disabled={form.state.busy()}
|
||||
autofocus
|
||||
list="dialog-server-addresses"
|
||||
aria-describedby={form.state.error() ? "dialog-server-error" : undefined}
|
||||
onInput={(event) => form.change.value(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
<datalist id="dialog-server-addresses">
|
||||
{form.state.urls().map((url) => (
|
||||
<option value={url} />
|
||||
))}
|
||||
</datalist>
|
||||
<Show when={form.state.error()}>
|
||||
<span id="dialog-server-error" class="settings-server-dialog-error" role="alert">
|
||||
{form.state.error()}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<label class="settings-server-dialog-label">{language.t("dialog.server.add.name")}</label>
|
||||
<TextInput
|
||||
type="text"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={form.state.name()}
|
||||
placeholder={language.t("dialog.server.add.namePlaceholder")}
|
||||
disabled={form.state.busy()}
|
||||
onInput={(event) => form.change.name(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<label class="settings-server-dialog-label">{language.t("dialog.server.add.password")}</label>
|
||||
<TextInput
|
||||
type="password"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={form.state.password()}
|
||||
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
|
||||
disabled={form.state.busy()}
|
||||
onInput={(event) => form.change.password(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
<Show when={props.mode === "add" && platform.platform === "web"}>
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<Button
|
||||
variant="neutral"
|
||||
size="large"
|
||||
class="!w-full self-stretch"
|
||||
disabled={form.state.busy() || !camera.available.latest}
|
||||
aria-describedby={
|
||||
!camera.available.latest && !camera.available.loading
|
||||
? "dialog-server-camera-unavailable"
|
||||
: undefined
|
||||
}
|
||||
onClick={form.scan.start}
|
||||
>
|
||||
{language.t("server.connect.scan")}
|
||||
</Button>
|
||||
<Show when={!camera.available.latest && !camera.available.loading}>
|
||||
<span id="dialog-server-camera-unavailable" class="settings-server-dialog-hint">
|
||||
{language.t(
|
||||
window.isSecureContext ? "server.connect.camera.unavailable" : "server.connect.camera.insecure",
|
||||
)}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<label class="settings-server-dialog-label">{language.t("dialog.server.add.name")}</label>
|
||||
<TextInput
|
||||
type="text"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={form.state.name()}
|
||||
placeholder={language.t("dialog.server.add.namePlaceholder")}
|
||||
disabled={form.state.busy()}
|
||||
onInput={(event) => form.change.name(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<label class="settings-server-dialog-label">{language.t("dialog.server.add.password")}</label>
|
||||
<TextInput
|
||||
type="password"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={form.state.password()}
|
||||
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
|
||||
disabled={form.state.busy()}
|
||||
onInput={(event) => form.change.password(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="neutral" disabled={form.state.busy()} onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="contrast" disabled={form.state.busy()} onClick={form.submit}>
|
||||
{submitLabel()}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
<Show when={!form.state.scanning()}>
|
||||
<DialogFooter>
|
||||
<Button variant="neutral" disabled={form.state.busy()} onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="contrast" disabled={form.state.busy()} onClick={form.submit}>
|
||||
{submitLabel()}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Show>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -149,6 +213,8 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
|
||||
mode: "list" as FormMode,
|
||||
originalUrl: undefined as string | undefined,
|
||||
values: { url: "", name: "", password: "" },
|
||||
urls: [] as string[],
|
||||
scanning: false,
|
||||
error: "",
|
||||
status: undefined as boolean | undefined,
|
||||
})
|
||||
@@ -161,6 +227,8 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
|
||||
mode: "list",
|
||||
originalUrl: undefined,
|
||||
values: { url: "", name: "", password: "" },
|
||||
urls: [],
|
||||
scanning: false,
|
||||
error: "",
|
||||
status: undefined,
|
||||
})
|
||||
@@ -265,6 +333,16 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
|
||||
setStore("error", "")
|
||||
request.mutate()
|
||||
}
|
||||
const pair = (pairing: NonNullable<ReturnType<typeof decodePairingCode>>) => {
|
||||
healthPreview.cancel()
|
||||
setStore({
|
||||
values: { ...store.values, url: pairing.urls[0], password: pairing.password },
|
||||
urls: pairing.urls,
|
||||
scanning: false,
|
||||
error: "",
|
||||
})
|
||||
request.mutate()
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (store.mode !== "edit") return
|
||||
@@ -281,6 +359,8 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
|
||||
value: () => store.values.url,
|
||||
name: () => store.values.name,
|
||||
password: () => store.values.password,
|
||||
urls: () => store.urls,
|
||||
scanning: () => store.scanning,
|
||||
error: () => store.error,
|
||||
status: () => store.status,
|
||||
},
|
||||
@@ -289,6 +369,11 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
|
||||
name: (value: string) => change("name", value),
|
||||
password: (value: string) => change("password", value),
|
||||
},
|
||||
scan: {
|
||||
start: () => setStore("scanning", true),
|
||||
stop: () => setStore("scanning", false),
|
||||
complete: pair,
|
||||
},
|
||||
start: { add: startAdd, edit: startEdit },
|
||||
reset,
|
||||
submit,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
.server-connect-scanner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-base);
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 44px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.server-connect-error {
|
||||
color: var(--v2-state-fg-danger);
|
||||
}
|
||||
|
||||
.server-connect-video {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
background: var(--v2-background-bg-deep);
|
||||
}
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.server-connect-video [role="status"] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { createStore } from "solid-js/store"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { decodePairingCode } from "./pairing"
|
||||
import "./scanner.css"
|
||||
|
||||
export function PairingScanner(props: {
|
||||
onScan: (value: NonNullable<ReturnType<typeof decodePairingCode>>) => void
|
||||
|
||||
@@ -41,8 +41,7 @@
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
form,
|
||||
.server-connect-scanner {
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
@@ -95,25 +94,4 @@
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.server-connect-video {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
background: var(--v2-background-bg-deep);
|
||||
}
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.server-connect-video [role="status"] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createResource, lazy, Show, Suspense } from "solid-js"
|
||||
import { lazy, Show, Suspense } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
@@ -10,6 +10,7 @@ import { useCheckServerHealth } from "@/runtime/server/health"
|
||||
import { useServers } from "@/runtime/server/registry"
|
||||
import { serverAddress } from "./pairing"
|
||||
import { isMixedContent } from "./browser"
|
||||
import { createCameraAvailability } from "./camera"
|
||||
import "./screen.css"
|
||||
|
||||
const PairingScanner = lazy(() => import("./scanner").then((module) => ({ default: module.PairingScanner })))
|
||||
@@ -19,23 +20,7 @@ export function ConnectServerScreen() {
|
||||
const platform = usePlatform()
|
||||
const servers = useServers()
|
||||
const check = useCheckServerHealth()
|
||||
const cameraSupported =
|
||||
platform.platform === "web" && window.isSecureContext && !!navigator.mediaDevices?.getUserMedia
|
||||
const [camera, cameraActions] = createResource(
|
||||
async () => {
|
||||
if (!cameraSupported || !navigator.mediaDevices.enumerateDevices) return false
|
||||
const denied = await navigator.permissions?.query({ name: "camera" }).then(
|
||||
(permission) => permission.state === "denied",
|
||||
() => false,
|
||||
)
|
||||
if (denied) return false
|
||||
return navigator.mediaDevices.enumerateDevices().then(
|
||||
(devices) => devices.some((device) => device.kind === "videoinput"),
|
||||
() => false,
|
||||
)
|
||||
},
|
||||
{ initialValue: false },
|
||||
)
|
||||
const camera = createCameraAvailability()
|
||||
const [state, setState] = createStore({ url: "", password: "", urls: [] as string[], error: "", scanning: false })
|
||||
const connectionError = () =>
|
||||
language.t(
|
||||
@@ -78,7 +63,7 @@ export function ConnectServerScreen() {
|
||||
<PairingScanner
|
||||
onCancel={() => {
|
||||
setState("scanning", false)
|
||||
void cameraActions.refetch()
|
||||
void camera.refetch()
|
||||
}}
|
||||
onScan={(pairing) => {
|
||||
setState({
|
||||
@@ -154,13 +139,15 @@ export function ConnectServerScreen() {
|
||||
<Button
|
||||
variant="neutral"
|
||||
size="large"
|
||||
disabled={request.isPending || !camera.latest}
|
||||
aria-describedby={!camera.latest && !camera.loading ? "server-connect-camera-unavailable" : undefined}
|
||||
disabled={request.isPending || !camera.available.latest}
|
||||
aria-describedby={
|
||||
!camera.available.latest && !camera.available.loading ? "server-connect-camera-unavailable" : undefined
|
||||
}
|
||||
onClick={() => setState("scanning", true)}
|
||||
>
|
||||
{language.t("server.connect.scan")}
|
||||
</Button>
|
||||
<Show when={!camera.latest && !camera.loading}>
|
||||
<Show when={!camera.available.latest && !camera.available.loading}>
|
||||
<p id="server-connect-camera-unavailable">
|
||||
{language.t(
|
||||
window.isSecureContext ? "server.connect.camera.unavailable" : "server.connect.camera.insecure",
|
||||
|
||||
@@ -2,7 +2,6 @@ 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 { useAttachmentDestination } from "@/composer/attachments/deliver"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { createActiveComposerAdapter } from "./adapter"
|
||||
import { createSessionQueue } from "./queue"
|
||||
@@ -30,7 +29,6 @@ export function createSessionComposerController(input: {
|
||||
draft: adapter.state,
|
||||
working: adapter.working,
|
||||
behavior: settings.general.followUpBehavior,
|
||||
destination: useAttachmentDestination(input.controls),
|
||||
restoreFocus: (cursor) => {
|
||||
const target = editor
|
||||
if (!target) return
|
||||
|
||||
@@ -5,11 +5,11 @@ import type { SessionInboxInfo } from "@opencode/client/promise"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import type { ComposerDelivery } from "@/composer/adapter"
|
||||
import type { ComposerStateTarget } from "@/composer/submission-state"
|
||||
import type { ImageAttachmentPart, Prompt } from "@/composer/state"
|
||||
import { clonePrompt, promptLength } from "@/composer/prompt-parts"
|
||||
import type { ImageAttachmentPart, PathAttachmentPart, Prompt } from "@/composer/state"
|
||||
import { clonePrompt, isAttachment, promptLength } from "@/composer/prompt-parts"
|
||||
import { buildPromptRequest } from "@/composer/request"
|
||||
import { deliverAttachments, type AttachmentDestination } from "@/composer/attachments/deliver"
|
||||
import { createLegacyBlobReference } from "@/runtime/persistence/drafts"
|
||||
import { blobDataUrl, createLegacyBlobReference } from "@/runtime/persistence/drafts"
|
||||
import { readPromptPresentation } from "@/composer/comment-note"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
@@ -30,7 +30,6 @@ export function createSessionQueue(input: {
|
||||
draft: ComposerStateTarget
|
||||
working: Accessor<boolean>
|
||||
behavior: Accessor<ComposerDelivery>
|
||||
destination: () => AttachmentDestination
|
||||
restoreFocus: (cursor: number) => void
|
||||
}) {
|
||||
const data = useData()
|
||||
@@ -61,7 +60,6 @@ export function createSessionQueue(input: {
|
||||
change.item,
|
||||
change.prompt,
|
||||
change.text,
|
||||
input.destination(),
|
||||
)
|
||||
// Admit before cancelling so a failed replacement never discards the original.
|
||||
const admitted = await data.session.prompt({
|
||||
@@ -187,15 +185,15 @@ export function createSessionQueue(input: {
|
||||
if (!editing || mutation.isPending) return
|
||||
const prompt = clonePrompt(input.draft.current())
|
||||
const text = prompt.map((part) => ("content" in part ? part.content : "")).join("")
|
||||
const images = prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
|
||||
if (!text.trim() && !images.length) return cancelEdit()
|
||||
const attachments = prompt.filter(isAttachment)
|
||||
if (!text.trim() && !attachments.length) return cancelEdit()
|
||||
const item = queued().find((entry) => entry.id === editing.id)
|
||||
const original = item ? queuedPromptAttachments(item) : []
|
||||
const pristine =
|
||||
item &&
|
||||
text.trim() === queuedPromptText(item) &&
|
||||
images.length === original.length &&
|
||||
images.every((image, index) => image.id === original[index].id)
|
||||
attachments.length === original.length &&
|
||||
attachments.every((attachment, index) => attachment.id === original[index].id)
|
||||
if (pristine && delivery === "queue") return cancelEdit()
|
||||
mutation.mutate({
|
||||
type: "edit",
|
||||
@@ -251,7 +249,7 @@ export function queuedPromptRows(items: QueuedPrompt[], replacement?: { original
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
text: queuedPromptText(item),
|
||||
attachments: item.payload.files?.length ?? 0,
|
||||
attachments: (item.payload.files?.length ?? 0) + (readPromptPresentation(item.payload.metadata)?.attachments.length ?? 0),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -261,18 +259,32 @@ export function queuedPromptText(item: QueuedPrompt) {
|
||||
}
|
||||
|
||||
// Inline attachments are the files the composer added itself, so they return
|
||||
// to it as image parts that an edit can remove or extend. Mentions and
|
||||
// `file://` context stay in the payload; see editedPromptInput.
|
||||
export function queuedPromptAttachments(item: QueuedPrompt): ImageAttachmentPart[] {
|
||||
return (item.payload.files ?? [])
|
||||
.filter((file) => isComposerAttachment(file))
|
||||
.map((file, index) => ({
|
||||
type: "image",
|
||||
id: `${item.id}:file:${index}`,
|
||||
filename: file.name ?? "attachment",
|
||||
mime: file.mime,
|
||||
blob: createLegacyBlobReference(`data:${file.mime};base64,${file.data}`),
|
||||
}))
|
||||
// to it as image parts that an edit can remove or extend, and path references
|
||||
// return as path parts. Mentions and `file://` context stay in the payload; see
|
||||
// editedPromptInput.
|
||||
export function queuedPromptAttachments(item: QueuedPrompt): (ImageAttachmentPart | PathAttachmentPart)[] {
|
||||
return [
|
||||
...(item.payload.files ?? [])
|
||||
.filter((file) => isComposerAttachment(file))
|
||||
.map(
|
||||
(file, index): ImageAttachmentPart => ({
|
||||
type: "image",
|
||||
id: `${item.id}:file:${index}`,
|
||||
filename: file.name ?? "attachment",
|
||||
mime: file.mime,
|
||||
blob: createLegacyBlobReference(`data:${file.mime};base64,${file.data}`),
|
||||
}),
|
||||
),
|
||||
...(readPromptPresentation(item.payload.metadata)?.attachments ?? []).map(
|
||||
(file, index): PathAttachmentPart => ({
|
||||
type: "path",
|
||||
id: `${item.id}:path:${index}`,
|
||||
filename: file.name,
|
||||
mime: file.mime,
|
||||
path: file.path,
|
||||
}),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
function isComposerAttachment(file: NonNullable<QueuedPrompt["payload"]["files"]>[number]) {
|
||||
@@ -292,13 +304,13 @@ async function editedPromptInput(
|
||||
item: QueuedPrompt | undefined,
|
||||
prompt: Prompt,
|
||||
text: string,
|
||||
destination: AttachmentDestination,
|
||||
) {
|
||||
const attachments = await deliverAttachments(
|
||||
prompt.filter((part): part is ImageAttachmentPart => part.type === "image"),
|
||||
destination,
|
||||
const images = await Promise.all(
|
||||
prompt
|
||||
.filter((part): part is ImageAttachmentPart => part.type === "image")
|
||||
.map(async (part) => ({ ...part, dataUrl: await blobDataUrl(part.blob, part.mime) })),
|
||||
)
|
||||
const request = buildPromptRequest({ prompt, context: [], attachments, text, sessionDirectory: directory })
|
||||
const request = buildPromptRequest({ prompt, context: [], images, text, sessionDirectory: directory })
|
||||
const payload = item?.payload
|
||||
const display = item ? queuedPromptText(item) : ""
|
||||
const notes = payload && display && payload.text.startsWith(display) ? payload.text.slice(display.length) : ""
|
||||
|
||||
@@ -1517,6 +1517,12 @@
|
||||
color: var(--v2-state-fg-danger);
|
||||
}
|
||||
|
||||
.settings-server-dialog-hint {
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-base);
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
.settings-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
|
||||
width: min(280px, 100%);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { useIsRouting, useLocation } from "@solidjs/router"
|
||||
import { batch, createEffect, onCleanup, onMount, Show } from "solid-js"
|
||||
import { useIsRouting, useLocation, useParams } from "@solidjs/router"
|
||||
import { batch, createEffect, createMemo, on, onCleanup, onMount, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import {
|
||||
applyProviderMetricEvent,
|
||||
isProviderMetricEvent,
|
||||
projectedProviderMetrics,
|
||||
type ProviderMetrics,
|
||||
type ProviderMetricState,
|
||||
} from "./provider-metrics"
|
||||
|
||||
type Mem = Performance & {
|
||||
memory?: {
|
||||
@@ -39,12 +49,23 @@ const time = (n?: number) => {
|
||||
return `${Math.round(n)}`
|
||||
}
|
||||
|
||||
const fixed = (n?: number, digits = 0) => {
|
||||
if (n === undefined || Number.isNaN(n)) return
|
||||
return n.toFixed(digits)
|
||||
}
|
||||
|
||||
const mb = (n?: number) => {
|
||||
if (n === undefined || Number.isNaN(n)) return
|
||||
const v = n / 1024 / 1024
|
||||
return `${v >= 1024 ? v.toFixed(0) : v.toFixed(1)}MB`
|
||||
}
|
||||
|
||||
const duration = (n?: number) => {
|
||||
if (n === undefined || Number.isNaN(n)) return
|
||||
if (n < 1_000) return `${Math.round(n)}ms`
|
||||
return `${(n / 1_000).toFixed(n < 10_000 ? 1 : 0)}s`
|
||||
}
|
||||
|
||||
const bad = (n: number | undefined, limit: number, low = false) => {
|
||||
if (n === undefined || Number.isNaN(n)) return false
|
||||
return low ? n < limit : n > limit
|
||||
@@ -80,6 +101,7 @@ function Cell(props: {
|
||||
}}
|
||||
>
|
||||
<div
|
||||
dir="ltr"
|
||||
classList={{
|
||||
"text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
|
||||
}}
|
||||
@@ -87,6 +109,7 @@ function Cell(props: {
|
||||
{props.label}
|
||||
</div>
|
||||
<div
|
||||
dir="ltr"
|
||||
classList={{
|
||||
"uppercase font-bold tabular-nums": true,
|
||||
"text-[11px] leading-text-tight": !!props.inline,
|
||||
@@ -136,8 +159,12 @@ function ToggleCell(props: {
|
||||
"flex-col items-center": !props.inline,
|
||||
}}
|
||||
>
|
||||
<span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">{props.label}</span>
|
||||
<span class="text-[11px] leading-none font-bold">{props.value}</span>
|
||||
<span dir="ltr" class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">
|
||||
{props.label}
|
||||
</span>
|
||||
<span dir="ltr" class="text-[11px] leading-none font-bold">
|
||||
{props.value}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
@@ -149,9 +176,11 @@ function ToggleCell(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export function DebugBar(props: { inline?: boolean } = {}) {
|
||||
export function DebugBar(props: { diagnostics?: boolean; inline?: boolean } = {}) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const global = useGlobal()
|
||||
const params = useParams<{ serverKey?: string; id?: string }>()
|
||||
const location = useLocation()
|
||||
const routing = useIsRouting()
|
||||
const [state, setState] = createStore({
|
||||
@@ -175,8 +204,55 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
||||
dur: undefined as number | undefined,
|
||||
pending: false,
|
||||
},
|
||||
live: undefined as ProviderMetrics | undefined,
|
||||
})
|
||||
|
||||
const target = createMemo(
|
||||
() => {
|
||||
if (!params.serverKey || !params.id) return
|
||||
const connection = global.servers
|
||||
.list()
|
||||
.find((item) => base64Encode(ServerConnection.key(item)) === params.serverKey)
|
||||
if (!connection) return
|
||||
return { ctx: global.ensureServerCtx(connection), id: params.id }
|
||||
},
|
||||
undefined,
|
||||
{ equals: (a, b) => a?.ctx === b?.ctx && a?.id === b?.id },
|
||||
)
|
||||
// History comes from the already-loaded message projection; live requests refine it in place.
|
||||
const projected = createMemo(() => {
|
||||
const current = target()
|
||||
if (!current) return
|
||||
return projectedProviderMetrics(current.ctx.data.session.message.list(current.id))
|
||||
})
|
||||
const metrics = () => state.live ?? projected()
|
||||
|
||||
// Missed events during an outage are never replayed; the refreshed projection must win.
|
||||
createEffect(
|
||||
on(
|
||||
() => target()?.ctx.sdk.connection.status(),
|
||||
(status) => {
|
||||
if (status !== "connected") setState("live", undefined)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(target, (current) => {
|
||||
setState("live", undefined)
|
||||
if (!current) return
|
||||
const accumulator: ProviderMetricState = {}
|
||||
onCleanup(
|
||||
current.ctx.sdk.event.listen((event) => {
|
||||
if (!isProviderMetricEvent(event) || event.data.sessionID !== current.id) return
|
||||
applyProviderMetricEvent(accumulator, event)
|
||||
if (accumulator.latest) setState("live", accumulator.latest)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const na = () => language.t("debugBar.na").toUpperCase()
|
||||
const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined)
|
||||
const heapv = () => {
|
||||
@@ -204,6 +280,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
||||
let two = 0
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.diagnostics) return
|
||||
const busy = routing()
|
||||
const next = `${location.pathname}${location.search}`
|
||||
|
||||
@@ -248,6 +325,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
if (!props.diagnostics) return
|
||||
const obs: PerformanceObserver[] = []
|
||||
const fps: Array<{ at: number; dur: number }> = []
|
||||
const long: Array<{ at: number; dur: number }> = []
|
||||
@@ -448,7 +526,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label={language.t("debugBar.ariaLabel")}
|
||||
aria-label={language.t(props.diagnostics ? "debugBar.ariaLabel" : "debugBar.providerAriaLabel")}
|
||||
classList={{
|
||||
"pointer-events-auto hidden overflow-hidden text-text-strong md:block": true,
|
||||
"mt-[-6px] w-full shrink-0 px-3 py-1": !!props.inline,
|
||||
@@ -467,102 +545,132 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
||||
}}
|
||||
>
|
||||
<Cell
|
||||
label={language.t("debugBar.nav.label")}
|
||||
tip={language.t("debugBar.nav.tip")}
|
||||
value={navv()}
|
||||
bad={bad(state.nav.dur, 400)}
|
||||
dim={state.nav.dur === undefined && !state.nav.pending}
|
||||
label={language.t("debugBar.tps.label")}
|
||||
tip={language.t("debugBar.tps.tip")}
|
||||
value={fixed(metrics()?.tps, 1) ?? na()}
|
||||
dim={metrics()?.tps === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.fps.label")}
|
||||
tip={language.t("debugBar.fps.tip")}
|
||||
value={state.fps === undefined ? na() : `${Math.round(state.fps)}`}
|
||||
bad={bad(state.fps, 50, true)}
|
||||
dim={state.fps === undefined}
|
||||
label={language.t("debugBar.ttft.label")}
|
||||
tip={language.t("debugBar.ttft.tip")}
|
||||
value={duration(metrics()?.ttft) ?? na()}
|
||||
dim={metrics()?.ttft === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.frame.label")}
|
||||
tip={language.t("debugBar.frame.tip")}
|
||||
value={time(state.gap) ?? na()}
|
||||
bad={bad(state.gap, 50)}
|
||||
dim={state.gap === undefined}
|
||||
label={language.t("debugBar.ttfa.label")}
|
||||
tip={language.t("debugBar.ttfa.tip")}
|
||||
value={duration(metrics()?.ttfa) ?? na()}
|
||||
dim={metrics()?.ttfa === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.jank.label")}
|
||||
tip={language.t("debugBar.jank.tip")}
|
||||
value={state.jank === undefined ? na() : `${state.jank}`}
|
||||
bad={bad(state.jank, 8)}
|
||||
dim={state.jank === undefined}
|
||||
label={language.t("debugBar.e2e.label")}
|
||||
tip={language.t("debugBar.e2e.tip")}
|
||||
value={duration(metrics()?.e2e) ?? na()}
|
||||
dim={metrics()?.e2e === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.long.label")}
|
||||
tip={language.t("debugBar.long.tip", { max: ms(state.long.max) ?? na() })}
|
||||
value={longv()}
|
||||
bad={bad(state.long.block, 200)}
|
||||
dim={state.long.count === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.delay.label")}
|
||||
tip={language.t("debugBar.delay.tip")}
|
||||
value={time(state.delay) ?? na()}
|
||||
bad={bad(state.delay, 100)}
|
||||
dim={state.delay === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.inp.label")}
|
||||
tip={language.t("debugBar.inp.tip")}
|
||||
value={time(state.inp) ?? na()}
|
||||
bad={bad(state.inp, 200)}
|
||||
dim={state.inp === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.cls.label")}
|
||||
tip={language.t("debugBar.cls.tip")}
|
||||
value={state.cls === undefined ? na() : state.cls.toFixed(2)}
|
||||
bad={bad(state.cls, 0.1)}
|
||||
dim={state.cls === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.mem.label")}
|
||||
tip={
|
||||
state.heap.used === undefined
|
||||
? language.t("debugBar.mem.tipUnavailable")
|
||||
: language.t("debugBar.mem.tip", {
|
||||
used: mb(state.heap.used) ?? na(),
|
||||
limit: mb(state.heap.limit) ?? na(),
|
||||
})
|
||||
}
|
||||
value={heapv()}
|
||||
bad={bad(heap(), 0.8)}
|
||||
dim={state.heap.used === undefined}
|
||||
inline={props.inline}
|
||||
span={platform.setForceFocus ? 2 : 3}
|
||||
/>
|
||||
<ToggleCell
|
||||
active={language.direction() === "rtl"}
|
||||
inline={props.inline}
|
||||
label={language.t("debugBar.direction.label")}
|
||||
tip={language.t("debugBar.direction.tip")}
|
||||
value={language.t(`debugBar.direction.${language.direction()}`)}
|
||||
onClick={() => language.setDirection(language.direction() === "rtl" ? "ltr" : "rtl")}
|
||||
/>
|
||||
<Show when={platform.setForceFocus}>
|
||||
<ToggleCell
|
||||
active={state.focus}
|
||||
<Show when={props.diagnostics}>
|
||||
<Cell
|
||||
label={language.t("debugBar.nav.label")}
|
||||
tip={language.t("debugBar.nav.tip")}
|
||||
value={navv()}
|
||||
bad={bad(state.nav.dur, 400)}
|
||||
dim={state.nav.dur === undefined && !state.nav.pending}
|
||||
inline={props.inline}
|
||||
label={language.t("debugBar.focus.label")}
|
||||
tip={language.t("debugBar.focus.tip")}
|
||||
value={language.t(state.focus ? "debugBar.focus.on" : "debugBar.focus.off")}
|
||||
onClick={() => void toggleFocus()}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.fps.label")}
|
||||
tip={language.t("debugBar.fps.tip")}
|
||||
value={state.fps === undefined ? na() : `${Math.round(state.fps)}`}
|
||||
bad={bad(state.fps, 50, true)}
|
||||
dim={state.fps === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.frame.label")}
|
||||
tip={language.t("debugBar.frame.tip")}
|
||||
value={time(state.gap) ?? na()}
|
||||
bad={bad(state.gap, 50)}
|
||||
dim={state.gap === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.jank.label")}
|
||||
tip={language.t("debugBar.jank.tip")}
|
||||
value={state.jank === undefined ? na() : `${state.jank}`}
|
||||
bad={bad(state.jank, 8)}
|
||||
dim={state.jank === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.long.label")}
|
||||
tip={language.t("debugBar.long.tip", { max: ms(state.long.max) ?? na() })}
|
||||
value={longv()}
|
||||
bad={bad(state.long.block, 200)}
|
||||
dim={state.long.count === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.delay.label")}
|
||||
tip={language.t("debugBar.delay.tip")}
|
||||
value={time(state.delay) ?? na()}
|
||||
bad={bad(state.delay, 100)}
|
||||
dim={state.delay === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.inp.label")}
|
||||
tip={language.t("debugBar.inp.tip")}
|
||||
value={time(state.inp) ?? na()}
|
||||
bad={bad(state.inp, 200)}
|
||||
dim={state.inp === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.cls.label")}
|
||||
tip={language.t("debugBar.cls.tip")}
|
||||
value={state.cls === undefined ? na() : state.cls.toFixed(2)}
|
||||
bad={bad(state.cls, 0.1)}
|
||||
dim={state.cls === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.mem.label")}
|
||||
tip={
|
||||
state.heap.used === undefined
|
||||
? language.t("debugBar.mem.tipUnavailable")
|
||||
: language.t("debugBar.mem.tip", {
|
||||
used: mb(state.heap.used) ?? na(),
|
||||
limit: mb(state.heap.limit) ?? na(),
|
||||
})
|
||||
}
|
||||
value={heapv()}
|
||||
bad={bad(heap(), 0.8)}
|
||||
dim={state.heap.used === undefined}
|
||||
inline={props.inline}
|
||||
span={platform.setForceFocus ? 2 : 3}
|
||||
/>
|
||||
<ToggleCell
|
||||
active={language.direction() === "rtl"}
|
||||
inline={props.inline}
|
||||
label={language.t("debugBar.direction.label")}
|
||||
tip={language.t("debugBar.direction.tip")}
|
||||
value={language.t(`debugBar.direction.${language.direction()}`)}
|
||||
onClick={() => language.setDirection(language.direction() === "rtl" ? "ltr" : "rtl")}
|
||||
/>
|
||||
<Show when={platform.setForceFocus}>
|
||||
<ToggleCell
|
||||
active={state.focus}
|
||||
inline={props.inline}
|
||||
label={language.t("debugBar.focus.label")}
|
||||
tip={language.t("debugBar.focus.tip")}
|
||||
value={language.t(state.focus ? "debugBar.focus.on" : "debugBar.focus.off")}
|
||||
onClick={() => void toggleFocus()}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode/client/promise"
|
||||
import type { ProviderMetricEvent } from "./provider-metrics"
|
||||
import { foldProviderMetrics, projectedProviderMetrics } from "./provider-metrics"
|
||||
|
||||
const durable = { aggregateID: "ses_test", seq: 0, version: 1 } as const
|
||||
|
||||
const events: ProviderMetricEvent[] = [
|
||||
{
|
||||
id: "evt_started",
|
||||
created: 1_000,
|
||||
type: "session.step.started",
|
||||
durable,
|
||||
data: {
|
||||
sessionID: "ses_test",
|
||||
assistantMessageID: "msg_assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
started: 1_000,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "evt_reasoning",
|
||||
created: 1_300,
|
||||
type: "session.reasoning.started",
|
||||
durable: { ...durable, seq: 1 },
|
||||
data: { sessionID: "ses_test", assistantMessageID: "msg_assistant", ordinal: 0 },
|
||||
},
|
||||
{
|
||||
id: "evt_text",
|
||||
created: 1_800,
|
||||
type: "session.text.started",
|
||||
durable: { ...durable, seq: 2 },
|
||||
data: { sessionID: "ses_test", assistantMessageID: "msg_assistant", ordinal: 0 },
|
||||
},
|
||||
{
|
||||
id: "evt_streamed",
|
||||
created: 3_800,
|
||||
type: "session.step.streamed",
|
||||
durable: { ...durable, seq: 3 },
|
||||
data: { sessionID: "ses_test", assistantMessageID: "msg_assistant" },
|
||||
},
|
||||
{
|
||||
id: "evt_ended",
|
||||
created: 4_000,
|
||||
type: "session.step.ended",
|
||||
durable: { ...durable, seq: 4 },
|
||||
data: {
|
||||
sessionID: "ses_test",
|
||||
assistantMessageID: "msg_assistant",
|
||||
finish: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 200, output: 100, reasoning: 20, cache: { read: 0, write: 0 } },
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
test("calculates provider response metrics from durable events", () => {
|
||||
expect(foldProviderMetrics(events)).toEqual({
|
||||
tps: 50,
|
||||
ttft: 300,
|
||||
ttfa: 800,
|
||||
e2e: 2_800,
|
||||
})
|
||||
})
|
||||
|
||||
test("ignores failed attempts without usage", () => {
|
||||
expect(
|
||||
foldProviderMetrics([
|
||||
...events.slice(0, 4),
|
||||
{
|
||||
id: "evt_failed",
|
||||
created: 4_000,
|
||||
type: "session.step.failed",
|
||||
durable: { ...durable, seq: 4 },
|
||||
data: {
|
||||
sessionID: "ses_test",
|
||||
assistantMessageID: "msg_assistant",
|
||||
error: { type: "aborted", message: "Step interrupted" },
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
test("keeps completed metrics while the next provider attempt runs", () => {
|
||||
expect(
|
||||
foldProviderMetrics([
|
||||
...events,
|
||||
{
|
||||
id: "evt_retry",
|
||||
created: 5_000,
|
||||
type: "session.step.started",
|
||||
durable: { ...durable, seq: 5 },
|
||||
data: {
|
||||
sessionID: "ses_test",
|
||||
assistantMessageID: "msg_assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
started: 5_000,
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toEqual(foldProviderMetrics(events))
|
||||
})
|
||||
|
||||
const assistant: SessionMessageAssistant = {
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [
|
||||
{ type: "reasoning", text: "Think", time: { created: 1_300, completed: 1_700 } },
|
||||
{ type: "text", text: "Answer" },
|
||||
],
|
||||
tokens: { input: 200, output: 100, reasoning: 20, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1_000, streamed: 3_800, completed: 4_000 },
|
||||
}
|
||||
|
||||
test("derives a baseline from the latest completed projected request", () => {
|
||||
const messages: SessionMessageInfo[] = [
|
||||
{ id: "msg_user", type: "user", text: "Hi", time: { created: 1 } },
|
||||
assistant,
|
||||
{ ...assistant, id: "msg_running", tokens: undefined, time: { created: 5_000 } },
|
||||
]
|
||||
// Reasoning ended at 1_700, so TPS spans 1_700 → 3_800 = 100 / 2.1s.
|
||||
expect(projectedProviderMetrics(messages)).toEqual({ tps: 100 / 2.1, ttft: 300, ttfa: 700, e2e: 2_800 })
|
||||
})
|
||||
|
||||
const tool = (created: number): SessionMessageAssistant["content"][number] => ({
|
||||
type: "tool",
|
||||
id: "call_1",
|
||||
name: "read",
|
||||
state: { status: "running", input: {}, metadata: {} },
|
||||
time: { created, ran: created + 100 },
|
||||
})
|
||||
|
||||
test("leaves text-first history unavailable until a live request", () => {
|
||||
const unavailable = { tps: undefined, ttft: undefined, ttfa: undefined, e2e: 2_800 }
|
||||
expect(projectedProviderMetrics([{ ...assistant, content: [{ type: "text", text: "Answer" }] }])).toEqual(unavailable)
|
||||
expect(
|
||||
projectedProviderMetrics([{ ...assistant, content: [{ type: "text", text: "Answer" }, tool(2_500)] }]),
|
||||
).toEqual(unavailable)
|
||||
})
|
||||
|
||||
test("uses the first tool call as first output for tool-first history", () => {
|
||||
expect(projectedProviderMetrics([{ ...assistant, content: [tool(1_800)] }])).toEqual({
|
||||
tps: 50,
|
||||
ttft: 800,
|
||||
ttfa: undefined,
|
||||
e2e: 2_800,
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import type {
|
||||
SessionLogItem,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageInfo,
|
||||
TokenUsageInfo,
|
||||
} from "@opencode/client/promise"
|
||||
|
||||
type ProviderMetricEventType =
|
||||
| "session.step.started"
|
||||
| "session.step.streamed"
|
||||
| "session.step.ended"
|
||||
| "session.step.failed"
|
||||
| "session.text.started"
|
||||
| "session.reasoning.started"
|
||||
| "session.tool.input.started"
|
||||
|
||||
const types: ReadonlySet<string> = new Set<ProviderMetricEventType>([
|
||||
"session.step.started",
|
||||
"session.step.streamed",
|
||||
"session.step.ended",
|
||||
"session.step.failed",
|
||||
"session.text.started",
|
||||
"session.reasoning.started",
|
||||
"session.tool.input.started",
|
||||
])
|
||||
|
||||
export type ProviderMetricEvent = Extract<SessionLogItem, { type: ProviderMetricEventType }>
|
||||
|
||||
export type ProviderMetrics = {
|
||||
tps?: number
|
||||
ttft?: number
|
||||
ttfa?: number
|
||||
e2e?: number
|
||||
}
|
||||
|
||||
type Attempt = {
|
||||
assistantMessageID: string
|
||||
started: number
|
||||
first?: number
|
||||
answer?: number
|
||||
streamed?: number
|
||||
tokens?: TokenUsageInfo
|
||||
}
|
||||
|
||||
export type ProviderMetricState = { attempt?: Attempt; latest?: ProviderMetrics }
|
||||
|
||||
export function isProviderMetricEvent(event: { type: string }): event is ProviderMetricEvent {
|
||||
return types.has(event.type)
|
||||
}
|
||||
|
||||
export function applyProviderMetricEvent(state: ProviderMetricState, event: ProviderMetricEvent) {
|
||||
if (event.type === "session.step.started") {
|
||||
state.attempt = {
|
||||
assistantMessageID: event.data.assistantMessageID,
|
||||
started: event.data.started,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!state.attempt || event.data.assistantMessageID !== state.attempt.assistantMessageID) return
|
||||
|
||||
if (
|
||||
event.type === "session.text.started" ||
|
||||
event.type === "session.reasoning.started" ||
|
||||
event.type === "session.tool.input.started"
|
||||
) {
|
||||
state.attempt.first ??= event.created
|
||||
if (event.type === "session.text.started") state.attempt.answer ??= event.created
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.step.streamed") {
|
||||
state.attempt.streamed = event.created
|
||||
return
|
||||
}
|
||||
|
||||
// Interrupted or failed attempts without usage would publish misleading partial numbers.
|
||||
if (!event.data.tokens || state.attempt.first === undefined || state.attempt.streamed === undefined) return
|
||||
state.attempt.tokens = event.data.tokens
|
||||
state.latest = attemptMetrics(state.attempt)
|
||||
}
|
||||
|
||||
export function foldProviderMetrics(events: readonly ProviderMetricEvent[]) {
|
||||
const state: ProviderMetricState = {}
|
||||
events.forEach((event) => applyProviderMetricEvent(state, event))
|
||||
return state.latest
|
||||
}
|
||||
|
||||
/**
|
||||
* Baseline from already-loaded history. Text parts carry no start timestamp yet, so TTFT, TTFA,
|
||||
* and TPS stay unavailable for text-first requests until a live request supplies them.
|
||||
*/
|
||||
export function projectedProviderMetrics(messages: readonly SessionMessageInfo[]): ProviderMetrics | undefined {
|
||||
const message = messages.findLast(
|
||||
(item): item is SessionMessageAssistant =>
|
||||
item.type === "assistant" && item.time.streamed !== undefined && item.tokens !== undefined,
|
||||
)
|
||||
if (!message) return
|
||||
// Content is chronological; only a non-text head carries the first-output time.
|
||||
const head = message.content[0]
|
||||
const first = head && head.type !== "text" ? head.time?.created : undefined
|
||||
// Reasoning ends when the answer starts, so a reasoning part right before the first text
|
||||
// approximates the live `session.text.started` timestamp.
|
||||
const text = message.content.findIndex((item) => item.type === "text")
|
||||
const before = text > 0 ? message.content[text - 1] : undefined
|
||||
const answer = first !== undefined && before?.type === "reasoning" ? before.time?.completed : undefined
|
||||
return attemptMetrics({
|
||||
assistantMessageID: message.id,
|
||||
started: message.time.created,
|
||||
first,
|
||||
answer,
|
||||
streamed: message.time.streamed,
|
||||
tokens: message.tokens,
|
||||
})
|
||||
}
|
||||
|
||||
function attemptMetrics(attempt: Attempt): ProviderMetrics {
|
||||
const ttft = elapsed(attempt.started, attempt.first)
|
||||
const ttfa = elapsed(attempt.started, attempt.answer)
|
||||
const e2e = elapsed(attempt.started, attempt.streamed)
|
||||
// Output tokens exclude reasoning, so measure them from the answer start when one exists.
|
||||
const generation = elapsed(attempt.answer ?? attempt.first, attempt.streamed)
|
||||
const output = attempt.tokens?.output
|
||||
return {
|
||||
tps: generation && output && generation > 0 && output > 0 ? output / (generation / 1_000) : undefined,
|
||||
ttft,
|
||||
ttfa,
|
||||
e2e,
|
||||
}
|
||||
}
|
||||
|
||||
function elapsed(start: number | undefined, end: number | undefined) {
|
||||
if (start === undefined || end === undefined) return
|
||||
return Math.max(0, end - start)
|
||||
}
|
||||
@@ -5,11 +5,14 @@ import { ResizeHandle } from "@opencode/ui/resize-handle"
|
||||
import { Titlebar, type TitlebarUpdate } from "@/shell/titlebar/titlebar"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { ToastRegion } from "@/shell/notifications/toast"
|
||||
import { UploadToastHost } from "@/composer/attachments/uploads"
|
||||
import { TitlebarRightProvider } from "@/shell/titlebar/right-slot"
|
||||
import { useSettingsSurface } from "@/settings/surface"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { SshAuthentication } from "@/servers/ssh/authentication"
|
||||
import { useUpdaterInstall } from "@/shell/updates/download"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
|
||||
const DebugBar = lazy(() => import("@/shell/debug/debug-bar").then((module) => ({ default: module.DebugBar })))
|
||||
|
||||
@@ -18,6 +21,8 @@ export default function Layout(props: ParentProps) {
|
||||
const settings = useSettingsSurface()
|
||||
const preferences = useSettings()
|
||||
const installUpdate = useUpdaterInstall()
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const mobile = createMediaQuery("(max-width: 767px)")
|
||||
const [state, setState] = createStore({
|
||||
debugTools: false,
|
||||
@@ -34,14 +39,21 @@ export default function Layout(props: ParentProps) {
|
||||
install: installUpdate,
|
||||
}
|
||||
// A plain object avoids the compiler's conditional-prop memo, which leaks when read from event handlers.
|
||||
const debugTools = import.meta.env.DEV
|
||||
? {
|
||||
get visible() {
|
||||
return state.debugTools
|
||||
},
|
||||
toggle: () => setState("debugTools", (value) => !value),
|
||||
}
|
||||
: undefined
|
||||
const debugTools = {
|
||||
get visible() {
|
||||
return state.debugTools
|
||||
},
|
||||
toggle: () => setState("debugTools", (value) => !value),
|
||||
}
|
||||
|
||||
command.register("debug-bar", () => [
|
||||
{
|
||||
id: "debugBar.toggle",
|
||||
title: language.t("command.debugBar.toggle"),
|
||||
category: language.t("command.category.view"),
|
||||
onSelect: debugTools.toggle,
|
||||
},
|
||||
])
|
||||
|
||||
return (
|
||||
<TitlebarRightProvider>
|
||||
@@ -105,12 +117,13 @@ export default function Layout(props: ParentProps) {
|
||||
</SshAuthentication>
|
||||
</main>
|
||||
</div>
|
||||
<Show when={import.meta.env.DEV && state.debugTools}>
|
||||
<Show when={state.debugTools}>
|
||||
<Suspense>
|
||||
<DebugBar inline />
|
||||
<DebugBar diagnostics={import.meta.env.DEV} inline />
|
||||
</Suspense>
|
||||
</Show>
|
||||
<ToastRegion />
|
||||
<UploadToastHost />
|
||||
</div>
|
||||
</TitlebarRightProvider>
|
||||
)
|
||||
|
||||
@@ -34,10 +34,15 @@ describe("Composer attachment ownership", () => {
|
||||
addPart: () => false,
|
||||
setDraggingType() {},
|
||||
directory: () => "C:/repo",
|
||||
destination: () => ({
|
||||
input: { image: true, pdf: true },
|
||||
local: false,
|
||||
upload: () => Promise.reject(new Error("native attachments must not upload")),
|
||||
}),
|
||||
isDialogActive: () => false,
|
||||
warn() {},
|
||||
duplicate() {},
|
||||
onError: rejectTest,
|
||||
onUploadError: rejectTest,
|
||||
store: () => stored.promise,
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { resolveObjectURL } from "node:buffer"
|
||||
import { createDraftStore } from "@/runtime/persistence/drafts"
|
||||
import { createDraftStore, resolveBlobUrl } from "@/runtime/persistence/drafts"
|
||||
|
||||
function fixture(id: string, getBlob: () => Promise<Blob | null>) {
|
||||
const documents = new Map([
|
||||
@@ -20,7 +20,19 @@ function fixture(id: string, getBlob: () => Promise<Blob | null>) {
|
||||
return { store, documents }
|
||||
}
|
||||
|
||||
test("deduplicates concurrent history and draft reads without invalidating either live reference", async () => {
|
||||
test("loading history and a draft reads no image bytes", async () => {
|
||||
let reads = 0
|
||||
const { store } = fixture("history-cache-lazy", async () => {
|
||||
reads++
|
||||
return new Blob(["shared screenshot"])
|
||||
})
|
||||
const [history, draft] = await Promise.all([store.getItem("history"), store.getItem("draft")])
|
||||
expect(JSON.parse(history!).entries[0].prompt[0].blob).toEqual({ id: "history-cache-lazy" })
|
||||
expect(JSON.parse(draft!).prompt[0].blob).toEqual({ id: "history-cache-lazy" })
|
||||
expect(reads).toBe(0)
|
||||
})
|
||||
|
||||
test("deduplicates concurrent resolves without invalidating either live reference", async () => {
|
||||
const pending = Promise.withResolvers<Blob | null>()
|
||||
const started = Promise.withResolvers<void>()
|
||||
let reads = 0
|
||||
@@ -29,55 +41,33 @@ test("deduplicates concurrent history and draft reads without invalidating eithe
|
||||
started.resolve()
|
||||
return pending.promise
|
||||
})
|
||||
const history = store.getItem("history")
|
||||
const draft = store.getItem("draft")
|
||||
await store.getItem("history")
|
||||
const first = resolveBlobUrl({ id: "history-cache-concurrent" })
|
||||
const second = resolveBlobUrl({ id: "history-cache-concurrent" })
|
||||
await started.promise
|
||||
pending.resolve(new Blob(["shared screenshot"]))
|
||||
const [saved, active] = await Promise.all([history, draft])
|
||||
const reference = JSON.parse(saved!).entries[0].prompt[0].blob
|
||||
expect(JSON.parse(active!).prompt[0].blob).toEqual(reference)
|
||||
const [a, b] = await Promise.all([first, second])
|
||||
expect(a).toBe(b!)
|
||||
expect(reads).toBe(1)
|
||||
await store.removeItem("history")
|
||||
expect(await resolveObjectURL(reference.url)?.text()).toBe("shared screenshot")
|
||||
expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob).toEqual(reference)
|
||||
expect(await resolveObjectURL(a!)?.text()).toBe("shared screenshot")
|
||||
expect(await resolveBlobUrl({ id: "history-cache-concurrent" })).toBe(a!)
|
||||
expect(reads).toBe(1)
|
||||
})
|
||||
|
||||
test("hydrates repeated references once within one history document", async () => {
|
||||
let reads = 0
|
||||
const { store, documents } = fixture("history-cache-repeated", async () => {
|
||||
reads++
|
||||
return new Blob(["repeated screenshot"])
|
||||
})
|
||||
documents.set(
|
||||
"history",
|
||||
JSON.stringify({
|
||||
entries: Array.from({ length: 100 }, () => ({
|
||||
prompt: [{ type: "image", blob: { id: "history-cache-repeated" } }],
|
||||
})),
|
||||
}),
|
||||
)
|
||||
const value = JSON.parse((await store.getItem("history"))!)
|
||||
expect(value.entries).toHaveLength(100)
|
||||
expect(
|
||||
new Set(value.entries.map((entry: { prompt: { blob: { url: string } }[] }) => entry.prompt[0].blob.url)).size,
|
||||
).toBe(1)
|
||||
expect(reads).toBe(1)
|
||||
})
|
||||
|
||||
test("reuses a live URL on remount but reads the latest document", async () => {
|
||||
test("a document re-read while its image is live gets the URL back without a read", async () => {
|
||||
let reads = 0
|
||||
const { store, documents } = fixture("history-cache-remount", async () => {
|
||||
reads++
|
||||
return new Blob(["saved screenshot"])
|
||||
})
|
||||
const first = JSON.parse((await store.getItem("history"))!)
|
||||
const url = await resolveBlobUrl({ id: "history-cache-remount" })
|
||||
const changed = JSON.parse(documents.get("history")!)
|
||||
changed.entries[0].prompt.unshift({ type: "text", content: "new admission" })
|
||||
documents.set("history", JSON.stringify(changed))
|
||||
const second = JSON.parse((await store.getItem("history"))!)
|
||||
expect(second.entries[0].prompt[0].content).toBe("new admission")
|
||||
expect(second.entries[0].prompt[1].blob).toEqual(first.entries[0].prompt[0].blob)
|
||||
expect(second.entries[0].prompt[1].blob).toEqual({ id: "history-cache-remount", url })
|
||||
expect(reads).toBe(1)
|
||||
})
|
||||
|
||||
@@ -89,33 +79,34 @@ test("reuses a just-stored attachment without a round trip", async () => {
|
||||
})
|
||||
const reference = await store.putBlob(new Blob(["pending admission"]))
|
||||
expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob).toEqual(reference)
|
||||
expect(await resolveBlobUrl({ id: reference.id })).toBe(reference.url)
|
||||
expect(reads).toBe(0)
|
||||
expect(await resolveObjectURL(reference.url)?.text()).toBe("pending admission")
|
||||
})
|
||||
|
||||
test("does not retain a missing blob result", async () => {
|
||||
let reads = 0
|
||||
const { store } = fixture("history-cache-missing", async () => (++reads === 1 ? null : new Blob(["arrived"])))
|
||||
expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob.url).toBeUndefined()
|
||||
expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob.url).toStartWith("blob:")
|
||||
fixture("history-cache-missing", async () => (++reads === 1 ? null : new Blob(["arrived"])))
|
||||
expect(await resolveBlobUrl({ id: "history-cache-missing" })).toBeUndefined()
|
||||
expect(await resolveBlobUrl({ id: "history-cache-missing" })).toStartWith("blob:")
|
||||
expect(reads).toBe(2)
|
||||
})
|
||||
|
||||
test("retries after a failed blob read", async () => {
|
||||
let reads = 0
|
||||
const { store } = fixture("history-cache-failure", async () => {
|
||||
fixture("history-cache-failure", async () => {
|
||||
if (++reads === 1) throw new Error("temporary storage failure")
|
||||
return new Blob(["recovered"])
|
||||
})
|
||||
await expect(store.getItem("history")).rejects.toThrow("temporary storage failure")
|
||||
expect(JSON.parse((await store.getItem("history"))!).entries[0].prompt[0].blob.url).toStartWith("blob:")
|
||||
await expect(resolveBlobUrl({ id: "history-cache-failure" })).rejects.toThrow("temporary storage failure")
|
||||
expect(await resolveBlobUrl({ id: "history-cache-failure" })).toStartWith("blob:")
|
||||
expect(reads).toBe(2)
|
||||
})
|
||||
|
||||
test("keeps different blob IDs independent", async () => {
|
||||
const reads: string[] = []
|
||||
const store = createDraftStore({
|
||||
get: async () => JSON.stringify(["history-cache-first", "history-cache-second"].map((id) => ({ blob: { id } }))),
|
||||
createDraftStore({
|
||||
get: async () => null,
|
||||
set: async () => [],
|
||||
remove: async () => {},
|
||||
putBlob: async () => "unused",
|
||||
@@ -124,10 +115,8 @@ test("keeps different blob IDs independent", async () => {
|
||||
return new Blob([id])
|
||||
},
|
||||
})
|
||||
const value = JSON.parse((await store.getItem("history"))!)
|
||||
expect(value[0].blob.url).not.toBe(value[1].blob.url)
|
||||
expect(
|
||||
await Promise.all(value.map((item: { blob: { url: string } }) => resolveObjectURL(item.blob.url)?.text())),
|
||||
).toEqual(reads)
|
||||
const urls = await Promise.all(["history-cache-first", "history-cache-second"].map((id) => resolveBlobUrl({ id })))
|
||||
expect(urls[0]).not.toBe(urls[1])
|
||||
expect(await Promise.all(urls.map((url) => resolveObjectURL(url!)?.text()))).toEqual(reads)
|
||||
expect(reads).toEqual(["history-cache-first", "history-cache-second"])
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Schema } from "effect"
|
||||
import type { Platform } from "@/runtime/platform/platform"
|
||||
import { createComposerReady, createComposerState } from "@/composer/state"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
import { createDraftStore } from "@/runtime/persistence/drafts"
|
||||
import { createDraftStore, resolveBlobUrl } from "@/runtime/persistence/drafts"
|
||||
import { flushPersisted } from "@/runtime/persistence/persist"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
|
||||
@@ -103,15 +103,11 @@ describe("prompt persistence", () => {
|
||||
}),
|
||||
}))
|
||||
await root.session.ready.promise
|
||||
// Bytes stay in the store until the image is shown or sent.
|
||||
expect(root.session.current()).toEqual([
|
||||
{
|
||||
type: "image",
|
||||
id: "image",
|
||||
filename: "image.png",
|
||||
mime: "image/png",
|
||||
blob: { id: "composer-image", url: expect.stringMatching(/^blob:/) },
|
||||
},
|
||||
{ type: "image", id: "image", filename: "image.png", mime: "image/png", blob: { id: "composer-image", url: "" } },
|
||||
])
|
||||
expect(await resolveBlobUrl(root.session.current()[0]!.blob)).toStartWith("blob:")
|
||||
root.session.set([{ type: "text", content: "hello", start: 0, end: 5 }, ...root.session.current()])
|
||||
flushPersisted()
|
||||
await Bun.sleep(0)
|
||||
@@ -237,7 +233,7 @@ describe("prompt persistence", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("moves image data URLs into blobs and hydrates object URLs", async () => {
|
||||
test("moves image data URLs into blobs and resolves object URLs on demand", async () => {
|
||||
const documents = new Map<string, string>()
|
||||
const blobs = new Map<string, Blob>()
|
||||
const store = createDraftStore({
|
||||
@@ -258,8 +254,8 @@ test("moves image data URLs into blobs and hydrates object URLs", async () => {
|
||||
await store.setItem("prompt", JSON.stringify({ prompt: [{ type: "image", dataUrl: "data:image/png;base64,YQ==" }] }))
|
||||
expect(documents.get("prompt")).not.toContain("dataUrl")
|
||||
const value = JSON.parse((await store.getItem("prompt"))!)
|
||||
expect(value.prompt[0].blob.id).toBe("1")
|
||||
expect(value.prompt[0].blob.url).toStartWith("blob:")
|
||||
expect(value.prompt[0].blob).toEqual({ id: "1" })
|
||||
expect(await resolveBlobUrl(value.prompt[0].blob)).toStartWith("blob:")
|
||||
})
|
||||
|
||||
test("does not let delayed blob migration overwrite a newer draft", async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -47,7 +47,14 @@ export default Runtime.handler(Commands, (input) =>
|
||||
),
|
||||
)
|
||||
const updater = yield* Updater.Service
|
||||
const update = yield* updater.run().pipe(Effect.forkScoped)
|
||||
let installing: string | undefined
|
||||
const updateListeners = new Set<(version: string) => void>()
|
||||
const update = yield* updater
|
||||
.run((version) => {
|
||||
installing = version
|
||||
updateListeners.forEach((notify) => notify(version))
|
||||
})
|
||||
.pipe(Effect.ensuring(Effect.sync(() => (installing = undefined))), Effect.forkScoped)
|
||||
preflight.loading()
|
||||
const config = yield* Config.Service
|
||||
const npm = yield* Npm.Service
|
||||
@@ -92,7 +99,13 @@ export default Runtime.handler(Commands, (input) =>
|
||||
),
|
||||
{ signal },
|
||||
),
|
||||
check: (signal) => runPromise(Fiber.join(update).pipe(Effect.flatMap(() => updater.check())), { signal }),
|
||||
check: (signal, notify) => {
|
||||
if (installing) notify(installing)
|
||||
updateListeners.add(notify)
|
||||
return runPromise(Fiber.join(update).pipe(Effect.flatMap(() => updater.check())), { signal }).finally(() =>
|
||||
updateListeners.delete(notify),
|
||||
)
|
||||
},
|
||||
apply: (version) => runPromise(updater.apply(version)),
|
||||
},
|
||||
packages: {
|
||||
|
||||
@@ -13,7 +13,6 @@ import { HttpServer } from "effect/unstable/http"
|
||||
import { Env } from "./env"
|
||||
import { ServiceConfig } from "./services/service-config"
|
||||
import { ServiceRegistration } from "./services/service-registration"
|
||||
import { Updater } from "./services/updater"
|
||||
import { WebUi } from "./services/web-ui"
|
||||
import { databasePath } from "./database-path"
|
||||
|
||||
@@ -159,21 +158,6 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
const url = HttpServer.formatAddress(server.address)
|
||||
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
|
||||
if (foreground && !environmentPassword) console.log(`server password ${password}`)
|
||||
yield* Updater.Service.pipe(
|
||||
Effect.flatMap((updater) =>
|
||||
Updater.pollUpdates({
|
||||
check: updater.run().pipe(
|
||||
Effect.flatMap((result) => {
|
||||
if (!result) return Effect.void
|
||||
if (result.type === "available") return server.updateAvailable(result.version)
|
||||
return server.updated(result.version)
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
Effect.provide(Updater.layer),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
return yield* options.mode === "service"
|
||||
? server.shutdown
|
||||
: options.mode === "stdio"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { AppProcess } from "@opencode/util/process"
|
||||
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
|
||||
import { Context, Duration, Effect, FileSystem, Layer, Ref, Schedule } from "effect"
|
||||
import { Context, Duration, Effect, FileSystem, Layer, Ref } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "node:path"
|
||||
@@ -13,7 +13,7 @@ export type RunResult = { readonly type: "available" | "installed"; readonly ver
|
||||
export type CheckResult = RunResult | { readonly type: "unavailable"; readonly message: string }
|
||||
|
||||
export interface Interface {
|
||||
readonly run: () => Effect.Effect<RunResult | undefined>
|
||||
readonly run: (onInstall?: (version: string) => void) => Effect.Effect<RunResult | undefined>
|
||||
readonly check: () => Effect.Effect<CheckResult | undefined, Error>
|
||||
readonly apply: (version: string) => Effect.Effect<void, Error>
|
||||
readonly method: () => Effect.Effect<Method | undefined>
|
||||
@@ -24,18 +24,6 @@ export interface Interface {
|
||||
| undefined
|
||||
}
|
||||
|
||||
export const pollUpdates = Effect.fnUntraced(function* (input: {
|
||||
readonly check: Effect.Effect<unknown>
|
||||
readonly initialDelay?: Duration.Input
|
||||
readonly interval?: Duration.Input
|
||||
}) {
|
||||
const interval = input.interval ?? "10 minutes"
|
||||
return yield* input.check.pipe(
|
||||
Effect.repeat(Schedule.spaced(interval)),
|
||||
Effect.delay(input.initialDelay ?? "1 minute"),
|
||||
)
|
||||
})
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
|
||||
|
||||
export function decodePolicy(text: string): Policy | undefined {
|
||||
@@ -287,10 +275,11 @@ const make = Effect.gen(function* () {
|
||||
})
|
||||
|
||||
const run = Effect.fn("cli.updater.run")(
|
||||
function* () {
|
||||
function* (onInstall: (version: string) => void = () => {}) {
|
||||
const result = yield* inspect()
|
||||
if (!result) return undefined
|
||||
if (result.policy === "notify") return { type: "available" as const, version: result.version }
|
||||
onInstall(result.version)
|
||||
if (!(yield* install(result.version))) return yield* Effect.fail(new Error("Installation method not found"))
|
||||
return { type: "installed" as const, version: result.version }
|
||||
},
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Layer, Queue } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { testEffect } from "../../core/test/lib/effect"
|
||||
import { Updater } from "../src/services/updater"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
it.effect("polls after 1 minute and every 10 minutes after that", () =>
|
||||
Effect.gen(function* () {
|
||||
const checks = yield* Queue.unbounded<void>()
|
||||
yield* Updater.pollUpdates({ check: Queue.offer(checks, undefined).pipe(Effect.asVoid) }).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* Queue.size(checks)).toBe(0)
|
||||
yield* TestClock.adjust("59 seconds")
|
||||
expect(yield* Queue.size(checks)).toBe(0)
|
||||
yield* TestClock.adjust("1 second")
|
||||
yield* Queue.take(checks)
|
||||
yield* Effect.yieldNow
|
||||
yield* TestClock.adjust("10 minutes")
|
||||
yield* Queue.take(checks)
|
||||
}),
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -11,7 +11,6 @@ export type EventApi = Client["event"]
|
||||
export type GenerateApi = Client["generate"]
|
||||
export type IntegrationApi = Client["integration"]
|
||||
export type McpApi = Client["mcp"]
|
||||
export type MessageApi = Client["message"]
|
||||
export type ModelApi = Client["model"]
|
||||
export type PluginApi = Client["plugin"]
|
||||
export type PermissionApi = Client["permission"]
|
||||
|
||||
@@ -2135,7 +2135,7 @@ export type ConfigEntry =
|
||||
experimental?: {
|
||||
portable_shell_scanner?: boolean
|
||||
subagent_depth?: number
|
||||
policies?: Array<{ action: "provider.use"; resource: string; effect: "allow" | "deny" }>
|
||||
policies?: Array<{ action: "provider.use" | "permission"; resource: string; effect: "allow" | "deny" }>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1043,6 +1043,18 @@ export function createData(config: CreateDataInput) {
|
||||
: "interrupted",
|
||||
time: { created: event.created },
|
||||
})
|
||||
if (
|
||||
store.session.message[event.data.sessionID]?.some(
|
||||
(item) =>
|
||||
item.type === "assistant" &&
|
||||
item.content.some(
|
||||
(part) => part.type === "tool" && (part.state.status === "streaming" || part.state.status === "running"),
|
||||
),
|
||||
)
|
||||
) {
|
||||
sync.invalidate(`session.message:${event.data.sessionID}`)
|
||||
refresh(() => result.session.message.sync(event.data.sessionID))
|
||||
}
|
||||
// An event can overtake the first read; queue a revalidation when that read is still active.
|
||||
if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`)) return
|
||||
result.session.invalidate(event.data.sessionID)
|
||||
|
||||
@@ -52,6 +52,80 @@ test("uses the configured initial window and retains normal cursor page sizes",
|
||||
}
|
||||
})
|
||||
|
||||
test("reconciles a stale running tool when execution settles", async () => {
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
let completed = false
|
||||
let requests = 0
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async () => {
|
||||
requests++
|
||||
return Response.json({
|
||||
data: [
|
||||
{
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "provider", id: "model" },
|
||||
time: { created: 1, ...(completed ? { completed: 2 } : {}) },
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_execute",
|
||||
name: "execute",
|
||||
time: { created: 1, ran: 1, ...(completed ? { completed: 2 } : {}) },
|
||||
state: completed
|
||||
? { status: "completed", input: {}, metadata: {}, content: [] }
|
||||
: { status: "running", input: {}, metadata: {} },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
})
|
||||
},
|
||||
})
|
||||
const setup = createRoot((dispose) => ({
|
||||
data: createData({
|
||||
api: () => api,
|
||||
directory: "/project",
|
||||
event: {
|
||||
on: () => () => {},
|
||||
listen(handler) {
|
||||
listeners.add(handler)
|
||||
return () => listeners.delete(handler)
|
||||
},
|
||||
},
|
||||
connection: { status: () => "connected" },
|
||||
}),
|
||||
dispose,
|
||||
}))
|
||||
try {
|
||||
await setup.data.session.message.sync("ses_refresh")
|
||||
completed = true
|
||||
const interrupted: OpenCodeEvent = {
|
||||
id: "evt_interrupted",
|
||||
created: 3,
|
||||
type: "session.execution.interrupted",
|
||||
durable: { aggregateID: "ses_refresh", seq: 1, version: 1 },
|
||||
data: { sessionID: "ses_refresh", reason: "user" },
|
||||
}
|
||||
listeners.forEach((listener) => listener({ name: interrupted.type, details: interrupted }))
|
||||
|
||||
await wait(
|
||||
() =>
|
||||
setup.data.session.message.get("ses_refresh", "msg_assistant")?.content[0]?.type === "tool" &&
|
||||
setup.data.session.message.get("ses_refresh", "msg_assistant")?.content[0]?.state.status === "completed",
|
||||
)
|
||||
expect(requests).toBe(2)
|
||||
expect(setup.data.session.message.get("ses_refresh", "msg_assistant")?.content[0]).toMatchObject({
|
||||
state: { status: "completed" },
|
||||
})
|
||||
} finally {
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("revalidates after an event overtakes an active session read", async () => {
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((resolve) => (release = resolve))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "CodeMode Transport Coverage",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"paths": {
|
||||
"/records/{recordID}": {
|
||||
"get": {
|
||||
"operationId": "records.get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "recordID",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Record",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Record"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/events": {
|
||||
"get": {
|
||||
"operationId": "events.subscribe",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Events",
|
||||
"content": {
|
||||
"text/event-stream": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/files/{path}": {
|
||||
"get": {
|
||||
"operationId": "files.read",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "path",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "File",
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"operationId": "files.write",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "path",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Written"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/terminals/{terminalID}/connect": {
|
||||
"get": {
|
||||
"operationId": "terminals.connect",
|
||||
"x-websocket": true,
|
||||
"parameters": [
|
||||
{
|
||||
"name": "terminalID",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"101": {
|
||||
"description": "Connected"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"Record": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "value"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,8 @@ type Recorded = {
|
||||
readonly body: unknown
|
||||
}
|
||||
|
||||
const opencodeSpec = async (): Promise<Document> => {
|
||||
return Bun.file(new URL("../../protocol/openapi.json", import.meta.url)).json() as Promise<Document>
|
||||
const transportSpec = async (): Promise<Document> => {
|
||||
return Bun.file(new URL("./fixtures/openapi-transports.json", import.meta.url)).json() as Promise<Document>
|
||||
}
|
||||
|
||||
const happyPathSpec = async (): Promise<Document> => {
|
||||
@@ -219,53 +219,42 @@ describe("OpenAPI.fromSpec", () => {
|
||||
expect(client.requests[3]!.headers.authorization).toBe("Bearer bearer-secret")
|
||||
})
|
||||
|
||||
test("converts representative opencode operations into the expected tool shape", async () => {
|
||||
const spec = await opencodeSpec()
|
||||
test("generates supported operations and reports unsupported transports", async () => {
|
||||
const spec = await transportSpec()
|
||||
const result = OpenAPI.fromSpec({ spec, baseUrl })
|
||||
|
||||
expect(result.skipped).toHaveLength(6)
|
||||
expect(result.skipped).toContainEqual({
|
||||
method: "GET",
|
||||
path: "/api/pty/{ptyID}/connect",
|
||||
reason: "WebSocket operations are not supported",
|
||||
})
|
||||
expect(result.skipped).toContainEqual({
|
||||
method: "POST",
|
||||
path: "/api/experimental/fs/write",
|
||||
reason: "request body has no JSON content (declared: application/octet-stream)",
|
||||
})
|
||||
expect(result.skipped.filter((item) => item.reason === "SSE operations are not supported")).toHaveLength(2)
|
||||
expect(result.skipped).toContainEqual({
|
||||
method: "GET",
|
||||
path: "/api/fs/read/*",
|
||||
reason: "binary responses are not supported",
|
||||
})
|
||||
expect(toolAt(result.tools, "server.info")).not.toBeUndefined()
|
||||
expect(toolAt(result.tools, "session.get")).not.toBeUndefined()
|
||||
expect(toolAt(result.tools, "session.create")).not.toBeUndefined()
|
||||
expect(result.skipped).toEqual([
|
||||
{
|
||||
method: "GET",
|
||||
path: "/events",
|
||||
reason: "SSE operations are not supported",
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/files/{path}",
|
||||
reason: "binary responses are not supported",
|
||||
},
|
||||
{
|
||||
method: "PUT",
|
||||
path: "/files/{path}",
|
||||
reason: "request body has no JSON content (declared: application/octet-stream)",
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/terminals/{terminalID}/connect",
|
||||
reason: "WebSocket operations are not supported",
|
||||
},
|
||||
])
|
||||
|
||||
const sessionGet = toolAt(result.tools, "session.get")
|
||||
expect(Tool.isTool(sessionGet)).toBe(true)
|
||||
if (!Tool.isTool(sessionGet)) throw new Error("session.get was not generated")
|
||||
expect(inputTypeScript(sessionGet)).toBe("{ sessionID: string }")
|
||||
expect(outputTypeScript(sessionGet)).toContain("id: string")
|
||||
expect(outputTypeScript(sessionGet)).toContain("additions: number")
|
||||
|
||||
const switchAgent = toolAt(result.tools, "session.switchAgent")
|
||||
expect(Tool.isTool(switchAgent)).toBe(true)
|
||||
if (!Tool.isTool(switchAgent)) throw new Error("session.switchAgent was not generated")
|
||||
expect(inputTypeScript(switchAgent)).toBe("{ sessionID: string; agent: string }")
|
||||
|
||||
const instructionPut = toolAt(result.tools, "experimental.session.instructions.entry.put")
|
||||
expect(Tool.isTool(instructionPut)).toBe(true)
|
||||
if (!Tool.isTool(instructionPut)) throw new Error("experimental.session.instructions.entry.put was not generated")
|
||||
expect(inputTypeScript(instructionPut)).toBe("{ sessionID: string; key: string; value: unknown }")
|
||||
expect(toolAt(result.tools, "experimental_session_instructions_entry_put_2")).toBeUndefined()
|
||||
expect(Tool.isTool(toolAt(result.tools, "pty.connect"))).toBe(false)
|
||||
expect(toolAt(result.tools, "session.log")).toBeUndefined()
|
||||
expect(toolAt(result.tools, "event.subscribe")).toBeUndefined()
|
||||
expect(toolAt(result.tools, "fs.read")).toBeUndefined()
|
||||
expect(toolAt(result.tools, "pty.connect.token")).not.toBeUndefined()
|
||||
const get = toolAt(result.tools, "records.get")
|
||||
expect(Tool.isTool(get)).toBe(true)
|
||||
if (!Tool.isTool(get)) throw new Error("records.get was not generated")
|
||||
expect(inputTypeScript(get)).toBe("{ recordID: string }")
|
||||
expect(outputTypeScript(get)).toBe("{ id: string; value: string }")
|
||||
expect(toolAt(result.tools, "events.subscribe")).toBeUndefined()
|
||||
expect(toolAt(result.tools, "files.read")).toBeUndefined()
|
||||
expect(toolAt(result.tools, "files.write")).toBeUndefined()
|
||||
expect(toolAt(result.tools, "terminals.connect")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("preserves operation path sanitization and collision handling", () => {
|
||||
@@ -976,30 +965,16 @@ describe("OpenAPI.fromSpec", () => {
|
||||
expect(result).toMatchObject({ password: "returned-by-server", profile: { secret: "returned-secret" } })
|
||||
})
|
||||
|
||||
test("documents that the opencode fixture is unauthenticated", async () => {
|
||||
const spec = await opencodeSpec()
|
||||
const components = isRecord(spec.components) ? spec.components : {}
|
||||
const result = OpenAPI.fromSpec({ spec, baseUrl })
|
||||
|
||||
expect(spec.security).toStrictEqual([])
|
||||
expect(isRecord(components.securitySchemes) ? Object.keys(components.securitySchemes) : []).toStrictEqual([])
|
||||
const info = toolAt(result.tools, "server.info")
|
||||
const infoInput = Tool.isTool(info) && isRecord(info.input) ? info.input : undefined
|
||||
expect(infoInput).toMatchObject({ type: "object", properties: {} })
|
||||
const input = isRecord(infoInput) ? infoInput : {}
|
||||
expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual([])
|
||||
})
|
||||
|
||||
test("exposes real opencode operations through CodeMode discovery", async () => {
|
||||
test("exposes generated operations through CodeMode discovery", async () => {
|
||||
const { layer } = recordingClient(() => json({}))
|
||||
const runtime = CodeMode.make({
|
||||
tools: { opencode: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools },
|
||||
tools: { api: OpenAPI.fromSpec({ spec: await happyPathSpec(), baseUrl }).tools },
|
||||
})
|
||||
const result = await Effect.runPromise(
|
||||
runtime
|
||||
.execute(
|
||||
`
|
||||
return search({ query: "server info", namespace: "opencode", limit: 1 })
|
||||
return search({ query: "get a user", namespace: "api", limit: 1 })
|
||||
`,
|
||||
)
|
||||
.pipe(Effect.provide(layer)),
|
||||
@@ -1010,55 +985,12 @@ describe("OpenAPI.fromSpec", () => {
|
||||
expect(result.value).toMatchObject({
|
||||
items: [
|
||||
{
|
||||
path: "tools.opencode.server.info",
|
||||
description: "Return the server identity, connection URLs, paths, and readiness status.",
|
||||
path: "tools.api.users.get",
|
||||
description: "Get a user",
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(JSON.stringify(result.value)).toContain("version: string")
|
||||
})
|
||||
|
||||
test("invokes real opencode path parameters and JSON request bodies", async () => {
|
||||
const { requests, layer } = recordingClient((request) => {
|
||||
if (request.method === "GET") return json({ id: "ses_123" })
|
||||
return json({ id: "ses_456" })
|
||||
})
|
||||
const runtime = CodeMode.make({
|
||||
tools: { opencode: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools },
|
||||
})
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
runtime
|
||||
.execute(
|
||||
`
|
||||
const existing = await tools.opencode.session.get({ sessionID: "ses_123" })
|
||||
const created = await tools.opencode.session.create({ id: "ses_456" })
|
||||
return { existing, created }
|
||||
`,
|
||||
)
|
||||
.pipe(Effect.provide(layer)),
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ ok: true })
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests[0]).toMatchObject({ method: "GET", body: undefined })
|
||||
expect(new URL(requests[0]!.url).pathname).toBe("/api/session/ses_123")
|
||||
expect(requests[1]).toMatchObject({
|
||||
method: "POST",
|
||||
url: "http://localhost:4096/api/session",
|
||||
body: { id: "ses_456" },
|
||||
})
|
||||
})
|
||||
|
||||
test("serializes deep-object query parameters from the opencode fixture", async () => {
|
||||
const client = recordingClient(() => json({ directory: "/tmp" }))
|
||||
const location = toolAt(OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools, "location.get")
|
||||
if (!Tool.isTool(location)) throw new Error("location.get was not generated")
|
||||
|
||||
await Effect.runPromise(location.execute({ location: { directory: "/tmp" } }).pipe(Effect.provide(client.layer)))
|
||||
|
||||
const url = new URL(client.requests[0]!.url)
|
||||
expect(url.searchParams.get("location[directory]")).toBe("/tmp")
|
||||
expect(JSON.stringify(result.value)).toContain("userId: string")
|
||||
})
|
||||
|
||||
test("serializes supported simple and form parameter shapes", async () => {
|
||||
@@ -1466,15 +1398,15 @@ describe("OpenAPI.fromSpec", () => {
|
||||
test("fails missing required parameters before auth and network", async () => {
|
||||
const { requests, layer } = recordingClient(() => json({}))
|
||||
const runtime = CodeMode.make({
|
||||
tools: { opencode: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools },
|
||||
tools: { api: OpenAPI.fromSpec({ spec: await transportSpec(), baseUrl }).tools },
|
||||
})
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
runtime.execute("return await tools.opencode.session.get({})").pipe(Effect.provide(layer)),
|
||||
runtime.execute("return await tools.api.records.get({})").pipe(Effect.provide(layer)),
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ ok: false })
|
||||
expect(JSON.stringify(result)).toContain("Missing required path parameter 'sessionID'")
|
||||
expect(JSON.stringify(result)).toContain("Missing required path parameter 'recordID'")
|
||||
expect(requests).toHaveLength(0)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"name": "@opencode/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -12,16 +12,34 @@ export const Plugin = define({
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.provider.reload())
|
||||
yield* ctx.provider.transform((providers) => {
|
||||
// User-global policy takes priority over policy authored by a repository.
|
||||
const policies = loaded.entries
|
||||
const policies = () =>
|
||||
loaded.entries
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.toReversed()
|
||||
.flatMap((entry) => entry.info.experimental?.policies ?? [])
|
||||
yield* ctx.provider.transform((providers) => {
|
||||
// User-global policy takes priority over policy authored by a repository.
|
||||
for (const record of providers.list()) {
|
||||
const policy = policies.findLast((policy) => Wildcard.match(record.provider.id, policy.resource))
|
||||
const policy = policies().findLast(
|
||||
(policy) => policy.action === "provider.use" && Wildcard.match(record.provider.id, policy.resource),
|
||||
)
|
||||
if (policy?.effect === "deny") providers.remove(record.provider.id)
|
||||
}
|
||||
})
|
||||
yield* ctx.permission.hook("evaluate", (event) =>
|
||||
Effect.sync(() => {
|
||||
const current = policies()
|
||||
const denied = event.resources.some((resource) => {
|
||||
const policy = current.findLast(
|
||||
(policy) =>
|
||||
policy.action === "permission" && Wildcard.match(`${event.action}:${resource}`, policy.resource),
|
||||
)
|
||||
return policy?.effect === "deny"
|
||||
})
|
||||
if (!denied) return
|
||||
event.effect = "deny"
|
||||
event.message = "Blocked by configuration policy"
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export * as NativeCompactionPlugin from "./compaction.js"
|
||||
|
||||
import { LLMClient, Message } from "@opencode/ai"
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { Effect } from "effect"
|
||||
import { SessionCompaction } from "../session/compaction.js"
|
||||
import type { PluginInternal } from "./internal.js"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.compaction.native",
|
||||
effect: Effect.fn("NativeCompactionPlugin")(function* () {
|
||||
const llm = yield* LLMClient.Service
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
yield* compaction.transform((editor) => {
|
||||
editor.native((input) => {
|
||||
const request = input.request
|
||||
if (LLMClient.canCompact(request, { mechanism: "trigger" }))
|
||||
return Effect.gen(function* () {
|
||||
const retained = yield* input.retained
|
||||
const result = yield* llm.compact(request, { ...input.options, mechanism: "trigger" })
|
||||
return { replacement: [...retained, Message.assistant(result.checkpoint)], usage: result.usage }
|
||||
})
|
||||
if (LLMClient.canCompact(request))
|
||||
return llm.compact(request, { mechanism: "endpoint", http: input.options.http })
|
||||
return undefined
|
||||
})
|
||||
})
|
||||
}),
|
||||
} satisfies PluginInternal.InternalPlugin)
|
||||
@@ -19,7 +19,6 @@ import { LocationServiceMap } from "../location-service-map.js"
|
||||
import { Model } from "../model.js"
|
||||
import { Mcp } from "../mcp/index.js"
|
||||
import { Session } from "../session.js"
|
||||
import { SessionMessage } from "../session/message.js"
|
||||
import { PersistentPty } from "../persistent-pty.js"
|
||||
import { Provider } from "../provider.js"
|
||||
import { Reference } from "../reference.js"
|
||||
@@ -393,30 +392,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
})
|
||||
}),
|
||||
},
|
||||
message: {
|
||||
list: Effect.fn("PluginHost.messages")(function* (input) {
|
||||
if (input.cursor !== undefined && input.order !== undefined)
|
||||
return yield* Effect.fail(new Error("Cursor cannot be combined with order"))
|
||||
const decoded = input.cursor === undefined ? undefined : yield* decodeMessageCursor(input.cursor)
|
||||
const order = decoded?.order ?? input.order ?? "desc"
|
||||
const messages = yield* sessions.messages({
|
||||
sessionID: input.sessionID,
|
||||
limit: input.limit ?? DefaultMessagesLimit,
|
||||
order,
|
||||
type: input.type,
|
||||
cursor: decoded ? { id: decoded.id, direction: decoded.direction } : undefined,
|
||||
})
|
||||
const first = messages[0]
|
||||
const last = messages.at(-1)
|
||||
return {
|
||||
data: messages,
|
||||
cursor: {
|
||||
previous: first ? encodeMessageCursor(first, order, "previous") : undefined,
|
||||
next: last ? encodeMessageCursor(last, order, "next") : undefined,
|
||||
},
|
||||
}
|
||||
}),
|
||||
},
|
||||
permission: {
|
||||
hook: (name, callback) => hooks.register("permission", name, callback),
|
||||
list: (input) => permission.forSession(input.sessionID),
|
||||
@@ -572,14 +547,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
.pipe(Effect.map((interrupted) => ({ interrupted }))),
|
||||
wait: (input) => sessions.wait(input.sessionID),
|
||||
context: (input) => sessions.context(input.sessionID),
|
||||
message: {
|
||||
get: Effect.fn(function* (input) {
|
||||
yield* sessions.get(input.sessionID)
|
||||
const message = yield* sessions.message(input)
|
||||
if (!message) return yield* Effect.fail(new Error(`Message not found: ${input.messageID}`))
|
||||
return message
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
return context
|
||||
@@ -688,29 +655,3 @@ function methodImplementation(input: IntegrationMethodRegistration): Integration
|
||||
function credential(value: Credential.OAuth) {
|
||||
return Credential.OAuth.make({ ...value, methodID: Integration.MethodID.make(value.methodID) })
|
||||
}
|
||||
|
||||
const DefaultMessagesLimit = 50
|
||||
|
||||
const MessageCursor = Schema.Struct({
|
||||
id: SessionMessage.ID,
|
||||
order: Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")]),
|
||||
direction: Schema.Union([Schema.Literal("previous"), Schema.Literal("next")]),
|
||||
})
|
||||
|
||||
function encodeMessageCursor(
|
||||
message: SessionMessage.Info,
|
||||
order: "asc" | "desc",
|
||||
direction: "previous" | "next",
|
||||
) {
|
||||
return Buffer.from(JSON.stringify({ id: message.id, order, direction })).toString("base64url")
|
||||
}
|
||||
|
||||
const decodeMessageCursor = Effect.fn("PluginHost.decodeMessageCursor")(function* (cursor: string) {
|
||||
const parsed = yield* Effect.try({
|
||||
try: () => JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")),
|
||||
catch: () => new Error("Invalid cursor"),
|
||||
})
|
||||
return yield* Schema.decodeUnknownEffect(MessageCursor)(parsed).pipe(
|
||||
Effect.mapError(() => new Error("Invalid cursor")),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as PluginInternal from "./internal.js"
|
||||
|
||||
import { LLMClient } from "@opencode/ai"
|
||||
import type { Plugin } from "@opencode/plugin/effect/plugin"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { httpClient } from "@opencode/util/effect/app-node-platform"
|
||||
@@ -12,6 +13,7 @@ import { Provider } from "../provider.js"
|
||||
import { Command } from "../command.js"
|
||||
import { Config } from "../config.js"
|
||||
import { Credential } from "../credential.js"
|
||||
import { llmClient } from "../effect/app-node-platform.js"
|
||||
import { ConfigAgentPlugin } from "../config/plugin/agent.js"
|
||||
import { ConfigCommandPlugin } from "../config/plugin/command.js"
|
||||
import { ConfigCompactionPlugin } from "../config/plugin/compaction.js"
|
||||
@@ -84,6 +86,7 @@ import { WriteTool } from "../tool/plugin/write.js"
|
||||
import { AgentPlugin } from "./agent.js"
|
||||
import BrowserPlugin from "@opencode/plugin-browser"
|
||||
import { CommandPlugin } from "./command.js"
|
||||
import { NativeCompactionPlugin } from "./compaction.js"
|
||||
import { IdentityPlugin } from "./identity.js"
|
||||
import { PlanPlugin } from "./plan.js"
|
||||
import { ModelsDevPlugin } from "./models-dev.js"
|
||||
@@ -120,6 +123,7 @@ const services = [
|
||||
Integration.Service,
|
||||
Job.Service,
|
||||
KV.Service,
|
||||
LLMClient.Service,
|
||||
Location.Service,
|
||||
ModelsDev.Service,
|
||||
Mcp.Service,
|
||||
@@ -171,6 +175,7 @@ export const requirements = LayerNode.group([
|
||||
Integration.node,
|
||||
Job.node,
|
||||
KV.node,
|
||||
llmClient,
|
||||
Location.node,
|
||||
ModelsDev.node,
|
||||
Mcp.node,
|
||||
@@ -212,6 +217,7 @@ const pre = [
|
||||
SkillPlugin.Plugin,
|
||||
VcsHgPlugin.Plugin,
|
||||
ModelsDevPlugin,
|
||||
NativeCompactionPlugin.Plugin,
|
||||
...ProviderPlugins,
|
||||
...WebSearchPlugins,
|
||||
PatchTool.Plugin,
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
LLMRequest,
|
||||
Message,
|
||||
type ContentPart,
|
||||
type Usage,
|
||||
} from "@opencode/ai"
|
||||
import type { StreamOptions } from "@opencode/ai/route"
|
||||
import type { SessionCompactionResult } from "@opencode/plugin/effect/session"
|
||||
import { SessionError } from "@opencode/schema/session-error"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
@@ -91,8 +93,25 @@ export type Settings = {
|
||||
tokens: number
|
||||
}
|
||||
|
||||
export type NativeInput = {
|
||||
readonly request: LLMRequest
|
||||
readonly options: StreamOptions
|
||||
/** Whole, real user messages within the retained-token allowance, for checkpoint-only mechanisms. */
|
||||
readonly retained: Effect.Effect<ReadonlyArray<Message>>
|
||||
}
|
||||
|
||||
export type NativeResult = {
|
||||
readonly replacement: ReadonlyArray<Message>
|
||||
readonly usage?: Usage
|
||||
}
|
||||
|
||||
/** Returns the provider's replacement window, or `undefined` when this strategy has no mechanism for the route. */
|
||||
export type NativeStrategy = (input: NativeInput) => Effect.Effect<NativeResult, AIError> | undefined
|
||||
|
||||
export type Editor = {
|
||||
configure: (settings: Partial<Settings>) => void
|
||||
/** Later registrations take precedence. */
|
||||
native: (strategy: NativeStrategy) => void
|
||||
}
|
||||
|
||||
export type AutoInput = {
|
||||
@@ -380,15 +399,18 @@ export const layer = Layer.effect(
|
||||
const llm = yield* LLMClient.Service
|
||||
const db = (yield* Database.Service).db
|
||||
|
||||
const state = State.create<Settings, Editor>({
|
||||
const state = State.create<Settings & { readonly native: NativeStrategy[] }, Editor>({
|
||||
name: "session-compaction",
|
||||
initial: () => ({ auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS }),
|
||||
initial: () => ({ auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS, native: [] }),
|
||||
editor: (editor) => ({
|
||||
configure: (settings) => {
|
||||
if (settings.auto !== undefined) editor.auto = settings.auto
|
||||
if (settings.buffer !== undefined) editor.buffer = settings.buffer
|
||||
if (settings.tokens !== undefined) editor.tokens = settings.tokens
|
||||
},
|
||||
native: (strategy) => {
|
||||
editor.native.push(strategy)
|
||||
},
|
||||
}),
|
||||
})
|
||||
const failed = Effect.fnUntraced(function* (input: SessionEvent.Compaction.Failed["data"]) {
|
||||
@@ -504,6 +526,23 @@ export const layer = Layer.effect(
|
||||
return yield* reject(
|
||||
"Provider compaction requires the endpoint in provider/model settings, not a model.request rewrite",
|
||||
)
|
||||
const native = state
|
||||
.get()
|
||||
.native.toReversed()
|
||||
.map((strategy) =>
|
||||
strategy({
|
||||
request,
|
||||
options: prepared.options,
|
||||
retained: original(context.session.id).pipe(
|
||||
Effect.map((messages) => retainUsers(messages, context.model, state.get().tokens)),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.find((effect) => effect !== undefined)
|
||||
if (!native)
|
||||
return yield* reject(
|
||||
`No plugin provides native compaction for ${request.model.provider}/${request.model.route.id}`,
|
||||
)
|
||||
const transient = SessionRunnerRetry.transient(yield* SessionRunnerRetry.policy(context.session.id), {
|
||||
agent: context.agent.id,
|
||||
model: context.model.ref,
|
||||
@@ -514,25 +553,7 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
// Transient provider failures retry like any other request; only a known automatic overflow permits
|
||||
// local recovery, and nothing is installed until the provider returns a checkpoint.
|
||||
const result = yield* restore(
|
||||
Effect.gen(function* () {
|
||||
if (LLMClient.canCompact(request, { mechanism: "trigger" })) {
|
||||
const retained = retainUsers(yield* original(context.session.id), context.model, state.get().tokens)
|
||||
const result = yield* llm
|
||||
.compact(request, { ...prepared.options, mechanism: "trigger" })
|
||||
.pipe(transient)
|
||||
return { replacement: [...retained, Message.assistant(result.checkpoint)], usage: result.usage }
|
||||
}
|
||||
if (LLMClient.canCompact(request))
|
||||
return yield* llm
|
||||
.compact(request, { mechanism: "endpoint", http: prepared.options.http })
|
||||
.pipe(transient)
|
||||
// Model resolution admits provider policies only for routes with a compaction operation.
|
||||
return yield* Effect.die(
|
||||
new Error(`${request.model.provider}/${request.model.route.id} has no compaction operation`),
|
||||
)
|
||||
}),
|
||||
)
|
||||
const result = yield* restore(native.pipe(transient))
|
||||
const usage = result.usage ? SessionUsage.record(result.usage, context.model.cost) : undefined
|
||||
if (usage)
|
||||
yield* bus.publish(SessionEvent.UsageRecorded, {
|
||||
|
||||
@@ -93,9 +93,10 @@ export const prepare = Effect.fn("SessionPrompt.prepare")(function* (request: {
|
||||
const materializeAttachment = Effect.fn("SessionPrompt.materializeAttachment")(function* (
|
||||
input: PromptInput.FileAttachment,
|
||||
) {
|
||||
const label = attachmentLabel(input)
|
||||
const resolved = input.uri.startsWith("data:")
|
||||
? {
|
||||
bytes: yield* decodeDataURL(input.uri),
|
||||
bytes: yield* decodeDataURL(input.uri, label),
|
||||
source: { type: "inline" as const },
|
||||
start: undefined,
|
||||
end: undefined,
|
||||
@@ -105,8 +106,8 @@ const materializeAttachment = Effect.fn("SessionPrompt.materializeAttachment")(f
|
||||
: yield* readFileAttachment(input.uri)
|
||||
if (resolved.bytes.byteLength > MAX_ATTACHMENT_BYTES)
|
||||
return yield* new AttachmentError({
|
||||
uri: input.uri,
|
||||
message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${input.uri}`,
|
||||
uri: label,
|
||||
message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${label}`,
|
||||
})
|
||||
|
||||
const mime = resolved.mime ?? Mime.detect(resolved.bytes)
|
||||
@@ -138,7 +139,7 @@ const normalizeImageAttachment = Effect.fn("SessionPrompt.normalizeImageAttachme
|
||||
) {
|
||||
if (!mime.startsWith("image/")) return { data: Base64.make(data), mime }
|
||||
const image = yield* Image.Service
|
||||
const label = input.name ?? (input.uri.startsWith("data:") ? "inline attachment" : input.uri)
|
||||
const label = attachmentLabel(input)
|
||||
const content = { uri: label, content: data, encoding: "base64" as const, mime }
|
||||
const normalized = yield* image.normalize(label, content).pipe(
|
||||
Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content)),
|
||||
@@ -201,7 +202,12 @@ const readFileAttachment = Effect.fn("SessionPrompt.readFileAttachment")(functio
|
||||
|
||||
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
||||
|
||||
function decodeDataURL(uri: string) {
|
||||
// A data URL is the whole file; errors and logs must name the attachment, not echo its bytes.
|
||||
function attachmentLabel(input: PromptInput.FileAttachment) {
|
||||
return input.name ?? (input.uri.startsWith("data:") ? "inline attachment" : input.uri)
|
||||
}
|
||||
|
||||
function decodeDataURL(uri: string, label: string) {
|
||||
return Effect.try({
|
||||
try: () => {
|
||||
const comma = uri.indexOf(",")
|
||||
@@ -214,7 +220,7 @@ function decodeDataURL(uri: string) {
|
||||
if (bytes.toString("base64") !== payload) throw new Error("Non-canonical base64")
|
||||
return bytes
|
||||
},
|
||||
catch: () => new AttachmentError({ uri, message: "Invalid attachment data URL" }),
|
||||
catch: () => new AttachmentError({ uri: label, message: `Invalid attachment data URL: ${label}` }),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { PluginHost } from "@opencode/core/plugin/host"
|
||||
import { SessionEvent } from "@opencode/core/session/event"
|
||||
import { fromPromise } from "@opencode/plugin/promise/adapter"
|
||||
import { Effect } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { PluginTestLayer } from "./plugin/fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
it.effect("exposes session messages through the plugin host", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const bus = yield* Bus.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
const session = yield* host.session.create({})
|
||||
|
||||
const empty = yield* host.message.list({ sessionID: session.id })
|
||||
expect(empty.data).toEqual([])
|
||||
|
||||
yield* bus.publish(SessionEvent.Synthetic, { sessionID: session.id, text: "hello" })
|
||||
const listed = yield* host.message.list({ sessionID: session.id })
|
||||
expect(listed.data.length).toBe(1)
|
||||
const first = listed.data[0]
|
||||
if (!first || first.type !== "synthetic") return yield* Effect.die("expected synthetic message")
|
||||
expect(first.text).toBe("hello")
|
||||
expect(listed.cursor.previous).toBeDefined()
|
||||
expect(listed.cursor.next).toBeDefined()
|
||||
|
||||
const fetched = yield* host.session.message.get({ sessionID: session.id, messageID: first.id })
|
||||
expect(fetched.id).toBe(first.id)
|
||||
|
||||
const missing = yield* host.session.message
|
||||
.get({ sessionID: session.id, messageID: first.id.replace(/^msg_/, "msg_missing_") as typeof first.id })
|
||||
.pipe(Effect.flip)
|
||||
expect(String(missing)).toContain("Message not found")
|
||||
|
||||
const invalid = yield* host.message.list({ sessionID: session.id, cursor: "invalid" }).pipe(Effect.flip)
|
||||
expect(String(invalid)).toContain("Invalid cursor")
|
||||
|
||||
const combined = yield* host.message
|
||||
.list({ sessionID: session.id, cursor: listed.cursor.next, order: "asc" })
|
||||
.pipe(Effect.flip)
|
||||
expect(String(combined)).toContain("Cursor cannot be combined")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("exposes session messages to promise plugins", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const bus = yield* Bus.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
const session = yield* host.session.create({})
|
||||
yield* bus.publish(SessionEvent.Synthetic, { sessionID: session.id, text: "hello promise" })
|
||||
|
||||
const seen: string[] = []
|
||||
const definition = fromPromise({
|
||||
id: "message-list",
|
||||
async setup(ctx) {
|
||||
const listed = await ctx.message.list({ sessionID: session.id })
|
||||
const first = listed.data[0]
|
||||
if (first?.type === "synthetic") seen.push(first.text)
|
||||
if (first) {
|
||||
const fetched = await ctx.session.message.get({ sessionID: session.id, messageID: first.id })
|
||||
if (fetched.type === "synthetic") seen.push(fetched.text)
|
||||
}
|
||||
},
|
||||
})
|
||||
yield* definition.effect(host)
|
||||
|
||||
expect(seen).toEqual(["hello promise", "hello promise"])
|
||||
}),
|
||||
)
|
||||
@@ -102,9 +102,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
transform: () => Effect.die("unused mcp.transform"),
|
||||
reload: () => Effect.die("unused mcp.reload"),
|
||||
},
|
||||
message: overrides.message ?? {
|
||||
list: () => Effect.die("unused message.list"),
|
||||
},
|
||||
permission: overrides.permission ?? {
|
||||
hook: () => Effect.die("unused permission.hook"),
|
||||
list: () => Effect.die("unused permission.list"),
|
||||
@@ -178,9 +175,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
interrupt: overrides.session?.interrupt ?? (() => Effect.die("unused session.interrupt")),
|
||||
wait: overrides.session?.wait ?? (() => Effect.die("unused session.wait")),
|
||||
context: overrides.session?.context ?? (() => Effect.die("unused session.context")),
|
||||
message: overrides.session?.message ?? {
|
||||
get: () => Effect.die("unused session.message.get"),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { LLMClient, LanguageModel, Message, ToolDefinition } from "@opencode/ai"
|
||||
import { LLMClient, LanguageModel, Message, ToolDefinition, Usage } from "@opencode/ai"
|
||||
import { OpenAI } from "@opencode/ai/providers"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
@@ -7,6 +7,7 @@ import { Database } from "@opencode/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { llmClient } from "@opencode/core/effect/app-node-platform"
|
||||
import { Instructions } from "@opencode/core/instructions/index"
|
||||
import { NativeCompactionPlugin } from "@opencode/core/plugin/compaction"
|
||||
import { PluginHooks } from "@opencode/core/plugin/hooks"
|
||||
import { Project } from "@opencode/core/project"
|
||||
import { ProjectTable } from "@opencode/core/project/sql"
|
||||
@@ -26,6 +27,7 @@ import { SessionStore } from "@opencode/core/session/store"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { DateTime, Deferred, Effect, Fiber, Schema } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { host } from "./plugin/host"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
@@ -44,7 +46,8 @@ const it = testEffect(
|
||||
),
|
||||
)
|
||||
|
||||
const setup = Effect.fnUntraced(function* (endpoint = false) {
|
||||
const setup = Effect.fnUntraced(function* (options: { endpoint?: boolean; plugin?: boolean } = {}) {
|
||||
const endpoint = options.endpoint ?? false
|
||||
const db = (yield* Database.Service).db
|
||||
const bus = yield* Bus.Service
|
||||
const inbox = yield* SessionInbox.Service
|
||||
@@ -185,6 +188,7 @@ const setup = Effect.fnUntraced(function* (endpoint = false) {
|
||||
render: { initial: String, changed: (_previous, value) => value, removed: () => "removed" },
|
||||
})
|
||||
yield* InstructionState.prepare(db, bus, instructions, sessionID)
|
||||
if (options.plugin !== false) yield* NativeCompactionPlugin.Plugin.effect(host())
|
||||
yield* hooks.register("session", "model.request", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.headers["x-test-hook"] = event.kind
|
||||
@@ -261,6 +265,7 @@ const setup = Effect.fnUntraced(function* (endpoint = false) {
|
||||
store,
|
||||
hooks,
|
||||
model,
|
||||
compaction,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -348,7 +353,7 @@ it.live(
|
||||
|
||||
it.live("manual and automatic endpoint compaction keep the provider replacement unchanged", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup(true)
|
||||
const fixture = yield* setup({ endpoint: true })
|
||||
yield* fixture.prompt("Original user")
|
||||
expect(yield* fixture.compact).toEqual({ status: "completed" })
|
||||
expect(yield* fixture.automatic).toEqual({ status: "completed" })
|
||||
@@ -445,6 +450,31 @@ it.live("rejects request-hook route rewrites before provider compaction", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("provider compaction fails without a native strategy and persists a registered strategy's window", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup({ plugin: false })
|
||||
yield* fixture.prompt("Original user")
|
||||
expect(yield* fixture.compact).toMatchObject({
|
||||
status: "failed",
|
||||
error: { type: "provider.unsupported-operation", message: expect.stringContaining("openai/openai-responses") },
|
||||
})
|
||||
yield* fixture.compaction.transform((editor) => {
|
||||
editor.native(() =>
|
||||
Effect.succeed({
|
||||
replacement: [Message.assistant("plugin window")],
|
||||
usage: new Usage({ nonCachedInputTokens: 20, outputTokens: 4 }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
expect(yield* fixture.compact).toEqual({ status: "completed" })
|
||||
expect(fixture.state.calls).toBe(0)
|
||||
const installed = yield* fixture.checkpoint
|
||||
expect(installed.provenance).toEqual(SessionProviderContext.provenance(fixture.model)!)
|
||||
expect(SessionProviderContext.decode(installed)).toEqual([Message.assistant("plugin window")])
|
||||
expect(yield* fixture.store.get(fixture.sessionID)).toMatchObject({ tokens: { input: 20, output: 4 } })
|
||||
}),
|
||||
)
|
||||
|
||||
test("retained user budget counts attachments and drops whole oldest messages", () => {
|
||||
const model = SessionRunnerModel.resolved(OpenAI.responses("gpt-5.4-mini"), {
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
|
||||
@@ -501,8 +501,31 @@ describe("Session.prompt", () => {
|
||||
|
||||
expect(error).toMatchObject({
|
||||
_tag: "Session.AttachmentError",
|
||||
uri,
|
||||
message: "Invalid attachment data URL",
|
||||
uri: "image.png",
|
||||
message: "Invalid attachment data URL: image.png",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects oversized inline attachments without echoing their bytes", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* Session.Service
|
||||
const uri = `data:application/octet-stream;base64,${Buffer.alloc(20 * 1024 * 1024 + 1).toString("base64")}`
|
||||
|
||||
const error = yield* session
|
||||
.prompt({
|
||||
sessionID,
|
||||
text: "Inspect this",
|
||||
files: [{ uri }],
|
||||
resume: false,
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(error).toMatchObject({
|
||||
_tag: "Session.AttachmentError",
|
||||
uri: "inline attachment",
|
||||
message: "Attachment exceeds the 20971520 byte limit: inline attachment",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -56,6 +56,7 @@ import { Plugin } from "@opencode/core/plugin"
|
||||
import { PluginHooks } from "@opencode/core/plugin/hooks"
|
||||
import { OptimizePlugin } from "@opencode/core/plugin/optimize"
|
||||
import { IdentityPlugin } from "@opencode/core/plugin/identity"
|
||||
import { NativeCompactionPlugin } from "@opencode/core/plugin/compaction"
|
||||
import { QuestionTool } from "@opencode/core/tool/plugin/question"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { Config } from "@opencode/core/config"
|
||||
@@ -76,7 +77,7 @@ import { SessionSystemPrompt } from "@opencode/core/session/system-prompt"
|
||||
import { ID, Model } from "@opencode/core/model"
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { Cause, Context, Deferred, Effect, Exit, Fiber, Layer, Queue, Schema, Scope, Stream } from "effect"
|
||||
import { Cause, Context, DateTime, Deferred, Effect, Exit, Fiber, Layer, Queue, Schema, Scope, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { asc, desc, eq, sql } from "drizzle-orm"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -470,6 +471,7 @@ const layer = Layer.unwrap(
|
||||
Config.node,
|
||||
Snapshot.node,
|
||||
SessionCompaction.node,
|
||||
LayerNodePlatform.llmClient,
|
||||
SessionRunnerLLM.node,
|
||||
SessionExecution.node,
|
||||
Session.node,
|
||||
@@ -523,6 +525,7 @@ const setup = Effect.gen(function* () {
|
||||
discard: true,
|
||||
})
|
||||
yield* IdentityPlugin.Plugin.effect(pluginHost)
|
||||
yield* NativeCompactionPlugin.Plugin.effect(pluginHost)
|
||||
yield* agents.transform((editor) =>
|
||||
editor.update(Agent.ID.make("build"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
@@ -3391,11 +3394,17 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
scenario("consumes the full provider stream before recording its boundary and settling local tools", function* (s) {
|
||||
yield* s.admit("Echo this")
|
||||
const request = yield* s.llm.gate
|
||||
const tail = yield* Deferred.make<void>()
|
||||
const complete = yield* Deferred.make<void>()
|
||||
const finished = yield* Deferred.make<void>()
|
||||
yield* s.llm.push(
|
||||
Stream.fromIterable(TestLLM.tool("call-streamed", "echo", { text: "hello" })).pipe(
|
||||
Stream.fromIterable(
|
||||
TestLLM.complete(
|
||||
{ reason: { normalized: "tool-calls" }, usage: { outputTokens: 100, reasoningTokens: 80 } },
|
||||
LLMEvent.toolCall({ id: "call-streamed", name: "echo", input: { text: "hello" } }),
|
||||
),
|
||||
).pipe(
|
||||
Stream.concat(
|
||||
Stream.fromEffect(Deferred.succeed(tail, undefined).pipe(Effect.andThen(Deferred.await(complete)))).pipe(
|
||||
Stream.drain,
|
||||
@@ -3413,25 +3422,39 @@ describe("SessionRunnerLLM", () => {
|
||||
)
|
||||
const run = yield* Effect.forkChild(s.resume)
|
||||
|
||||
yield* request.started
|
||||
yield* TestClock.adjust("2 seconds")
|
||||
yield* request.release
|
||||
yield* tools.started
|
||||
yield* Deferred.await(tail)
|
||||
expect(s.requests).toHaveLength(1)
|
||||
expect(yield* recordedEventTypes(sessionID)).not.toContain("session.step.streamed.1")
|
||||
expect(requireAssistant(yield* s.context).time.completed).toBeUndefined()
|
||||
yield* TestClock.adjust("3 seconds")
|
||||
yield* Deferred.succeed(complete, undefined)
|
||||
yield* Fiber.join(streamed)
|
||||
expect(yield* Deferred.isDone(finished)).toBe(true)
|
||||
const assistant = requireAssistant(yield* s.context)
|
||||
expect(assistant.time.streamed).toBeDefined()
|
||||
expect(DateTime.toEpochMillis(assistant.time.streamed!) - DateTime.toEpochMillis(assistant.time.created)).toBe(
|
||||
5_000,
|
||||
)
|
||||
expect(assistant.time.completed).toBeUndefined()
|
||||
expect(assistant.content).toMatchObject([{ type: "tool", state: { status: "running" } }])
|
||||
|
||||
yield* TestClock.adjust("10 seconds")
|
||||
yield* tools.release
|
||||
yield* Fiber.join(run)
|
||||
const events = yield* recordedEventTypes(sessionID)
|
||||
expect(events.indexOf("session.step.streamed.1")).toBeLessThan(events.indexOf("session.tool.success.2"))
|
||||
expect(events.indexOf("session.tool.success.2")).toBeLessThan(events.indexOf("session.step.ended.1"))
|
||||
expect(events.filter((type) => type === "session.step.streamed.1")).toHaveLength(2)
|
||||
yield* replaySessionProjection(sessionID)
|
||||
const replayed = (yield* s.context).find((message) => message.id === assistant.id)
|
||||
expect(replayed).toMatchObject({
|
||||
time: { created: assistant.time.created, streamed: assistant.time.streamed },
|
||||
tokens: { output: 20, reasoning: 80 },
|
||||
})
|
||||
})
|
||||
|
||||
scenario("restores durable reasoning provider metadata in the next request", function* (s) {
|
||||
@@ -5037,14 +5060,18 @@ describe("SessionRunnerLLM", () => {
|
||||
scenario(`bounds jittered exponential backoff before output for ${failure.name}`, function* (s) {
|
||||
yield* s.admit("Retry transport")
|
||||
yield* s.llm.push(TestLLM.failAfter(failure(), LLMEvent.stepStart({ index: 0 })))
|
||||
yield* s.llm.push(TestLLM.text("Recovered", "retry-success"))
|
||||
yield* s.llm.push(
|
||||
Stream.fromEffect(Effect.sleep(400)).pipe(
|
||||
Stream.flatMap(() => Stream.fromIterable(TestLLM.text("Recovered", "retry-success"))),
|
||||
),
|
||||
)
|
||||
|
||||
const scheduled = yield* subscribeRetries(s)
|
||||
const run = yield* s.resume.pipe(Effect.forkChild)
|
||||
yield* Queue.take(scheduled)
|
||||
yield* TestClock.adjust("1599 millis")
|
||||
expect(s.requests).toHaveLength(1)
|
||||
yield* TestClock.adjust("801 millis")
|
||||
yield* TestClock.adjust("1201 millis")
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(s.requests).toHaveLength(2)
|
||||
@@ -5058,6 +5085,10 @@ describe("SessionRunnerLLM", () => {
|
||||
])
|
||||
yield* replaySessionProjection(sessionID)
|
||||
expect((yield* s.context).filter((message) => message.type === "assistant")).toHaveLength(1)
|
||||
const assistant = requireAssistant(yield* s.context)
|
||||
expect(DateTime.toEpochMillis(assistant.time.streamed!) - DateTime.toEpochMillis(assistant.time.created)).toBe(
|
||||
400,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,8 @@ test("does not package external copies of bundled dependencies", () => {
|
||||
expect(pkg.devDependencies.effect).toBe("catalog:")
|
||||
expect(pkg.devDependencies["@effect/platform-node"]).toBe("catalog:")
|
||||
expect(pkg.devDependencies["drizzle-orm"]).toBe("catalog:")
|
||||
expect(pkg.optionalDependencies["msgpackr-extract"]).toBe("3.0.4")
|
||||
// IPC crosses the port by structured clone; no MessagePack runtime or native accelerator ships.
|
||||
expect(Object.keys(pkg.optionalDependencies)).not.toContain("msgpackr-extract")
|
||||
})
|
||||
|
||||
test("keeps PTY binaries without stale native packaging", () => {
|
||||
@@ -101,6 +102,6 @@ test("bundles one Effect runtime and Drizzle while keeping native dependencies e
|
||||
expect(imports).toContain("node:sqlite")
|
||||
expect(chunks.some((chunk) => chunk.dynamicImports.includes("@zip.js/zip.js"))).toBe(true)
|
||||
expect(imports).toContain(`@lydell/node-pty-${process.platform}-${process.arch}`)
|
||||
expect(modules.some((id) => id.includes("/node_modules/msgpackr-extract/"))).toBe(false)
|
||||
expect(chunks.some((chunk) => chunk.code.includes("msgpackr-extract"))).toBe(true)
|
||||
expect(modules.some((id) => id.includes("/node_modules/msgpackr"))).toBe(false)
|
||||
expect(chunks.some((chunk) => chunk.code.includes("msgpackr"))).toBe(false)
|
||||
}, 30_000)
|
||||
|
||||
@@ -58,9 +58,9 @@ const require = __cjs_mod__.createRequire(import.meta.url);
|
||||
},
|
||||
},
|
||||
externalizeDeps: {
|
||||
// Bundle the Effect family together; native MessagePack acceleration stays optional and external.
|
||||
// Bundle the Effect family together.
|
||||
exclude: ["effect", "@effect/platform-node", "@effect/platform-node-shared", "drizzle-orm"],
|
||||
include: [nodePtyPkg, "msgpackr-extract"],
|
||||
include: [nodePtyPkg],
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode/desktop",
|
||||
"private": true,
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
@@ -68,7 +68,6 @@
|
||||
"@lydell/node-pty-linux-arm64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-win32-x64": "1.2.0-beta.12",
|
||||
"msgpackr-extract": "3.0.4"
|
||||
"@lydell/node-pty-win32-x64": "1.2.0-beta.12"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,17 +3,22 @@ import { EventEmitter } from "node:events"
|
||||
import { MessageChannel } from "node:worker_threads"
|
||||
import type { MessagePortMain, WebContents } from "electron"
|
||||
import { Context, Effect, Layer, ManagedRuntime, Option, Queue, Schema, Stream } from "effect"
|
||||
import { Rpc, RpcClient, RpcClientError, RpcGroup, RpcMessage, RpcSerialization, RpcServer } from "effect/unstable/rpc"
|
||||
import { Rpc, RpcClient, RpcClientError, RpcGroup, RpcMessage, RpcServer } from "effect/unstable/rpc"
|
||||
import { Transferable } from "effect/unstable/workers"
|
||||
import { IpcPortHandoff, IpcServerProtocolLive } from "./ipc-transport"
|
||||
|
||||
describe("desktop RPC transport", () => {
|
||||
test("keeps multiple renderer ports independent", async () => {
|
||||
let received: unknown
|
||||
const handlers = TestRpcs.toLayer(
|
||||
Effect.gen(function* () {
|
||||
const handoff = yield* IpcPortHandoff
|
||||
return TestRpcs.of({
|
||||
"test.focused": (_request, context) => Effect.succeed(handoff.sender(context.client.id)?.id === 1),
|
||||
"test.blob.put": ({ data }) => Effect.succeed([...data].join(",")),
|
||||
"test.blob.put": ({ data }) => {
|
||||
received = data
|
||||
return Effect.succeed([...data].join(","))
|
||||
},
|
||||
"test.blob.get": () => Effect.succeed(new Uint8Array([3, 1, 4])),
|
||||
"test.events": () => Stream.make(new TestEvent({ value: "session.new" })),
|
||||
})
|
||||
@@ -34,6 +39,8 @@ describe("desktop RPC transport", () => {
|
||||
expect(focused).toBe(true)
|
||||
expect(unfocused).toBe(false)
|
||||
expect(await putBlob(firstClient, new Uint8Array([2, 7, 1]))).toBe("2,7,1")
|
||||
// Binary payloads arrive as bytes, not as base64 text.
|
||||
expect(received).toBeInstanceOf(Uint8Array)
|
||||
expect(await getBlob(firstClient)).toEqual(new Uint8Array([3, 1, 4]))
|
||||
expect(await firstEvent(firstClient)).toEqual(new TestEvent({ value: "session.new" }))
|
||||
|
||||
@@ -54,8 +61,8 @@ describe("desktop RPC transport", () => {
|
||||
class TestEvent extends Schema.TaggedClass<TestEvent>()("TestEvent", { value: Schema.String }) {}
|
||||
const TestRpcs = RpcGroup.make(
|
||||
Rpc.make("test.focused", { success: Schema.Boolean }),
|
||||
Rpc.make("test.blob.put", { payload: { data: Schema.Uint8Array }, success: Schema.String }),
|
||||
Rpc.make("test.blob.get", { success: Schema.Uint8Array }),
|
||||
Rpc.make("test.blob.put", { payload: { data: Transferable.Uint8Array }, success: Schema.String }),
|
||||
Rpc.make("test.blob.get", { success: Transferable.Uint8Array }),
|
||||
Rpc.make("test.events", { success: TestEvent, stream: true }),
|
||||
)
|
||||
type TestRpcClient = RpcClient.FromGroup<typeof TestRpcs, RpcClientError.RpcClientError>
|
||||
@@ -109,13 +116,9 @@ function clientProtocol(port: MessagePort) {
|
||||
RpcClient.Protocol,
|
||||
RpcClient.Protocol.make(
|
||||
Effect.fnUntraced(function* (writeResponse, clientIds) {
|
||||
const serialization = yield* RpcSerialization.RpcSerialization
|
||||
const parser = serialization.makeUnsafe()
|
||||
const inbound = yield* Queue.unbounded<RpcMessage.FromServerEncoded>()
|
||||
const onMessage = (event: MessageEvent) =>
|
||||
parser
|
||||
.decode(event.data)
|
||||
.forEach((message) => Queue.offerUnsafe(inbound, message as RpcMessage.FromServerEncoded))
|
||||
Queue.offerUnsafe(inbound, event.data as RpcMessage.FromServerEncoded)
|
||||
port.addEventListener("message", onMessage)
|
||||
port.start()
|
||||
yield* Effect.addFinalizer(() =>
|
||||
@@ -131,18 +134,15 @@ function clientProtocol(port: MessagePort) {
|
||||
Effect.forkScoped,
|
||||
)
|
||||
return {
|
||||
codecFor: serialization.codecFor,
|
||||
codecFor: Schema.toCodecJson,
|
||||
send: (_clientId: number, request: RpcMessage.FromClientEncoded) =>
|
||||
Effect.sync(() => {
|
||||
const encoded = parser.encode(request)
|
||||
if (encoded !== undefined) port.postMessage(encoded)
|
||||
}),
|
||||
Effect.sync(() => port.postMessage(request)),
|
||||
supportsAck: true,
|
||||
supportsTransferables: false,
|
||||
}
|
||||
}),
|
||||
),
|
||||
).pipe(Layer.provide(RpcSerialization.layerMsgPack))
|
||||
)
|
||||
}
|
||||
|
||||
function sender(id: number) {
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { MessagePortMain, WebContents } from "electron"
|
||||
import { Context, Effect, Layer, Option, Queue, Stream } from "effect"
|
||||
import { RpcMessage, RpcSerialization, RpcServer } from "effect/unstable/rpc"
|
||||
import { Context, Effect, Layer, Option, Queue, Schema, Stream } from "effect"
|
||||
import { RpcMessage, RpcServer } from "effect/unstable/rpc"
|
||||
import { bindIpcEvents } from "./ipc-events"
|
||||
|
||||
type PortBinding = {
|
||||
readonly id: number
|
||||
readonly sender: WebContents
|
||||
readonly port: MessagePortMain
|
||||
readonly parser: RpcSerialization.Parser
|
||||
readonly onMessage: (event: Electron.MessageEvent) => void
|
||||
readonly onClose: () => void
|
||||
readonly unbindEvents: Effect.Effect<void>
|
||||
@@ -20,6 +19,9 @@ type Handoff = {
|
||||
|
||||
export class IpcPortHandoff extends Context.Service<IpcPortHandoff, Handoff>()("opencode/desktop/IpcPortHandoff") {}
|
||||
|
||||
// Messages cross the port by structured clone, like Effect's worker protocol: no serialization
|
||||
// layer, so binary payloads stay binary and nothing is packed into a shared buffer. Electron's
|
||||
// MessagePortMain can only transfer ports, so byte payloads are cloned in both directions.
|
||||
export const IpcServerProtocolLive = Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const handoffs = yield* Queue.unbounded<readonly [WebContents, MessagePortMain]>()
|
||||
@@ -30,7 +32,6 @@ export const IpcServerProtocolLive = Layer.unwrap(
|
||||
RpcServer.Protocol,
|
||||
RpcServer.Protocol.make(
|
||||
Effect.fnUntraced(function* (writeRequest) {
|
||||
const serialization = yield* RpcSerialization.RpcSerialization
|
||||
const disconnects = yield* Queue.unbounded<number>()
|
||||
const inbound = yield* Queue.unbounded<readonly [number, RpcMessage.FromClientEncoded]>()
|
||||
const runFork = Effect.runForkWith(yield* Effect.context())
|
||||
@@ -58,21 +59,12 @@ export const IpcServerProtocolLive = Layer.unwrap(
|
||||
}
|
||||
|
||||
const id = nextClientId++
|
||||
const parser = serialization.makeUnsafe()
|
||||
const onMessage = (event: Electron.MessageEvent) => {
|
||||
try {
|
||||
parser
|
||||
.decode(event.data)
|
||||
.forEach((message) =>
|
||||
Queue.offerUnsafe(inbound, [id, message as RpcMessage.FromClientEncoded] as const),
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
Queue.offerUnsafe(inbound, [id, event.data as RpcMessage.FromClientEncoded] as const)
|
||||
}
|
||||
const onClose = () => runFork(disconnect(id))
|
||||
const unbindEvents = yield* bindIpcEvents(sender.id)
|
||||
const binding = { id, sender, port, parser, onMessage, onClose, unbindEvents }
|
||||
const binding = { id, sender, port, onMessage, onClose, unbindEvents }
|
||||
bindings.set(id, binding)
|
||||
senderBindings.set(sender.id, id)
|
||||
port.on("message", onMessage)
|
||||
@@ -92,14 +84,11 @@ export const IpcServerProtocolLive = Layer.unwrap(
|
||||
yield* Effect.addFinalizer(() => Effect.forEach([...bindings.keys()], disconnect, { discard: true }))
|
||||
|
||||
return {
|
||||
codecFor: serialization.codecFor,
|
||||
codecFor: Schema.toCodecJson,
|
||||
disconnects,
|
||||
send: (clientId, response) =>
|
||||
Effect.sync(() => {
|
||||
const binding = bindings.get(clientId)
|
||||
if (!binding) return
|
||||
const encoded = binding.parser.encode(response)
|
||||
if (encoded !== undefined) binding.port.postMessage(encoded)
|
||||
bindings.get(clientId)?.port.postMessage(response)
|
||||
}),
|
||||
end: disconnect,
|
||||
clientIds: Effect.sync(() => new Set(bindings.keys())),
|
||||
@@ -123,4 +112,4 @@ export const IpcServerProtocolLive = Layer.unwrap(
|
||||
}),
|
||||
)
|
||||
}),
|
||||
).pipe(Layer.provide(RpcSerialization.layerMsgPack))
|
||||
)
|
||||
|
||||
@@ -98,8 +98,10 @@ export function createDraftStore(
|
||||
orphans = true
|
||||
return id
|
||||
},
|
||||
getBlob(id: string): Uint8Array | null {
|
||||
return db.select({ data: blobs.data }).from(blobs).where(eq(blobs.id, id)).get()?.data ?? null
|
||||
getBlob(id: string): Uint8Array<ArrayBuffer> | null {
|
||||
const data = db.select({ data: blobs.data }).from(blobs).where(eq(blobs.id, id)).get()?.data
|
||||
// node:sqlite allocates a dedicated ArrayBuffer per BLOB column value.
|
||||
return data ? (data as Uint8Array<ArrayBuffer>) : null
|
||||
},
|
||||
flush: writer.flush,
|
||||
close: writer.close,
|
||||
|
||||
@@ -17,6 +17,8 @@ protocol.registerSchemesAsPrivileged([
|
||||
standard: true,
|
||||
supportFetchAPI: true,
|
||||
stream: true,
|
||||
// Let Chromium keep V8 bytecode for the renderer bundle between launches.
|
||||
codeCache: true,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Context, Effect, Layer, ManagedRuntime, Queue, Stream } from "effect"
|
||||
import { RpcClient, RpcMessage, RpcSerialization } from "effect/unstable/rpc"
|
||||
import { Context, Effect, Layer, ManagedRuntime, Queue, Schema, Stream } from "effect"
|
||||
import { RpcClient, RpcMessage } from "effect/unstable/rpc"
|
||||
import { DesktopRpcs, type DesktopRpcClient } from "../shared/ipc-rpc"
|
||||
import type { DesktopEvent } from "../shared/ipc-rpc/events"
|
||||
import { IpcTransportPort } from "../shared/ipc-transport"
|
||||
@@ -79,22 +79,17 @@ export function listen<Tag extends EventTag>(tag: Tag, listener: (value: EventVa
|
||||
}
|
||||
}
|
||||
|
||||
// Structured clone over the port, like Effect's worker protocol: no serialization layer, so binary
|
||||
// payloads stay binary. Buffers are cloned rather than transferred: Electron's MessagePortMain
|
||||
// drops transferred ArrayBuffers, so a request carrying one would never arrive.
|
||||
function clientProtocol(value: MessagePort) {
|
||||
return Layer.effect(
|
||||
RpcClient.Protocol,
|
||||
RpcClient.Protocol.make(
|
||||
Effect.fnUntraced(function* (writeResponse, clientIds) {
|
||||
const serialization = yield* RpcSerialization.RpcSerialization
|
||||
const parser = serialization.makeUnsafe()
|
||||
const inbound = yield* Queue.unbounded<RpcMessage.FromServerEncoded>()
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
try {
|
||||
parser
|
||||
.decode(event.data)
|
||||
.forEach((message) => Queue.offerUnsafe(inbound, message as RpcMessage.FromServerEncoded))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
Queue.offerUnsafe(inbound, event.data as RpcMessage.FromServerEncoded)
|
||||
}
|
||||
value.addEventListener("message", onMessage)
|
||||
value.start()
|
||||
@@ -111,16 +106,15 @@ function clientProtocol(value: MessagePort) {
|
||||
Effect.forkScoped,
|
||||
)
|
||||
return {
|
||||
codecFor: serialization.codecFor,
|
||||
codecFor: Schema.toCodecJson,
|
||||
send: (_clientId, request) =>
|
||||
Effect.sync(() => {
|
||||
const encoded = parser.encode(request)
|
||||
if (encoded !== undefined) value.postMessage(encoded)
|
||||
value.postMessage(request)
|
||||
}),
|
||||
supportsAck: true,
|
||||
supportsTransferables: false,
|
||||
}
|
||||
}),
|
||||
),
|
||||
).pipe(Layer.provide(RpcSerialization.layerMsgPack))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { Rpc, RpcGroup } from "effect/unstable/rpc"
|
||||
import { Transferable } from "effect/unstable/workers"
|
||||
|
||||
const OptionalString = Schema.optional(Schema.String)
|
||||
const PickerOptions = Schema.Struct({
|
||||
@@ -18,7 +19,7 @@ const PickedFiles = Schema.Struct({
|
||||
token: Schema.String,
|
||||
files: Schema.Array(Schema.Struct({ path: Schema.String, name: Schema.String, size: Schema.Number })),
|
||||
})
|
||||
const ClipboardImage = Schema.Struct({ buffer: Schema.Uint8Array, width: Schema.Number, height: Schema.Number })
|
||||
const ClipboardImage = Schema.Struct({ buffer: Transferable.Uint8Array, width: Schema.Number, height: Schema.Number })
|
||||
|
||||
export const FilesOpenDirectoryPicker = Rpc.make("FilesOpenDirectoryPicker", {
|
||||
payload: { options: Schema.optional(PickerOptions) },
|
||||
@@ -30,7 +31,7 @@ export const FilesOpenFilePicker = Rpc.make("FilesOpenFilePicker", {
|
||||
})
|
||||
export const FilesReadPickedFile = Rpc.make("FilesReadPickedFile", {
|
||||
payload: { token: Schema.String, path: Schema.String },
|
||||
success: Schema.Uint8Array,
|
||||
success: Transferable.Uint8Array,
|
||||
})
|
||||
export const FilesReleasePickedFiles = Rpc.make("FilesReleasePickedFiles", {
|
||||
payload: { token: Schema.String },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { Rpc, RpcGroup } from "effect/unstable/rpc"
|
||||
import { Transferable } from "effect/unstable/workers"
|
||||
|
||||
export const StorageItems = Rpc.make("StorageItems", {
|
||||
payload: { name: Schema.String },
|
||||
@@ -24,12 +25,12 @@ export const DraftsSet = Rpc.make("DraftsSet", {
|
||||
})
|
||||
export const DraftsDelete = Rpc.make("DraftsDelete", { payload: { key: Schema.String } })
|
||||
export const DraftsPutBlob = Rpc.make("DraftsPutBlob", {
|
||||
payload: { data: Schema.Uint8Array },
|
||||
payload: { data: Transferable.Uint8Array },
|
||||
success: Schema.String,
|
||||
})
|
||||
export const DraftsGetBlob = Rpc.make("DraftsGetBlob", {
|
||||
payload: { id: Schema.String },
|
||||
success: Schema.NullOr(Schema.Uint8Array),
|
||||
success: Schema.NullOr(Transferable.Uint8Array),
|
||||
})
|
||||
|
||||
export const StorageRpcs = RpcGroup.make(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"name": "@opencode/http-recorder",
|
||||
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"description": "OpenCode's desktop browser plugin",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user