Compare commits

..
8 Commits
144 changed files with 1016 additions and 2521 deletions
+36 -35
View File
@@ -32,7 +32,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.8",
"version": "2.0.6",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -54,7 +54,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.8",
"version": "2.0.6",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -113,7 +113,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.8",
"version": "2.0.6",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -177,7 +177,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.8",
"version": "2.0.6",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -203,7 +203,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.8",
"version": "2.0.6",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -217,7 +217,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.8",
"version": "2.0.6",
"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.8",
"version": "2.0.6",
"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.8",
"version": "2.0.6",
"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.8",
"version": "2.0.6",
"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.8",
"version": "2.0.6",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -341,7 +341,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.8",
"version": "2.0.6",
"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.8",
"version": "2.0.6",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "4.1.2",
@@ -456,11 +456,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",
},
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.8",
"version": "2.0.6",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -497,7 +498,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.8",
"version": "2.0.6",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -513,7 +514,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.8",
"version": "2.0.6",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -532,7 +533,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.8",
"version": "2.0.6",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -545,7 +546,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.8",
"version": "2.0.6",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -559,7 +560,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.8",
"version": "2.0.6",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -574,7 +575,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.8",
"version": "2.0.6",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -613,7 +614,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.8",
"version": "2.0.6",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -643,7 +644,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.8",
"version": "2.0.6",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -658,7 +659,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.8",
"version": "2.0.6",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -682,7 +683,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.8",
"version": "2.0.6",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -703,7 +704,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.8",
"version": "2.0.6",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -725,7 +726,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.8",
"version": "2.0.6",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -760,7 +761,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.8",
"version": "2.0.6",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -780,7 +781,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.8",
"version": "2.0.6",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -814,7 +815,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.8",
"version": "2.0.6",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -833,7 +834,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.8",
"version": "2.0.6",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -879,7 +880,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.8",
"version": "2.0.6",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -893,7 +894,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.8",
"version": "2.0.6",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -928,7 +929,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.8",
"version": "2.0.6",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -963,7 +964,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.8",
"version": "2.0.6",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -996,7 +997,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.8",
"version": "2.0.6",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1037,7 +1038,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.8",
"version": "2.0.6",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-TlCPtuIvTNF01fLI0yHCLzVkx53yF9xxvAObDMGeo8M=",
"aarch64-linux": "sha256-aEjyPIXHeA5w1iZXbPKJAOIJQBfXg+LAx/y7C5uFz4g=",
"aarch64-darwin": "sha256-lthMTio2qNy+H2tL9cAPmqC41O+wdSADI61cnW4dIb0=",
"x86_64-darwin": "sha256-ONFbRQnwzOYHo0Pyh327QfJG2uzqzNEn/SS2vbYYMdM="
"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="
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.8",
"version": "2.0.6",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.8",
"version": "2.0.6",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
@@ -485,7 +485,6 @@ export function stepStarted(message: SessionMessageAssistant) {
assistantMessageID: message.id,
agent: message.agent,
model: message.model,
started: message.time.created,
})
}
@@ -108,15 +108,6 @@ 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")
@@ -305,12 +296,7 @@ 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"; currentDirectory?: string } = {},
) {
const currentDirectory = options.currentDirectory ?? directory
async function openDraft(page: Page, worktree = "main", options: { git?: boolean; direction?: "ltr" | "rtl" } = {}) {
const project = {
id: "proj_new_summary",
worktree: directory,
@@ -329,7 +315,7 @@ async function openDraft(
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
const state: { fail: boolean; hold?: Promise<void>; holdDirectory?: string } = { fail: false }
await mockOpenCodeServer(page, {
directory: currentDirectory,
directory,
project,
sessions,
provider: {
@@ -456,7 +442,7 @@ async function openDraft(
},
)
await page.addInitScript(
({ directory, currentDirectory, server, draftID, secondDraftID, worktree }) => {
({ directory, server, draftID, secondDraftID, worktree }) => {
if (!localStorage.getItem("opencode.global.dat:server"))
localStorage.setItem(
"opencode.global.dat:server",
@@ -469,12 +455,12 @@ async function openDraft(
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "draft", draftID, server, directory: currentDirectory, worktree },
{ type: "draft", draftID: secondDraftID, server, directory: currentDirectory, worktree },
{ type: "draft", draftID, server, directory, worktree },
{ type: "draft", draftID: secondDraftID, server, directory, worktree },
]),
)
},
{ directory, currentDirectory, server, draftID, secondDraftID, worktree },
{ directory, 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, started: Date.now() })
mock.emit("session.step.started", { sessionID, assistantMessageID: assistantID, agent: "build", model })
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, started: Date.now() })
mock.emit("session.step.started", { ...later, agent: "build", model })
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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.8",
"version": "2.0.6",
"description": "",
"type": "module",
"exports": {
@@ -16,86 +16,6 @@
}
}
[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,12 +1,8 @@
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 { ComposerPrompt } from "../types"
import type { ImageAttachmentPart, PathAttachmentPart } from "../state"
import type { AttachmentDestination } from "./destination"
import { uploads } from "./uploads"
import type { ComposerAttachment, ComposerPrompt } from "../types"
type PromptTarget = {
current: () => ComposerPrompt
@@ -20,11 +16,9 @@ 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
@@ -49,23 +43,9 @@ 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
@@ -84,40 +64,17 @@ export function createComposerAttachments(
input.duplicate()
return true
}
const attachment: ImageAttachmentPart = { type: "image", id: uuid(), filename: file.name, sourcePath, mime, blob }
const attachment: ComposerAttachment = {
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
@@ -196,11 +153,6 @@ 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()
@@ -213,16 +165,6 @@ 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"],
@@ -240,8 +182,8 @@ const textMimes = new Set([
"application/yaml",
])
// 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.
// 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.
async function attachmentMime(file: File) {
const type = file.type.split(";", 1)[0]?.trim().toLowerCase() ?? ""
if (imageMimes.has(type) || type === "application/pdf") return type
@@ -0,0 +1,70 @@
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
},
})
}
@@ -1,57 +0,0 @@
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)
})
}
@@ -1,116 +0,0 @@
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,
images: [],
attachments: [],
text: value,
sessionDirectory: "C:/repo",
})
+6 -6
View File
@@ -7,7 +7,7 @@ import type {
ComposerPersistedState,
ComposerPrompt,
} from "../types"
import { isAttachment, promptLength } from "../prompt-parts"
import { 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(isAttachment),
...state.prompt.filter((part) => part.type === "image"),
],
cursor: content.length,
retry: undefined,
@@ -83,7 +83,7 @@ export function createComposerEditorActions(input: ComposerStateStoreInput) {
clearRetry()
},
removeAttachment(id: string) {
setStore()("prompt", (parts) => parts.filter((part) => !isAttachment(part) || part.id !== id))
setStore()("prompt", (parts) => parts.filter((part) => part.type !== "image" || 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 (isAttachment(part)) return [part]
if (part.type === "image") 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 (isAttachment(part)) return [part]
if (part.type === "image") 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 (isAttachment(part)) return part
if (part.type === "image") return part
const next = { ...part, start: offset, end: offset + part.content.length }
offset = next.end
return next
+14 -59
View File
@@ -1,10 +1,9 @@
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
import { createEffect, createMemo, 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"
@@ -13,8 +12,6 @@ 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"
@@ -27,7 +24,6 @@ import type {
ComposerSuggestion,
} from "../types"
import type { ComposerEditorModel, ComposerSelectControl } from "./interaction"
import { isAttachment } from "../prompt-parts"
import "../attachments/attachments.css"
import "./editor.css"
@@ -152,13 +148,11 @@ 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)}
/>
@@ -197,9 +191,9 @@ export function ComposerEditor(props: ComposerEditorProps) {
onInput={(event) => {
const cursor = composerCursor(event.currentTarget)
const prompt = parseComposerEditor(event.currentTarget)
const attachments = props.controller.parts().filter(isAttachment)
const images = props.controller.parts().filter((part) => part.type === "image")
localInput = true
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...attachments], cursor)
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor)
}}
onKeyDown={(event) => {
if (!view.draftOnly && props.controller.onKeyDown(event)) return
@@ -354,7 +348,7 @@ function renderComposerEditor(editor: HTMLDivElement, prompt: ComposerPrompt) {
const active = document.activeElement === editor
editor.replaceChildren(
...prompt.flatMap<Node>((part) => {
if (isAttachment(part)) return []
if (part.type === "image") return []
if (part.type === "text") return [document.createTextNode(part.content)]
const mention = document.createElement("span")
mentionParts.set(mention, part)
@@ -481,22 +475,17 @@ 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.uploads?.length ?? 0) > 0 || (props.comments?.length ?? 0) > 0}
>
<Show when={props.attachments.length > 0 || (props.comments?.length ?? 0) > 0}>
<div data-component="composer-attachments" data-slot="composer-attachments" class="relative">
<div
data-slot="composer-attachments-scroll"
@@ -533,34 +522,22 @@ export function ComposerAttachments(props: {
<For each={props.attachments}>
{(attachment) => (
<div class="relative group shrink-0">
<Tooltip
value={attachment.type === "path" ? attachment.path : attachment.filename}
placement="top"
contentClass="break-all"
>
<Tooltip value={attachment.filename} placement="top" contentClass="break-all">
<Show
when={attachment.type === "image" && attachment.mime.startsWith("image/") ? attachment : undefined}
when={attachment.mime.startsWith("image/")}
fallback={
<AttachmentCard title={attachment.filename}>
{typeLabel(attachment.filename, attachment.mime, i18n.t("ui.common.file"))}
</AttachmentCard>
}
>
{(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" />
</>
)
}}
<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" />
</Show>
</Tooltip>
<button
@@ -574,28 +551,6 @@ 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,7 +2,6 @@ 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,
@@ -19,7 +18,7 @@ import {
type ComposerInteractionCommand,
type ComposerInteractionEvent,
} from "../suggestions/machine"
import { clonePrompt, isAttachment, promptLength } from "../prompt-parts"
import { clonePrompt, promptLength } from "../prompt-parts"
import type { ComposerQueue } from "../adapter"
export type ComposerSelectControl = {
@@ -75,7 +74,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 (isAttachment(part)) return false
if (part.type === "image") return false
if (part.type === "file" || part.type === "agent") {
draft.addMention(part)
return true
@@ -170,7 +169,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(isAttachment),
draft.state.prompt.filter((part): part is ComposerAttachment => part.type === "image"),
0,
)
}
@@ -316,13 +315,7 @@ export function createComposerEditor(input: {
return draft.state.context.items.filter((item) => !!item.comment?.trim())
},
attachments(): ComposerAttachment[] {
return draft.state.prompt.filter(isAttachment)
},
uploads(): Upload[] {
return attachments?.pending() ?? []
},
cancelUpload(id: string) {
attachments?.cancel(id)
return draft.state.prompt.filter((part): part is ComposerAttachment => part.type === "image")
},
toggleContext(id: string) {
dispatch({ type: "context.active", id })
@@ -343,12 +336,11 @@ 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(isAttachment)) return true
if (persisted.prompt.some((part) => part.type === "image")) return true
if (persisted.context.items.some((item) => !!item.comment?.trim())) return true
return persisted.prompt.some((part) => "content" in part && !!part.content.trim())
},
@@ -377,7 +369,6 @@ 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" })
},
+4 -4
View File
@@ -1,6 +1,6 @@
import type { Prompt } from "@/composer/state"
import type { SelectedLineRange } from "@/workspaces/files/model"
import { clonePrompt, isAttachment } from "../prompt-parts"
import { clonePrompt } 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 hasAttachments = prompt.some(isAttachment)
const hasImages = prompt.some((part) => part.type === "image")
const hasComments = comments.some((comment) => !!comment.comment.trim())
if (!text && !hasAttachments && !hasComments) return entries
if (!text && !hasImages && !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 (isAttachment(partA) && partA.id !== (isAttachment(partB) ? partB.id : "")) return false
if (partA.type === "image" && partA.id !== (partB.type === "image" ? partB.id : "")) return false
}
if (entryA.comments.length !== entryB.comments.length) return false
for (let i = 0; i < entryA.comments.length; i++) {
+6 -17
View File
@@ -11,19 +11,18 @@ 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 { isAttachment } from "./prompt-parts"
import type { ImageAttachmentPart } from "./state"
import type { PromptHistoryComment } from "./history/entry"
import { createComposerHistory } from "./history/store"
import { composerPlaceholder } from "./placeholder"
import { createComposerSubmit } from "./submit"
import { useAttachmentDestination } from "./attachments/destination"
import { useAttachmentDestination } from "./attachments/deliver"
export type ComposerModel = ComposerEditorModel & {
readonly model: ComposerControls["model"]
@@ -74,7 +73,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
}, [])
})
const attachments = createMemo(() =>
prompt.current().filter(isAttachment),
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
)
const commentCount = createMemo(() => {
if (mode() === "shell") return 0
@@ -267,6 +266,7 @@ 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,12 +321,8 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
onContextRemove(item) {
if (item?.commentID) comments.remove(item.path, item.commentID)
},
openAttachment: (attachment) => {
if (attachment.type !== "image") return
void resolveBlobUrl(attachment.blob).then((src) => {
if (src) dialog.show(() => createComponent(ImagePreview, { src, alt: attachment.filename }))
})
},
openAttachment: (attachment) =>
dialog.show(() => createComponent(ImagePreview, { src: attachment.blob.url, alt: attachment.filename })),
openContext(key) {
const item = controller.contextItem(key)
if (item) openComment(item, adapter.controls(), layout, files, comments)
@@ -344,15 +340,8 @@ 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",
+2 -7
View File
@@ -1,9 +1,4 @@
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"
}
import type { Prompt } from "./state"
export function clonePrompt(prompt: Prompt): Prompt {
return prompt.map((part) =>
@@ -22,7 +17,7 @@ export function appendPrompt(prompt: Prompt, following: Prompt): Prompt {
...clonePrompt(prompt),
{ type: "text", content: "\n\n", start, end: offset },
...clonePrompt(following).map((part) =>
isAttachment(part) ? part : { ...part, start: part.start + offset, end: part.end + offset },
part.type === "image" ? part : { ...part, start: part.start + offset, end: part.end + offset },
),
]
}
+22 -17
View File
@@ -1,10 +1,15 @@
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>) {
return { type: "image" as const, id: `img_${filename}`, filename, mime, dataUrl: `data:${mime};base64,AAA`, ...extra }
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`,
}
}
describe("buildPromptRequest", () => {
@@ -25,7 +30,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
images: [inline("a.png", "image/png")],
attachments: [inline("a.png", "image/png")],
text: "hello @src/foo.ts @planner",
sessionDirectory: "/repo",
})
@@ -47,7 +52,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
context: [],
images: [inline("a.png", "image/png"), inline("b.pdf", "application/pdf")],
attachments: [inline("a.png", "image/png"), inline("b.pdf", "application/pdf")],
text: "check these",
sessionDirectory: "/repo",
})
@@ -62,7 +67,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt: [],
context: [],
images: [
attachments: [
inline("opencode.global.dat", "text/plain", {
sourcePath: "C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
}),
@@ -90,7 +95,7 @@ describe("buildPromptRequest", () => {
},
],
context: [],
images: [],
attachments: [],
text: "@docs",
sessionDirectory: "/repo/app",
})
@@ -112,7 +117,7 @@ describe("buildPromptRequest", () => {
{ key: "ctx:dup", type: "file", path: "src/foo.ts" },
{ key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" },
],
images: [],
attachments: [],
text: "@src/foo.ts",
sessionDirectory: "/repo",
})
@@ -134,7 +139,7 @@ describe("buildPromptRequest", () => {
comment: "Compare with @src/shared.ts and @src/review.ts.",
},
],
images: [],
attachments: [],
text: "look",
sessionDirectory: "/repo",
})
@@ -150,7 +155,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
images: [],
attachments: [],
text: "@src\\foo.ts",
sessionDirectory: "D:\\projects\\myapp", // Windows path
})
@@ -171,7 +176,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
images: [],
attachments: [],
text: "@file#name.txt",
sessionDirectory: "C:\\Users\\test\\Documents", // Windows path
})
@@ -192,7 +197,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
images: [],
attachments: [],
text: "@src/app.ts",
sessionDirectory: "/home/user/project",
})
@@ -206,7 +211,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
images: [],
attachments: [],
text: "@README.md",
sessionDirectory: "/Users/kelvin/Projects/opencode",
})
@@ -221,7 +226,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" },
],
images: [],
attachments: [],
text: "test",
sessionDirectory: "D:\\workspace\\app",
})
@@ -243,7 +248,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
images: [],
attachments: [],
text: "@D:\\other\\project\\file.ts",
sessionDirectory: "C:\\current\\project",
})
@@ -270,7 +275,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
images: [],
attachments: [],
text: "@src\\App.tsx",
sessionDirectory: "C:\\project",
})
@@ -295,7 +300,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
images: [],
attachments: [],
text: "@..\\..\\shared\\util.ts",
sessionDirectory: "C:\\projects\\myapp\\src",
})
@@ -325,7 +330,7 @@ describe("buildPromptRequest", () => {
},
],
context: [],
images: [],
attachments: [],
text: "@review",
sessionDirectory: "/repo",
})
+11 -11
View File
@@ -1,13 +1,14 @@
import { getFilename } from "@opencode/util/path"
import type { FileSelection } from "@/workspaces/files/model"
import { encodeFilePath } from "@/workspaces/files/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, PathAttachmentPart, Prompt, SkillPart } from "@/composer/state"
import type { AgentPart, FileAttachmentPart, 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 = {
@@ -34,7 +35,7 @@ type ContextFile = {
type BuildPromptRequestInput = {
prompt: Prompt
context: ContextFile[]
images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[]
attachments: DeliveredAttachment[]
text: string
sessionDirectory: string
}
@@ -62,7 +63,6 @@ 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.images.map((attachment) => ({
uri: attachment.dataUrl,
mime: attachment.mime,
name: attachment.sourcePath ?? attachment.filename,
}))
const inline = input.attachments.flatMap((item) =>
item.type === "inline"
? [{ uri: item.dataUrl, mime: item.attachment.mime, name: item.attachment.sourcePath ?? item.attachment.filename }]
: [],
)
// Like comments, path references reach the model as text and the message UI through metadata.
const attachments = input.prompt
.filter(isPathAttachment)
.map((part) => ({ name: part.filename, mime: part.mime, path: part.path }))
const attachments = input.attachments.flatMap((item) =>
item.type === "path" ? [{ name: item.attachment.filename, mime: item.attachment.mime, path: item.path }] : [],
)
return {
text: [
+2 -6
View File
@@ -136,7 +136,7 @@ describe("composer persistence schemas", () => {
).toEqual(value)
})
test("migrates inline images, keeps store references without a URL, and never encodes dataUrl", () => {
test("migrates inline images but never encodes dataUrl or unresolved references", () => {
const value = Schema.decodeUnknownSync(
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
)({
@@ -149,16 +149,12 @@ describe("composer persistence schemas", () => {
{ ...image, blob: { id: "missing" }, dataUrl: "data:image/png;base64,YQ==" },
],
})
expect(value.prompt).toHaveLength(6)
expect(value.prompt).toHaveLength(3)
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(
+4 -22
View File
@@ -61,12 +61,11 @@ const ImageFields = {
}
const Image = Persistence.struct({
...ImageFields,
// 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:|$)/)) }),
blob: Schema.Struct({ id: Schema.NonEmptyString, url: Schema.String.check(Schema.isPattern(/^(blob:|data:)/)) }),
})
// 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.
// Draft storage hydrates content-addressed blobs before this codec runs. Legacy
// inline data remains usable, but unresolved references are not renderable.
export const ImageAttachmentPart = Schema.Struct({
...ImageFields,
blob: Persistence.optional(
@@ -95,24 +94,7 @@ export const ImageAttachmentPart = Schema.Struct({
)
export type ImageAttachmentPart = typeof ImageAttachmentPart.Type
// 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 const ContentPart = Schema.Union([TextPart, FileAttachmentPart, AgentPart, SkillPart, ImageAttachmentPart])
export type ContentPart = typeof ContentPart.Type
export const Prompt = Persistence.array(ContentPart)
export type Prompt = typeof Prompt.Type
-9
View File
@@ -88,15 +88,6 @@ 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",
-1
View File
@@ -23,7 +23,6 @@ export type {
FileAttachmentPart,
FileContextItem,
ImageAttachmentPart,
PathAttachmentPart,
Prompt,
PromptModel,
SkillPart,
@@ -1,5 +1,5 @@
import type { ComposerState, ContextItem, Prompt } from "./state"
import { appendPrompt, clonePrompt, isAttachment } from "./prompt-parts"
import { appendPrompt, clonePrompt } 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) => isAttachment(part) || part.content.length > 0))
if (preserveDraft && target.current().some((part) => part.type === "image" || part.content.length > 0))
following = clonePrompt(target.current())
}
if (!following) target.reset()
+10
View File
@@ -3,6 +3,7 @@ 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"
@@ -48,6 +49,14 @@ 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) {} },
@@ -64,6 +73,7 @@ function submitInput(
resetHistory() {},
setMode() {},
closePopover() {},
destination: () => destination,
notify,
comments: { capture: () => [], clear() {}, restore() {} },
})
+32 -32
View File
@@ -8,8 +8,7 @@ import type { ComposerAdapter, ComposerDelivery, ComposerSelection, ComposerSess
import { createComposerSubmission } from "./submission-state"
import { buildPromptRequest } from "./request"
import { setCursorPosition } from "./editor/dom"
import { blobDataUrl, resolveBlobUrl } from "@/runtime/persistence/drafts"
import { isAttachment } from "./prompt-parts"
import { deliverAttachments, type AttachmentDestination } from "./attachments/deliver"
import type { ModelSelection } from "@/providers/models/selection"
const submitting = new WeakSet<object>()
@@ -35,6 +34,7 @@ type ComposerSubmitInput = {
resetHistory: () => void
setMode: (mode: "normal" | "shell") => void
closePopover: () => void
destination: () => AttachmentDestination
delivery?: (alternate: boolean) => ComposerDelivery
notify: {
missingSelection: () => void
@@ -59,22 +59,12 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
selection: item.selection ? { ...item.selection } : undefined,
})),
})
const read = readSubmission(input, submission.prompt, submission.context, options?.alternate ?? false)
if (!read) {
const value = readSubmission(input, submission.prompt, submission.context, options?.alternate ?? false)
if (!value) {
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.
@@ -97,10 +87,16 @@ 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.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.destination(),
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 }),
)
@@ -133,9 +129,13 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
if (command) {
clearSubmission(input, submission)
void sendCommand(session, value, command, input.adapter.controls().model.selection.trackSessionCommit).catch(
(error) => failSubmission(input, session, "command", error, restore, value.id),
)
void sendCommand(
session,
value,
command,
input.destination(),
input.adapter.controls().model.selection.trackSessionCommit,
).catch((error) => failSubmission(input, session, "command", error, restore, value.id))
return
}
} finally {
@@ -162,9 +162,6 @@ 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()
? [
@@ -199,7 +196,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() && !prompt.some(isAttachment) && comments === 0) return
if (!text.trim() && images.length === 0 && comments === 0) return
const controls = input.adapter.controls()
const model = controls.model.selection.current()
@@ -307,9 +304,10 @@ async function sendCommand(
session: ComposerSession,
value: ComposerSubmission,
command: { command: string; arguments: string },
destination: AttachmentDestination,
track?: ModelSelection["trackSessionCommit"],
) {
const request = await buildSubmissionRequest(session, value)
const request = await buildSubmissionRequest(session, value, destination)
// 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({
@@ -348,10 +346,11 @@ async function applySelection(
async function sendPrompt(
session: ComposerSession,
value: ComposerSubmission,
destination: AttachmentDestination,
track: ModelSelection["trackSessionCommit"] | undefined,
onAdmit: () => void,
) {
const request = await buildSubmissionRequest(session, value)
const request = await buildSubmissionRequest(session, value, destination)
// 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
@@ -385,14 +384,15 @@ async function sendPrompt(
await sending
}
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) })),
)
async function buildSubmissionRequest(
session: ComposerSession,
value: ComposerSubmission,
destination: AttachmentDestination,
) {
return buildPromptRequest({
prompt: value.prompt,
context: value.context,
images,
attachments: await deliverAttachments(value.images, destination),
text: value.text,
sessionDirectory: session.directory,
})
@@ -1,5 +1,4 @@
import type { ComposerHistoryEntry, ComposerPersistedState, ComposerSuggestion } from "../types"
import { isAttachment } from "../prompt-parts"
export type ComposerInteractionState = {
mode: "normal" | "shell"
@@ -237,7 +236,7 @@ function populated(persisted: ComposerPersistedState) {
return (
!!promptText(persisted).trim() ||
persisted.context.items.length > 0 ||
persisted.prompt.some((part) => part.type === "file" || isAttachment(part))
persisted.prompt.some((part) => part.type === "file" || part.type === "image")
)
}
+2 -10
View File
@@ -1,17 +1,9 @@
import type {
AgentPart,
ComposerStore,
FileAttachmentPart,
ImageAttachmentPart,
PathAttachmentPart,
Prompt,
SkillPart,
} from "./state"
import type { AgentPart, ComposerStore, FileAttachmentPart, ImageAttachmentPart, Prompt, SkillPart } from "./state"
export type ComposerFilePart = FileAttachmentPart
export type ComposerAgentPart = AgentPart
export type ComposerSkillPart = SkillPart
export type ComposerAttachment = ImageAttachmentPart | PathAttachmentPart
export type ComposerAttachment = ImageAttachmentPart
export type ComposerPrompt = Prompt
export type ComposerComment = ComposerStore["context"]["items"][number]
export type ComposerPersistedState = ComposerStore
@@ -48,8 +48,7 @@ export function createNewSessionComposerAdapter(props: {
submitted: props.submitted,
async start(selection, submission, message) {
const draftID = props.draftID
const currentDirectory = location().directory
const projectDirectory = data.location.info({ directory: currentDirectory })?.project.canonical ?? currentDirectory
const projectDirectory = location().directory
const worktree = props.worktree()
const branch = props.branch()
const mcp = props.mcp.capture()
-13
View File
@@ -74,7 +74,6 @@ 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",
@@ -367,9 +366,6 @@ 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",
@@ -967,16 +963,7 @@ 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, resolveBlobUrl } from "./drafts"
import { createDraftStore, draftTextChunk, draftTextThreshold } from "./drafts"
function memoryDriver() {
const documents = new Map<string, string>()
@@ -287,34 +287,15 @@ describe("draft store image retention", () => {
expect(await released(store, 5, shared.url)).toBe(true)
})
test("loading a document pins the images it references without fetching their bytes", async () => {
test("loading a document pins the images it references", 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 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])
const url = JSON.parse((await store.getItem("loaded"))!).prompt[0].blob.url
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()
})
})
+13 -22
View File
@@ -42,18 +42,6 @@ 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)
@@ -132,7 +120,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 Promise.resolve(existing.url)
if (existing) return existing.url
const pending = loading.get(id)
if (pending) return pending
const next = driver
@@ -142,7 +130,6 @@ 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) }
@@ -234,11 +221,9 @@ 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 = retained.get(aliases.get(ref.id) ?? ref.id)?.url
return { ...item, blob: url ? { id: ref.id, url } : { id: ref.id } }
const url = await loadBlobUrl(ref.id)
if (url) return { ...item, blob: { id: ref.id, url } }
}
}
return Object.fromEntries(
@@ -436,11 +421,17 @@ function referenced(json: string) {
return ids
}
export async function blobDataUrl(blob: BlobReference, mime: string) {
async function blobData(blob: BlobReference) {
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 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)
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.addEventListener("error", () => reject(reader.error))
@@ -1,23 +0,0 @@
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 }
}
+56 -141
View File
@@ -4,17 +4,7 @@ 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,
Suspense,
createEffect,
createMemo,
createSignal,
lazy,
onCleanup,
onMount,
} from "solid-js"
import { type Component, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import {
createServerHealthPreview,
@@ -28,12 +18,8 @@ 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<{
@@ -43,8 +29,6 @@ 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)
@@ -91,112 +75,64 @@ export const DialogServer: Component<{
</DialogHeader>
<Divider />
<DialogBody class="flex w-full min-w-0 flex-1 flex-col px-4 pt-4 pb-2">
<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>
<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>
</div>
</Show>
<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>
</DialogBody>
<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>
<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>
</Dialog>
)
}
@@ -213,8 +149,6 @@ 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,
})
@@ -227,8 +161,6 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
mode: "list",
originalUrl: undefined,
values: { url: "", name: "", password: "" },
urls: [],
scanning: false,
error: "",
status: undefined,
})
@@ -333,16 +265,6 @@ 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
@@ -359,8 +281,6 @@ 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,
},
@@ -369,11 +289,6 @@ 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,
@@ -1,41 +0,0 @@
.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,7 +4,6 @@ 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
+23 -1
View File
@@ -41,7 +41,8 @@
color: var(--v2-text-text-muted);
}
form {
form,
.server-connect-scanner {
display: flex;
flex-direction: column;
gap: 20px;
@@ -94,4 +95,25 @@
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;
}
}
+22 -9
View File
@@ -1,4 +1,4 @@
import { lazy, Show, Suspense } from "solid-js"
import { createResource, 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,7 +10,6 @@ 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 })))
@@ -20,7 +19,23 @@ export function ConnectServerScreen() {
const platform = usePlatform()
const servers = useServers()
const check = useCheckServerHealth()
const camera = createCameraAvailability()
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 [state, setState] = createStore({ url: "", password: "", urls: [] as string[], error: "", scanning: false })
const connectionError = () =>
language.t(
@@ -63,7 +78,7 @@ export function ConnectServerScreen() {
<PairingScanner
onCancel={() => {
setState("scanning", false)
void camera.refetch()
void cameraActions.refetch()
}}
onScan={(pairing) => {
setState({
@@ -139,15 +154,13 @@ export function ConnectServerScreen() {
<Button
variant="neutral"
size="large"
disabled={request.isPending || !camera.available.latest}
aria-describedby={
!camera.available.latest && !camera.available.loading ? "server-connect-camera-unavailable" : undefined
}
disabled={request.isPending || !camera.latest}
aria-describedby={!camera.latest && !camera.loading ? "server-connect-camera-unavailable" : undefined}
onClick={() => setState("scanning", true)}
>
{language.t("server.connect.scan")}
</Button>
<Show when={!camera.available.latest && !camera.available.loading}>
<Show when={!camera.latest && !camera.loading}>
<p id="server-connect-camera-unavailable">
{language.t(
window.isSecureContext ? "server.connect.camera.unavailable" : "server.connect.camera.insecure",
@@ -2,6 +2,7 @@ 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"
@@ -29,6 +30,7 @@ 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
+28 -40
View File
@@ -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, PathAttachmentPart, Prompt } from "@/composer/state"
import { clonePrompt, isAttachment, promptLength } from "@/composer/prompt-parts"
import type { ImageAttachmentPart, Prompt } from "@/composer/state"
import { clonePrompt, promptLength } from "@/composer/prompt-parts"
import { buildPromptRequest } from "@/composer/request"
import { blobDataUrl, createLegacyBlobReference } from "@/runtime/persistence/drafts"
import { readPromptPresentation } from "@/composer/comment-note"
import { deliverAttachments, type AttachmentDestination } from "@/composer/attachments/deliver"
import { createLegacyBlobReference } from "@/runtime/persistence/drafts"
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useWorkspaceLocation } from "@/workspaces/location"
@@ -30,6 +30,7 @@ export function createSessionQueue(input: {
draft: ComposerStateTarget
working: Accessor<boolean>
behavior: Accessor<ComposerDelivery>
destination: () => AttachmentDestination
restoreFocus: (cursor: number) => void
}) {
const data = useData()
@@ -60,6 +61,7 @@ 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({
@@ -185,15 +187,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 attachments = prompt.filter(isAttachment)
if (!text.trim() && !attachments.length) return cancelEdit()
const images = prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
if (!text.trim() && !images.length) return cancelEdit()
const item = queued().find((entry) => entry.id === editing.id)
const original = item ? queuedPromptAttachments(item) : []
const pristine =
item &&
text.trim() === queuedPromptText(item) &&
attachments.length === original.length &&
attachments.every((attachment, index) => attachment.id === original[index].id)
images.length === original.length &&
images.every((image, index) => image.id === original[index].id)
if (pristine && delivery === "queue") return cancelEdit()
mutation.mutate({
type: "edit",
@@ -249,7 +251,7 @@ export function queuedPromptRows(items: QueuedPrompt[], replacement?: { original
.map((item) => ({
id: item.id,
text: queuedPromptText(item),
attachments: (item.payload.files?.length ?? 0) + (readPromptPresentation(item.payload.metadata)?.attachments.length ?? 0),
attachments: item.payload.files?.length ?? 0,
}))
}
@@ -259,32 +261,18 @@ 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, 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,
}),
),
]
// 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}`),
}))
}
function isComposerAttachment(file: NonNullable<QueuedPrompt["payload"]["files"]>[number]) {
@@ -304,13 +292,13 @@ async function editedPromptInput(
item: QueuedPrompt | undefined,
prompt: Prompt,
text: string,
destination: AttachmentDestination,
) {
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 attachments = await deliverAttachments(
prompt.filter((part): part is ImageAttachmentPart => part.type === "image"),
destination,
)
const request = buildPromptRequest({ prompt, context: [], images, text, sessionDirectory: directory })
const request = buildPromptRequest({ prompt, context: [], attachments, 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) : ""
-6
View File
@@ -1517,12 +1517,6 @@
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%);
}
+88 -196
View File
@@ -1,20 +1,10 @@
import { useIsRouting, useLocation, useParams } from "@solidjs/router"
import { batch, createEffect, createMemo, on, onCleanup, onMount, Show } from "solid-js"
import { useIsRouting, useLocation } from "@solidjs/router"
import { batch, createEffect, 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?: {
@@ -49,23 +39,12 @@ 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
@@ -101,7 +80,6 @@ function Cell(props: {
}}
>
<div
dir="ltr"
classList={{
"text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
}}
@@ -109,7 +87,6 @@ function Cell(props: {
{props.label}
</div>
<div
dir="ltr"
classList={{
"uppercase font-bold tabular-nums": true,
"text-[11px] leading-text-tight": !!props.inline,
@@ -159,12 +136,8 @@ function ToggleCell(props: {
"flex-col items-center": !props.inline,
}}
>
<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 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>
</button>
)
@@ -176,11 +149,9 @@ function ToggleCell(props: {
)
}
export function DebugBar(props: { diagnostics?: boolean; inline?: boolean } = {}) {
export function DebugBar(props: { 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({
@@ -204,55 +175,8 @@ export function DebugBar(props: { diagnostics?: boolean; 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 = () => {
@@ -280,7 +204,6 @@ export function DebugBar(props: { diagnostics?: boolean; inline?: boolean } = {}
let two = 0
createEffect(() => {
if (!props.diagnostics) return
const busy = routing()
const next = `${location.pathname}${location.search}`
@@ -325,7 +248,6 @@ export function DebugBar(props: { diagnostics?: boolean; inline?: boolean } = {}
})
onMount(() => {
if (!props.diagnostics) return
const obs: PerformanceObserver[] = []
const fps: Array<{ at: number; dur: number }> = []
const long: Array<{ at: number; dur: number }> = []
@@ -526,7 +448,7 @@ export function DebugBar(props: { diagnostics?: boolean; inline?: boolean } = {}
return (
<aside
aria-label={language.t(props.diagnostics ? "debugBar.ariaLabel" : "debugBar.providerAriaLabel")}
aria-label={language.t("debugBar.ariaLabel")}
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,
@@ -545,132 +467,102 @@ export function DebugBar(props: { diagnostics?: boolean; inline?: boolean } = {}
}}
>
<Cell
label={language.t("debugBar.tps.label")}
tip={language.t("debugBar.tps.tip")}
value={fixed(metrics()?.tps, 1) ?? na()}
dim={metrics()?.tps === undefined}
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}
/>
<Cell
label={language.t("debugBar.ttft.label")}
tip={language.t("debugBar.ttft.tip")}
value={duration(metrics()?.ttft) ?? na()}
dim={metrics()?.ttft === undefined}
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.ttfa.label")}
tip={language.t("debugBar.ttfa.tip")}
value={duration(metrics()?.ttfa) ?? na()}
dim={metrics()?.ttfa === undefined}
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.e2e.label")}
tip={language.t("debugBar.e2e.tip")}
value={duration(metrics()?.e2e) ?? na()}
dim={metrics()?.e2e === undefined}
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}
/>
<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}
/>
<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}
/>
<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={language.direction() === "rtl"}
active={state.focus}
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")}
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 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>
@@ -1,153 +0,0 @@
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,
})
})
@@ -1,135 +0,0 @@
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)
}
+10 -23
View File
@@ -5,14 +5,11 @@ 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 })))
@@ -21,8 +18,6 @@ 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,
@@ -39,21 +34,14 @@ 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 = {
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,
},
])
const debugTools = import.meta.env.DEV
? {
get visible() {
return state.debugTools
},
toggle: () => setState("debugTools", (value) => !value),
}
: undefined
return (
<TitlebarRightProvider>
@@ -117,13 +105,12 @@ export default function Layout(props: ParentProps) {
</SshAuthentication>
</main>
</div>
<Show when={state.debugTools}>
<Show when={import.meta.env.DEV && state.debugTools}>
<Suspense>
<DebugBar diagnostics={import.meta.env.DEV} inline />
<DebugBar inline />
</Suspense>
</Show>
<ToastRegion />
<UploadToastHost />
</div>
</TitlebarRightProvider>
)
@@ -34,15 +34,10 @@ 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, resolveBlobUrl } from "@/runtime/persistence/drafts"
import { createDraftStore } from "@/runtime/persistence/drafts"
function fixture(id: string, getBlob: () => Promise<Blob | null>) {
const documents = new Map([
@@ -20,19 +20,7 @@ function fixture(id: string, getBlob: () => Promise<Blob | null>) {
return { store, documents }
}
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 () => {
test("deduplicates concurrent history and draft reads without invalidating either live reference", async () => {
const pending = Promise.withResolvers<Blob | null>()
const started = Promise.withResolvers<void>()
let reads = 0
@@ -41,33 +29,55 @@ test("deduplicates concurrent resolves without invalidating either live referenc
started.resolve()
return pending.promise
})
await store.getItem("history")
const first = resolveBlobUrl({ id: "history-cache-concurrent" })
const second = resolveBlobUrl({ id: "history-cache-concurrent" })
const history = store.getItem("history")
const draft = store.getItem("draft")
await started.promise
pending.resolve(new Blob(["shared screenshot"]))
const [a, b] = await Promise.all([first, second])
expect(a).toBe(b!)
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)
expect(reads).toBe(1)
await store.removeItem("history")
expect(await resolveObjectURL(a!)?.text()).toBe("shared screenshot")
expect(await resolveBlobUrl({ id: "history-cache-concurrent" })).toBe(a!)
expect(await resolveObjectURL(reference.url)?.text()).toBe("shared screenshot")
expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob).toEqual(reference)
expect(reads).toBe(1)
})
test("a document re-read while its image is live gets the URL back without a read", async () => {
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 () => {
let reads = 0
const { store, documents } = fixture("history-cache-remount", async () => {
reads++
return new Blob(["saved screenshot"])
})
const url = await resolveBlobUrl({ id: "history-cache-remount" })
const first = JSON.parse((await store.getItem("history"))!)
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({ id: "history-cache-remount", url })
expect(second.entries[0].prompt[1].blob).toEqual(first.entries[0].prompt[0].blob)
expect(reads).toBe(1)
})
@@ -79,34 +89,33 @@ 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
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:")
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:")
expect(reads).toBe(2)
})
test("retries after a failed blob read", async () => {
let reads = 0
fixture("history-cache-failure", async () => {
const { store } = fixture("history-cache-failure", async () => {
if (++reads === 1) throw new Error("temporary storage failure")
return new Blob(["recovered"])
})
await expect(resolveBlobUrl({ id: "history-cache-failure" })).rejects.toThrow("temporary storage failure")
expect(await resolveBlobUrl({ id: "history-cache-failure" })).toStartWith("blob:")
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:")
expect(reads).toBe(2)
})
test("keeps different blob IDs independent", async () => {
const reads: string[] = []
createDraftStore({
get: async () => null,
const store = createDraftStore({
get: async () => JSON.stringify(["history-cache-first", "history-cache-second"].map((id) => ({ blob: { id } }))),
set: async () => [],
remove: async () => {},
putBlob: async () => "unused",
@@ -115,8 +124,10 @@ test("keeps different blob IDs independent", async () => {
return new Blob([id])
},
})
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)
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)
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, resolveBlobUrl } from "@/runtime/persistence/drafts"
import { createDraftStore } from "@/runtime/persistence/drafts"
import { flushPersisted } from "@/runtime/persistence/persist"
import { Persist, persisted } from "@/runtime/persistence/storage"
@@ -103,11 +103,15 @@ 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: "" } },
{
type: "image",
id: "image",
filename: "image.png",
mime: "image/png",
blob: { id: "composer-image", url: expect.stringMatching(/^blob:/) },
},
])
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)
@@ -233,7 +237,7 @@ describe("prompt persistence", () => {
})
})
test("moves image data URLs into blobs and resolves object URLs on demand", async () => {
test("moves image data URLs into blobs and hydrates object URLs", async () => {
const documents = new Map<string, string>()
const blobs = new Map<string, Blob>()
const store = createDraftStore({
@@ -254,8 +258,8 @@ test("moves image data URLs into blobs and resolves object URLs on demand", asyn
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).toEqual({ id: "1" })
expect(await resolveBlobUrl(value.prompt[0].blob)).toStartWith("blob:")
expect(value.prompt[0].blob.id).toBe("1")
expect(value.prompt[0].blob.url).toStartWith("blob:")
})
test("does not let delayed blob migration overwrite a newer draft", async () => {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.8",
"version": "2.0.6",
"type": "module",
"license": "MIT",
"bin": {
+2 -15
View File
@@ -47,14 +47,7 @@ export default Runtime.handler(Commands, (input) =>
),
)
const updater = yield* Updater.Service
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)
const update = yield* updater.run().pipe(Effect.forkScoped)
preflight.loading()
const config = yield* Config.Service
const npm = yield* Npm.Service
@@ -99,13 +92,7 @@ export default Runtime.handler(Commands, (input) =>
),
{ 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),
)
},
check: (signal) => runPromise(Fiber.join(update).pipe(Effect.flatMap(() => updater.check())), { signal }),
apply: (version) => runPromise(updater.apply(version)),
},
packages: {
+16
View File
@@ -13,6 +13,7 @@ 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"
@@ -158,6 +159,21 @@ 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"
+15 -4
View File
@@ -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 } from "effect"
import { Context, Duration, Effect, FileSystem, Layer, Ref, Schedule } 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: (onInstall?: (version: string) => void) => Effect.Effect<RunResult | undefined>
readonly run: () => 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,6 +24,18 @@ 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 {
@@ -275,11 +287,10 @@ const make = Effect.gen(function* () {
})
const run = Effect.fn("cli.updater.run")(
function* (onInstall: (version: string) => void = () => {}) {
function* () {
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 }
},
+24
View File
@@ -0,0 +1,24 @@
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 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/client",
"version": "2.0.8",
"version": "2.0.6",
"type": "module",
"license": "MIT",
"repository": {
@@ -2135,7 +2135,7 @@ export type ConfigEntry =
experimental?: {
portable_shell_scanner?: boolean
subagent_depth?: number
policies?: Array<{ action: "provider.use" | "permission"; resource: string; effect: "allow" | "deny" }>
policies?: Array<{ action: "provider.use"; resource: string; effect: "allow" | "deny" }>
}
}
}
-12
View File
@@ -1043,18 +1043,6 @@ 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)
-74
View File
@@ -52,80 +52,6 @@ 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 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/codemode",
"version": "2.0.8",
"version": "2.0.6",
"description": "Effect-native confined code execution over schema-described tools",
"type": "module",
"license": "MIT",
-148
View File
@@ -1,148 +0,0 @@
{
"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
}
}
}
}
+107 -44
View File
@@ -14,8 +14,8 @@ type Recorded = {
readonly body: unknown
}
const transportSpec = async (): Promise<Document> => {
return Bun.file(new URL("./fixtures/openapi-transports.json", import.meta.url)).json() as Promise<Document>
const opencodeSpec = async (): Promise<Document> => {
return Bun.file(new URL("../../protocol/openapi.json", import.meta.url)).json() as Promise<Document>
}
const happyPathSpec = async (): Promise<Document> => {
@@ -219,42 +219,48 @@ describe("OpenAPI.fromSpec", () => {
expect(client.requests[3]!.headers.authorization).toBe("Bearer bearer-secret")
})
test("generates supported operations and reports unsupported transports", async () => {
const spec = await transportSpec()
test("converts representative opencode operations into the expected tool shape", async () => {
const spec = await opencodeSpec()
const result = OpenAPI.fromSpec({ spec, baseUrl })
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",
},
])
expect(result.skipped).toHaveLength(5)
expect(result.skipped).toContainEqual({
method: "GET",
path: "/api/pty/{ptyID}/connect",
reason: "WebSocket operations are not supported",
})
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()
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()
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()
})
test("preserves operation path sanitization and collision handling", () => {
@@ -965,16 +971,30 @@ describe("OpenAPI.fromSpec", () => {
expect(result).toMatchObject({ password: "returned-by-server", profile: { secret: "returned-secret" } })
})
test("exposes generated operations through CodeMode discovery", async () => {
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 () => {
const { layer } = recordingClient(() => json({}))
const runtime = CodeMode.make({
tools: { api: OpenAPI.fromSpec({ spec: await happyPathSpec(), baseUrl }).tools },
tools: { opencode: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools },
})
const result = await Effect.runPromise(
runtime
.execute(
`
return search({ query: "get a user", namespace: "api", limit: 1 })
return search({ query: "server info", namespace: "opencode", limit: 1 })
`,
)
.pipe(Effect.provide(layer)),
@@ -985,12 +1005,55 @@ describe("OpenAPI.fromSpec", () => {
expect(result.value).toMatchObject({
items: [
{
path: "tools.api.users.get",
description: "Get a user",
path: "tools.opencode.server.info",
description: "Return the server identity, connection URLs, paths, and readiness status.",
},
],
})
expect(JSON.stringify(result.value)).toContain("userId: string")
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")
})
test("serializes supported simple and form parameter shapes", async () => {
@@ -1398,15 +1461,15 @@ describe("OpenAPI.fromSpec", () => {
test("fails missing required parameters before auth and network", async () => {
const { requests, layer } = recordingClient(() => json({}))
const runtime = CodeMode.make({
tools: { api: OpenAPI.fromSpec({ spec: await transportSpec(), baseUrl }).tools },
tools: { opencode: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools },
})
const result = await Effect.runPromise(
runtime.execute("return await tools.api.records.get({})").pipe(Effect.provide(layer)),
runtime.execute("return await tools.opencode.session.get({})").pipe(Effect.provide(layer)),
)
expect(result).toMatchObject({ ok: false })
expect(JSON.stringify(result)).toContain("Missing required path parameter 'recordID'")
expect(JSON.stringify(result)).toContain("Missing required path parameter 'sessionID'")
expect(requests).toHaveLength(0)
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-app",
"version": "2.0.8",
"version": "2.0.6",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/console-core",
"version": "2.0.8",
"version": "2.0.6",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-function",
"version": "2.0.8",
"version": "2.0.6",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-mail",
"version": "2.0.8",
"version": "2.0.6",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-support",
"version": "2.0.8",
"version": "2.0.6",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.8",
"version": "2.0.6",
"name": "@opencode/core",
"type": "module",
"license": "MIT",
+4 -22
View File
@@ -12,34 +12,16 @@ 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())
const policies = () =>
loaded.entries
yield* ctx.provider.transform((providers) => {
// User-global policy takes priority over policy authored by a repository.
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) => policy.action === "provider.use" && Wildcard.match(record.provider.id, policy.resource),
)
const policy = policies.findLast((policy) => 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"
}),
)
}),
})
+6 -12
View File
@@ -93,10 +93,9 @@ 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, label),
bytes: yield* decodeDataURL(input.uri),
source: { type: "inline" as const },
start: undefined,
end: undefined,
@@ -106,8 +105,8 @@ const materializeAttachment = Effect.fn("SessionPrompt.materializeAttachment")(f
: yield* readFileAttachment(input.uri)
if (resolved.bytes.byteLength > MAX_ATTACHMENT_BYTES)
return yield* new AttachmentError({
uri: label,
message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${label}`,
uri: input.uri,
message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${input.uri}`,
})
const mime = resolved.mime ?? Mime.detect(resolved.bytes)
@@ -139,7 +138,7 @@ const normalizeImageAttachment = Effect.fn("SessionPrompt.normalizeImageAttachme
) {
if (!mime.startsWith("image/")) return { data: Base64.make(data), mime }
const image = yield* Image.Service
const label = attachmentLabel(input)
const label = input.name ?? (input.uri.startsWith("data:") ? "inline attachment" : input.uri)
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)),
@@ -202,12 +201,7 @@ const readFileAttachment = Effect.fn("SessionPrompt.readFileAttachment")(functio
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
// 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) {
function decodeDataURL(uri: string) {
return Effect.try({
try: () => {
const comma = uri.indexOf(",")
@@ -220,7 +214,7 @@ function decodeDataURL(uri: string, label: string) {
if (bytes.toString("base64") !== payload) throw new Error("Non-canonical base64")
return bytes
},
catch: () => new AttachmentError({ uri: label, message: `Invalid attachment data URL: ${label}` }),
catch: () => new AttachmentError({ uri, message: "Invalid attachment data URL" }),
})
}
+2 -25
View File
@@ -501,31 +501,8 @@ describe("Session.prompt", () => {
expect(error).toMatchObject({
_tag: "Session.AttachmentError",
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",
uri,
message: "Invalid attachment data URL",
})
}),
)
+4 -32
View File
@@ -76,7 +76,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, DateTime, Deferred, Effect, Exit, Fiber, Layer, Queue, Schema, Scope, Stream } from "effect"
import { Cause, Context, 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"
@@ -3391,17 +3391,11 @@ 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.complete(
{ reason: { normalized: "tool-calls" }, usage: { outputTokens: 100, reasoningTokens: 80 } },
LLMEvent.toolCall({ id: "call-streamed", name: "echo", input: { text: "hello" } }),
),
).pipe(
Stream.fromIterable(TestLLM.tool("call-streamed", "echo", { text: "hello" })).pipe(
Stream.concat(
Stream.fromEffect(Deferred.succeed(tail, undefined).pipe(Effect.andThen(Deferred.await(complete)))).pipe(
Stream.drain,
@@ -3419,39 +3413,25 @@ 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) {
@@ -5057,18 +5037,14 @@ 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(
Stream.fromEffect(Effect.sleep(400)).pipe(
Stream.flatMap(() => Stream.fromIterable(TestLLM.text("Recovered", "retry-success"))),
),
)
yield* s.llm.push(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("1201 millis")
yield* TestClock.adjust("801 millis")
yield* Fiber.join(run)
expect(s.requests).toHaveLength(2)
@@ -5082,10 +5058,6 @@ 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,8 +48,7 @@ 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:")
// IPC crosses the port by structured clone; no MessagePack runtime or native accelerator ships.
expect(Object.keys(pkg.optionalDependencies)).not.toContain("msgpackr-extract")
expect(pkg.optionalDependencies["msgpackr-extract"]).toBe("3.0.4")
})
test("keeps PTY binaries without stale native packaging", () => {
@@ -102,6 +101,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"))).toBe(false)
expect(chunks.some((chunk) => chunk.code.includes("msgpackr"))).toBe(false)
expect(modules.some((id) => id.includes("/node_modules/msgpackr-extract/"))).toBe(false)
expect(chunks.some((chunk) => chunk.code.includes("msgpackr-extract"))).toBe(true)
}, 30_000)
+2 -2
View File
@@ -58,9 +58,9 @@ const require = __cjs_mod__.createRequire(import.meta.url);
},
},
externalizeDeps: {
// Bundle the Effect family together.
// Bundle the Effect family together; native MessagePack acceleration stays optional and external.
exclude: ["effect", "@effect/platform-node", "@effect/platform-node-shared", "drizzle-orm"],
include: [nodePtyPkg],
include: [nodePtyPkg, "msgpackr-extract"],
},
},
plugins: [
+3 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode/desktop",
"private": true,
"version": "2.0.8",
"version": "2.0.6",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
@@ -68,6 +68,7 @@
"@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"
"@lydell/node-pty-win32-x64": "1.2.0-beta.12",
"msgpackr-extract": "3.0.4"
}
}
+15 -15
View File
@@ -3,22 +3,17 @@ 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, RpcServer } from "effect/unstable/rpc"
import { Transferable } from "effect/unstable/workers"
import { Rpc, RpcClient, RpcClientError, RpcGroup, RpcMessage, RpcSerialization, RpcServer } from "effect/unstable/rpc"
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 }) => {
received = data
return Effect.succeed([...data].join(","))
},
"test.blob.put": ({ data }) => Effect.succeed([...data].join(",")),
"test.blob.get": () => Effect.succeed(new Uint8Array([3, 1, 4])),
"test.events": () => Stream.make(new TestEvent({ value: "session.new" })),
})
@@ -39,8 +34,6 @@ 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" }))
@@ -61,8 +54,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: Transferable.Uint8Array }, success: Schema.String }),
Rpc.make("test.blob.get", { success: Transferable.Uint8Array }),
Rpc.make("test.blob.put", { payload: { data: Schema.Uint8Array }, success: Schema.String }),
Rpc.make("test.blob.get", { success: Schema.Uint8Array }),
Rpc.make("test.events", { success: TestEvent, stream: true }),
)
type TestRpcClient = RpcClient.FromGroup<typeof TestRpcs, RpcClientError.RpcClientError>
@@ -116,9 +109,13 @@ 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) =>
Queue.offerUnsafe(inbound, event.data as RpcMessage.FromServerEncoded)
parser
.decode(event.data)
.forEach((message) => Queue.offerUnsafe(inbound, message as RpcMessage.FromServerEncoded))
port.addEventListener("message", onMessage)
port.start()
yield* Effect.addFinalizer(() =>
@@ -134,15 +131,18 @@ function clientProtocol(port: MessagePort) {
Effect.forkScoped,
)
return {
codecFor: Schema.toCodecJson,
codecFor: serialization.codecFor,
send: (_clientId: number, request: RpcMessage.FromClientEncoded) =>
Effect.sync(() => port.postMessage(request)),
Effect.sync(() => {
const encoded = parser.encode(request)
if (encoded !== undefined) port.postMessage(encoded)
}),
supportsAck: true,
supportsTransferables: false,
}
}),
),
)
).pipe(Layer.provide(RpcSerialization.layerMsgPack))
}
function sender(id: number) {
+21 -10
View File
@@ -1,12 +1,13 @@
import type { MessagePortMain, WebContents } from "electron"
import { Context, Effect, Layer, Option, Queue, Schema, Stream } from "effect"
import { RpcMessage, RpcServer } from "effect/unstable/rpc"
import { Context, Effect, Layer, Option, Queue, Stream } from "effect"
import { RpcMessage, RpcSerialization, 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>
@@ -19,9 +20,6 @@ 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]>()
@@ -32,6 +30,7 @@ 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())
@@ -59,12 +58,21 @@ export const IpcServerProtocolLive = Layer.unwrap(
}
const id = nextClientId++
const parser = serialization.makeUnsafe()
const onMessage = (event: Electron.MessageEvent) => {
Queue.offerUnsafe(inbound, [id, event.data as RpcMessage.FromClientEncoded] as const)
try {
parser
.decode(event.data)
.forEach((message) =>
Queue.offerUnsafe(inbound, [id, message as RpcMessage.FromClientEncoded] as const),
)
} catch {
return
}
}
const onClose = () => runFork(disconnect(id))
const unbindEvents = yield* bindIpcEvents(sender.id)
const binding = { id, sender, port, onMessage, onClose, unbindEvents }
const binding = { id, sender, port, parser, onMessage, onClose, unbindEvents }
bindings.set(id, binding)
senderBindings.set(sender.id, id)
port.on("message", onMessage)
@@ -84,11 +92,14 @@ export const IpcServerProtocolLive = Layer.unwrap(
yield* Effect.addFinalizer(() => Effect.forEach([...bindings.keys()], disconnect, { discard: true }))
return {
codecFor: Schema.toCodecJson,
codecFor: serialization.codecFor,
disconnects,
send: (clientId, response) =>
Effect.sync(() => {
bindings.get(clientId)?.port.postMessage(response)
const binding = bindings.get(clientId)
if (!binding) return
const encoded = binding.parser.encode(response)
if (encoded !== undefined) binding.port.postMessage(encoded)
}),
end: disconnect,
clientIds: Effect.sync(() => new Set(bindings.keys())),
@@ -112,4 +123,4 @@ export const IpcServerProtocolLive = Layer.unwrap(
}),
)
}),
)
).pipe(Layer.provide(RpcSerialization.layerMsgPack))
+2 -4
View File
@@ -98,10 +98,8 @@ export function createDraftStore(
orphans = true
return id
},
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
getBlob(id: string): Uint8Array | null {
return db.select({ data: blobs.data }).from(blobs).where(eq(blobs.id, id)).get()?.data ?? null
},
flush: writer.flush,
close: writer.close,
+15 -9
View File
@@ -1,5 +1,5 @@
import { Context, Effect, Layer, ManagedRuntime, Queue, Schema, Stream } from "effect"
import { RpcClient, RpcMessage } from "effect/unstable/rpc"
import { Context, Effect, Layer, ManagedRuntime, Queue, Stream } from "effect"
import { RpcClient, RpcMessage, RpcSerialization } 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,17 +79,22 @@ 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) => {
Queue.offerUnsafe(inbound, event.data as RpcMessage.FromServerEncoded)
try {
parser
.decode(event.data)
.forEach((message) => Queue.offerUnsafe(inbound, message as RpcMessage.FromServerEncoded))
} catch {
return
}
}
value.addEventListener("message", onMessage)
value.start()
@@ -106,15 +111,16 @@ function clientProtocol(value: MessagePort) {
Effect.forkScoped,
)
return {
codecFor: Schema.toCodecJson,
codecFor: serialization.codecFor,
send: (_clientId, request) =>
Effect.sync(() => {
value.postMessage(request)
const encoded = parser.encode(request)
if (encoded !== undefined) value.postMessage(encoded)
}),
supportsAck: true,
supportsTransferables: false,
}
}),
),
)
).pipe(Layer.provide(RpcSerialization.layerMsgPack))
}
+2 -3
View File
@@ -1,6 +1,5 @@
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({
@@ -19,7 +18,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: Transferable.Uint8Array, width: Schema.Number, height: Schema.Number })
const ClipboardImage = Schema.Struct({ buffer: Schema.Uint8Array, width: Schema.Number, height: Schema.Number })
export const FilesOpenDirectoryPicker = Rpc.make("FilesOpenDirectoryPicker", {
payload: { options: Schema.optional(PickerOptions) },
@@ -31,7 +30,7 @@ export const FilesOpenFilePicker = Rpc.make("FilesOpenFilePicker", {
})
export const FilesReadPickedFile = Rpc.make("FilesReadPickedFile", {
payload: { token: Schema.String, path: Schema.String },
success: Transferable.Uint8Array,
success: Schema.Uint8Array,
})
export const FilesReleasePickedFiles = Rpc.make("FilesReleasePickedFiles", {
payload: { token: Schema.String },
@@ -1,6 +1,5 @@
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 },
@@ -25,12 +24,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: Transferable.Uint8Array },
payload: { data: Schema.Uint8Array },
success: Schema.String,
})
export const DraftsGetBlob = Rpc.make("DraftsGetBlob", {
payload: { id: Schema.String },
success: Schema.NullOr(Transferable.Uint8Array),
success: Schema.NullOr(Schema.Uint8Array),
})
export const StorageRpcs = RpcGroup.make(
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/enterprise",
"version": "2.0.8",
"version": "2.0.6",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/function",
"version": "2.0.8",
"version": "2.0.6",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.8",
"version": "2.0.6",
"name": "@opencode/http-recorder",
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/httpapi-codegen",
"version": "2.0.8",
"version": "2.0.6",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/latex",
"version": "2.0.8",
"version": "2.0.6",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/merman",
"version": "2.0.8",
"version": "2.0.6",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin-browser",
"version": "2.0.8",
"version": "2.0.6",
"description": "OpenCode's desktop browser plugin",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin",
"version": "2.0.8",
"version": "2.0.6",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/protocol",
"version": "2.0.8",
"version": "2.0.6",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/schema",
"version": "2.0.8",
"version": "2.0.6",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -6,7 +6,7 @@ export const Effect = Schema.Literals(["allow", "deny"])
export type Effect = typeof Effect.Type
export const Info = Schema.Struct({
action: Schema.Literals(["provider.use", "permission"]),
action: Schema.Literal("provider.use"),
resource: Schema.String,
effect: Effect,
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.8",
"version": "2.0.6",
"name": "@opencode/sdk",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/server",
"version": "2.0.8",
"version": "2.0.6",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/session-ui",
"version": "2.0.8",
"version": "2.0.6",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/simulation",
"version": "2.0.8",
"version": "2.0.6",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-app",
"version": "2.0.8",
"version": "2.0.6",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-core",
"version": "2.0.8",
"version": "2.0.6",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-server",
"version": "2.0.8",
"version": "2.0.6",
"private": true,
"type": "module",
"license": "MIT",

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