mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-19 15:17:51 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df010c9ae8 | ||
|
|
cda0e981d2 | ||
|
|
5e8d4790a3 | ||
|
|
d390419abe | ||
|
|
86d5b05d30 | ||
|
|
6573f923b8 | ||
|
|
47f66de8dd | ||
|
|
c076066c33 | ||
|
|
5c09d051e5 | ||
|
|
0a6111291e |
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -54,7 +54,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -113,7 +113,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"bin": {
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
},
|
||||
@@ -177,7 +177,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -203,7 +203,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -217,7 +217,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -253,7 +253,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -280,7 +280,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -297,7 +297,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -321,7 +321,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -341,7 +341,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -409,7 +409,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "4.1.2",
|
||||
@@ -456,12 +456,11 @@
|
||||
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-win32-x64": "1.2.0-beta.12",
|
||||
"msgpackr-extract": "3.0.4",
|
||||
},
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -498,7 +497,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -514,7 +513,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
@@ -533,7 +532,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -546,7 +545,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -560,7 +559,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -575,7 +574,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -614,7 +613,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -644,7 +643,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -659,7 +658,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -683,7 +682,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -704,7 +703,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -726,7 +725,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -761,7 +760,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -781,7 +780,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -815,7 +814,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -834,7 +833,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -880,7 +879,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -894,7 +893,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -929,7 +928,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -964,7 +963,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -997,7 +996,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1038,7 +1037,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-U9IuP/ev6w4urvogOwQyl3rdumY6W4YaY18NkFaOVHU=",
|
||||
"aarch64-linux": "sha256-Wc8OT2DRZpVo56KaoGE0Hsj1NDknakbWXO9w2qy6j+0=",
|
||||
"aarch64-darwin": "sha256-wAea8+jajnMDxZ6XJL+Hsrf0621hwtBtWyD1+dS45dE=",
|
||||
"x86_64-darwin": "sha256-g8PCNBSV6rO+VQjKU9AtYqj+r18o+fhLDXEQq+X2EZ4="
|
||||
"x86_64-linux": "sha256-TlCPtuIvTNF01fLI0yHCLzVkx53yF9xxvAObDMGeo8M=",
|
||||
"aarch64-linux": "sha256-aEjyPIXHeA5w1iZXbPKJAOIJQBfXg+LAx/y7C5uFz4g=",
|
||||
"aarch64-darwin": "sha256-lthMTio2qNy+H2tL9cAPmqC41O+wdSADI61cnW4dIb0=",
|
||||
"x86_64-darwin": "sha256-ONFbRQnwzOYHo0Pyh327QfJG2uzqzNEn/SS2vbYYMdM="
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
|
||||
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { FileIcon } from "@opencode/ui/file-icon"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
import { resolveBlobUrl } from "@/runtime/persistence/drafts"
|
||||
import { ProviderIcon } from "@opencode/ui/provider-icon"
|
||||
import { useI18n } from "@opencode/ui/context/i18n"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
@@ -545,17 +546,21 @@ export function ComposerAttachments(props: {
|
||||
</AttachmentCard>
|
||||
}
|
||||
>
|
||||
{(image) => (
|
||||
<>
|
||||
<img
|
||||
src={image().blob.url}
|
||||
alt={attachment.filename}
|
||||
class="w-[58px] h-[46px] rounded-[6px] object-cover"
|
||||
onClick={() => props.onAttachmentClick?.(attachment)}
|
||||
/>
|
||||
<div class="absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none" />
|
||||
</>
|
||||
)}
|
||||
{(image) => {
|
||||
// Restored drafts and history carry image ids only; bytes load when shown.
|
||||
const [url] = createResource(() => image().blob, resolveBlobUrl)
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
src={url() ?? ""}
|
||||
alt={attachment.filename}
|
||||
class="w-[58px] h-[46px] rounded-[6px] object-cover"
|
||||
onClick={() => props.onAttachmentClick?.(attachment)}
|
||||
/>
|
||||
<div class="absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none" />
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</Tooltip>
|
||||
<button
|
||||
|
||||
@@ -11,6 +11,7 @@ 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"
|
||||
@@ -322,7 +323,9 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
},
|
||||
openAttachment: (attachment) => {
|
||||
if (attachment.type !== "image") return
|
||||
dialog.show(() => createComponent(ImagePreview, { src: attachment.blob.url, alt: attachment.filename }))
|
||||
void resolveBlobUrl(attachment.blob).then((src) => {
|
||||
if (src) dialog.show(() => createComponent(ImagePreview, { src, alt: attachment.filename }))
|
||||
})
|
||||
},
|
||||
openContext(key) {
|
||||
const item = controller.contextItem(key)
|
||||
|
||||
@@ -136,7 +136,7 @@ describe("composer persistence schemas", () => {
|
||||
).toEqual(value)
|
||||
})
|
||||
|
||||
test("migrates inline images but never encodes dataUrl or unresolved references", () => {
|
||||
test("migrates inline images, keeps store references without a URL, and never encodes dataUrl", () => {
|
||||
const value = Schema.decodeUnknownSync(
|
||||
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
|
||||
)({
|
||||
@@ -149,12 +149,16 @@ describe("composer persistence schemas", () => {
|
||||
{ ...image, blob: { id: "missing" }, dataUrl: "data:image/png;base64,YQ==" },
|
||||
],
|
||||
})
|
||||
expect(value.prompt).toHaveLength(3)
|
||||
expect(value.prompt).toHaveLength(6)
|
||||
expect(value.prompt[0]).toEqual({
|
||||
...image,
|
||||
sourcePath: "/image.png",
|
||||
blob: { id: "data:image/png;base64,YQ==", url: "data:image/png;base64,YQ==" },
|
||||
})
|
||||
// Bytes still in the draft store resolve on use; a non-blob URL is discarded in favour of the id.
|
||||
expect(value.prompt[3]).toEqual({ ...image, blob: { id: "missing", url: "" } })
|
||||
expect(value.prompt[4]).toEqual({ ...image, blob: { id: "bad", url: "" } })
|
||||
expect(value.prompt[5]).toEqual({ ...image, blob: { id: "missing", url: "" } })
|
||||
const encoded = Schema.encodeSync(ComposerStore)(value)
|
||||
expect(JSON.stringify(encoded)).not.toContain("dataUrl")
|
||||
expect(
|
||||
|
||||
@@ -61,11 +61,12 @@ const ImageFields = {
|
||||
}
|
||||
const Image = Persistence.struct({
|
||||
...ImageFields,
|
||||
blob: Schema.Struct({ id: Schema.NonEmptyString, url: Schema.String.check(Schema.isPattern(/^(blob:|data:)/)) }),
|
||||
// An empty URL is an image whose bytes are still in the draft store; see `resolveBlobUrl`.
|
||||
blob: Schema.Struct({ id: Schema.NonEmptyString, url: Schema.String.check(Schema.isPattern(/^(blob:|data:|$)/)) }),
|
||||
})
|
||||
|
||||
// Draft storage hydrates content-addressed blobs before this codec runs. Legacy
|
||||
// inline data remains usable, but unresolved references are not renderable.
|
||||
// Draft storage keeps content-addressed blobs in the store until an image is shown or sent; a
|
||||
// reference without a URL resolves through `resolveBlobUrl`. Legacy inline data remains usable.
|
||||
export const ImageAttachmentPart = Schema.Struct({
|
||||
...ImageFields,
|
||||
blob: Persistence.optional(
|
||||
|
||||
@@ -88,6 +88,15 @@ describe("prompt state initialization", () => {
|
||||
start: 5,
|
||||
end: 12,
|
||||
},
|
||||
// A reference without a usable URL keeps its id; the bytes resolve from the draft store on use.
|
||||
{
|
||||
type: "image",
|
||||
id: "missing-blob",
|
||||
filename: "missing.png",
|
||||
mime: "image/png",
|
||||
blob: { id: "content-hash-without-a-url", url: "" },
|
||||
},
|
||||
{ type: "image", id: "invalid-url", filename: "invalid.png", mime: "image/png", blob: { id: "hash", url: "" } },
|
||||
{
|
||||
type: "image",
|
||||
id: "legacy",
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { ComposerAdapter, ComposerDelivery, ComposerSelection, ComposerSess
|
||||
import { createComposerSubmission } from "./submission-state"
|
||||
import { buildPromptRequest } from "./request"
|
||||
import { setCursorPosition } from "./editor/dom"
|
||||
import { blobDataUrl } from "@/runtime/persistence/drafts"
|
||||
import { blobDataUrl, resolveBlobUrl } from "@/runtime/persistence/drafts"
|
||||
import { isAttachment } from "./prompt-parts"
|
||||
import type { ModelSelection } from "@/providers/models/selection"
|
||||
|
||||
@@ -59,12 +59,22 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
selection: item.selection ? { ...item.selection } : undefined,
|
||||
})),
|
||||
})
|
||||
const value = readSubmission(input, submission.prompt, submission.context, options?.alternate ?? false)
|
||||
if (!value) {
|
||||
const read = readSubmission(input, submission.prompt, submission.context, options?.alternate ?? false)
|
||||
if (!read) {
|
||||
if (input.adapter.working() && input.adapter.kind === "active-session") void input.adapter.interrupt()
|
||||
return
|
||||
}
|
||||
if (submitting.has(input.adapter.state)) return
|
||||
// Images restored from a draft or history carry ids only; the optimistic message shows their URLs.
|
||||
const value = {
|
||||
...read,
|
||||
images: await Promise.all(
|
||||
read.images.map(async (image) => ({
|
||||
...image,
|
||||
blob: { ...image.blob, url: (await resolveBlobUrl(image.blob)) ?? image.blob.url },
|
||||
})),
|
||||
),
|
||||
}
|
||||
submitting.add(input.adapter.state)
|
||||
const comments = input.comments.capture()
|
||||
// Capture command intent before starting a session in a worktree whose catalog has not loaded.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createDraftStore, draftTextChunk, draftTextThreshold } from "./drafts"
|
||||
import { createDraftStore, draftTextChunk, draftTextThreshold, resolveBlobUrl } from "./drafts"
|
||||
|
||||
function memoryDriver() {
|
||||
const documents = new Map<string, string>()
|
||||
@@ -287,15 +287,34 @@ describe("draft store image retention", () => {
|
||||
expect(await released(store, 5, shared.url)).toBe(true)
|
||||
})
|
||||
|
||||
test("loading a document pins the images it references", async () => {
|
||||
test("loading a document pins the images it references without fetching their bytes", async () => {
|
||||
const { memory, store } = fresh()
|
||||
const reads: string[] = []
|
||||
const getBlob = memory.driver.getBlob
|
||||
memory.driver.getBlob = (id) => {
|
||||
reads.push(id)
|
||||
return getBlob(id)
|
||||
}
|
||||
const id = await memory.driver.putBlob(image(6))
|
||||
memory.documents.set("loaded", JSON.stringify({ prompt: [{ type: "image", blob: { id } }] }))
|
||||
const url = JSON.parse((await store.getItem("loaded"))!).prompt[0].blob.url
|
||||
const loaded = JSON.parse((await store.getItem("loaded"))!).prompt[0].blob
|
||||
expect(loaded).toEqual({ id })
|
||||
expect(reads).toEqual([])
|
||||
// The first consumer that shows or sends the image loads it; the pin from the load keeps it.
|
||||
const url = (await resolveBlobUrl(loaded))!
|
||||
expect(url.startsWith("blob:")).toBe(true)
|
||||
expect(reads).toEqual([id])
|
||||
expect(await resolveBlobUrl(loaded)).toBe(url)
|
||||
expect(reads).toEqual([id])
|
||||
await tick()
|
||||
expect(await released(store, 6, url)).toBe(false)
|
||||
await store.removeItem("loaded")
|
||||
await tick()
|
||||
expect(await released(store, 6, url)).toBe(true)
|
||||
})
|
||||
|
||||
test("a reference to bytes the store no longer holds resolves to nothing", async () => {
|
||||
fresh()
|
||||
expect(await resolveBlobUrl({ id: "gone" })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -42,6 +42,18 @@ const refs = new Map<string, Set<string>>()
|
||||
// Image ids that were restored under a different id (a store without WebCrypto assigns fresh
|
||||
// ones); live references still carry the original.
|
||||
const aliases = new Map<string, string>()
|
||||
// Fetches image bytes from the store created last. Documents load without their bytes; a consumer
|
||||
// that renders or sends an image resolves its URL through here, so a history full of large
|
||||
// attachments costs nothing at startup.
|
||||
let loader: ((id: string) => Promise<string | undefined>) | undefined
|
||||
|
||||
/** The object URL for an image reference, loading its bytes from the draft store on first use. */
|
||||
export function resolveBlobUrl(blob: { id: string; url?: string }) {
|
||||
if (blob.url) return Promise.resolve(blob.url)
|
||||
const existing = retained.get(aliases.get(blob.id) ?? blob.id)
|
||||
if (existing) return Promise.resolve(existing.url)
|
||||
return loader?.(blob.id) ?? Promise.resolve(undefined)
|
||||
}
|
||||
|
||||
function blobUrl(id: string, blob: Blob, grace?: number) {
|
||||
const existing = retained.get(id)
|
||||
@@ -120,7 +132,7 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
|
||||
const loading = new Map<string, Promise<string | undefined>>()
|
||||
const loadBlobUrl = (id: string) => {
|
||||
const existing = retained.get(id)
|
||||
if (existing) return existing.url
|
||||
if (existing) return Promise.resolve(existing.url)
|
||||
const pending = loading.get(id)
|
||||
if (pending) return pending
|
||||
const next = driver
|
||||
@@ -130,6 +142,7 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
|
||||
loading.set(id, next)
|
||||
return next
|
||||
}
|
||||
loader = loadBlobUrl
|
||||
const putBlob = async (blob: Blob) => {
|
||||
const id = await driver.putBlob(blob)
|
||||
return { id, url: blobUrl(id, blob, grace) }
|
||||
@@ -221,9 +234,11 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
|
||||
if (ref.kind === "text" && Array.isArray(ref.ids)) {
|
||||
return (await Promise.all(ref.ids.map((id) => loadChunk(String(id))))).join("")
|
||||
}
|
||||
// Bytes stay in the store until something renders or sends the image (see resolveBlobUrl);
|
||||
// only an image already pinned in this page gets its URL back immediately.
|
||||
if (typeof ref.id === "string") {
|
||||
const url = await loadBlobUrl(ref.id)
|
||||
if (url) return { ...item, blob: { id: ref.id, url } }
|
||||
const url = retained.get(aliases.get(ref.id) ?? ref.id)?.url
|
||||
return { ...item, blob: url ? { id: ref.id, url } : { id: ref.id } }
|
||||
}
|
||||
}
|
||||
return Object.fromEntries(
|
||||
@@ -423,7 +438,9 @@ function referenced(json: string) {
|
||||
|
||||
export async function blobDataUrl(blob: BlobReference, mime: string) {
|
||||
const kept = retained.get(aliases.get(blob.id) ?? blob.id)
|
||||
const data = kept ? kept.blob : await fetch(blob.url).then((response) => response.blob())
|
||||
const url = kept ? undefined : await resolveBlobUrl(blob)
|
||||
if (!kept && !url) throw new Error(`Attachment ${blob.id} has no stored bytes`)
|
||||
const data = kept ? kept.blob : await fetch(url!).then((response) => response.blob())
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.addEventListener("error", () => reject(reader.error))
|
||||
|
||||
@@ -409,6 +409,7 @@ export function SessionFileView(props: SessionFileViewProps) {
|
||||
}}
|
||||
enableLineSelection
|
||||
enableGutterUtility
|
||||
textSelectionAction={{ label: language.t("ui.lineComment.add") }}
|
||||
selectedLines={activeSelection()}
|
||||
commentedLines={commentedLines()}
|
||||
onRendered={() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { resolveObjectURL } from "node:buffer"
|
||||
import { createDraftStore } from "@/runtime/persistence/drafts"
|
||||
import { createDraftStore, resolveBlobUrl } from "@/runtime/persistence/drafts"
|
||||
|
||||
function fixture(id: string, getBlob: () => Promise<Blob | null>) {
|
||||
const documents = new Map([
|
||||
@@ -20,7 +20,19 @@ function fixture(id: string, getBlob: () => Promise<Blob | null>) {
|
||||
return { store, documents }
|
||||
}
|
||||
|
||||
test("deduplicates concurrent history and draft reads without invalidating either live reference", async () => {
|
||||
test("loading history and a draft reads no image bytes", async () => {
|
||||
let reads = 0
|
||||
const { store } = fixture("history-cache-lazy", async () => {
|
||||
reads++
|
||||
return new Blob(["shared screenshot"])
|
||||
})
|
||||
const [history, draft] = await Promise.all([store.getItem("history"), store.getItem("draft")])
|
||||
expect(JSON.parse(history!).entries[0].prompt[0].blob).toEqual({ id: "history-cache-lazy" })
|
||||
expect(JSON.parse(draft!).prompt[0].blob).toEqual({ id: "history-cache-lazy" })
|
||||
expect(reads).toBe(0)
|
||||
})
|
||||
|
||||
test("deduplicates concurrent resolves without invalidating either live reference", async () => {
|
||||
const pending = Promise.withResolvers<Blob | null>()
|
||||
const started = Promise.withResolvers<void>()
|
||||
let reads = 0
|
||||
@@ -29,55 +41,33 @@ test("deduplicates concurrent history and draft reads without invalidating eithe
|
||||
started.resolve()
|
||||
return pending.promise
|
||||
})
|
||||
const history = store.getItem("history")
|
||||
const draft = store.getItem("draft")
|
||||
await store.getItem("history")
|
||||
const first = resolveBlobUrl({ id: "history-cache-concurrent" })
|
||||
const second = resolveBlobUrl({ id: "history-cache-concurrent" })
|
||||
await started.promise
|
||||
pending.resolve(new Blob(["shared screenshot"]))
|
||||
const [saved, active] = await Promise.all([history, draft])
|
||||
const reference = JSON.parse(saved!).entries[0].prompt[0].blob
|
||||
expect(JSON.parse(active!).prompt[0].blob).toEqual(reference)
|
||||
const [a, b] = await Promise.all([first, second])
|
||||
expect(a).toBe(b!)
|
||||
expect(reads).toBe(1)
|
||||
await store.removeItem("history")
|
||||
expect(await resolveObjectURL(reference.url)?.text()).toBe("shared screenshot")
|
||||
expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob).toEqual(reference)
|
||||
expect(await resolveObjectURL(a!)?.text()).toBe("shared screenshot")
|
||||
expect(await resolveBlobUrl({ id: "history-cache-concurrent" })).toBe(a!)
|
||||
expect(reads).toBe(1)
|
||||
})
|
||||
|
||||
test("hydrates repeated references once within one history document", async () => {
|
||||
let reads = 0
|
||||
const { store, documents } = fixture("history-cache-repeated", async () => {
|
||||
reads++
|
||||
return new Blob(["repeated screenshot"])
|
||||
})
|
||||
documents.set(
|
||||
"history",
|
||||
JSON.stringify({
|
||||
entries: Array.from({ length: 100 }, () => ({
|
||||
prompt: [{ type: "image", blob: { id: "history-cache-repeated" } }],
|
||||
})),
|
||||
}),
|
||||
)
|
||||
const value = JSON.parse((await store.getItem("history"))!)
|
||||
expect(value.entries).toHaveLength(100)
|
||||
expect(
|
||||
new Set(value.entries.map((entry: { prompt: { blob: { url: string } }[] }) => entry.prompt[0].blob.url)).size,
|
||||
).toBe(1)
|
||||
expect(reads).toBe(1)
|
||||
})
|
||||
|
||||
test("reuses a live URL on remount but reads the latest document", async () => {
|
||||
test("a document re-read while its image is live gets the URL back without a read", async () => {
|
||||
let reads = 0
|
||||
const { store, documents } = fixture("history-cache-remount", async () => {
|
||||
reads++
|
||||
return new Blob(["saved screenshot"])
|
||||
})
|
||||
const first = JSON.parse((await store.getItem("history"))!)
|
||||
const url = await resolveBlobUrl({ id: "history-cache-remount" })
|
||||
const changed = JSON.parse(documents.get("history")!)
|
||||
changed.entries[0].prompt.unshift({ type: "text", content: "new admission" })
|
||||
documents.set("history", JSON.stringify(changed))
|
||||
const second = JSON.parse((await store.getItem("history"))!)
|
||||
expect(second.entries[0].prompt[0].content).toBe("new admission")
|
||||
expect(second.entries[0].prompt[1].blob).toEqual(first.entries[0].prompt[0].blob)
|
||||
expect(second.entries[0].prompt[1].blob).toEqual({ id: "history-cache-remount", url })
|
||||
expect(reads).toBe(1)
|
||||
})
|
||||
|
||||
@@ -89,33 +79,34 @@ test("reuses a just-stored attachment without a round trip", async () => {
|
||||
})
|
||||
const reference = await store.putBlob(new Blob(["pending admission"]))
|
||||
expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob).toEqual(reference)
|
||||
expect(await resolveBlobUrl({ id: reference.id })).toBe(reference.url)
|
||||
expect(reads).toBe(0)
|
||||
expect(await resolveObjectURL(reference.url)?.text()).toBe("pending admission")
|
||||
})
|
||||
|
||||
test("does not retain a missing blob result", async () => {
|
||||
let reads = 0
|
||||
const { store } = fixture("history-cache-missing", async () => (++reads === 1 ? null : new Blob(["arrived"])))
|
||||
expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob.url).toBeUndefined()
|
||||
expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob.url).toStartWith("blob:")
|
||||
fixture("history-cache-missing", async () => (++reads === 1 ? null : new Blob(["arrived"])))
|
||||
expect(await resolveBlobUrl({ id: "history-cache-missing" })).toBeUndefined()
|
||||
expect(await resolveBlobUrl({ id: "history-cache-missing" })).toStartWith("blob:")
|
||||
expect(reads).toBe(2)
|
||||
})
|
||||
|
||||
test("retries after a failed blob read", async () => {
|
||||
let reads = 0
|
||||
const { store } = fixture("history-cache-failure", async () => {
|
||||
fixture("history-cache-failure", async () => {
|
||||
if (++reads === 1) throw new Error("temporary storage failure")
|
||||
return new Blob(["recovered"])
|
||||
})
|
||||
await expect(store.getItem("history")).rejects.toThrow("temporary storage failure")
|
||||
expect(JSON.parse((await store.getItem("history"))!).entries[0].prompt[0].blob.url).toStartWith("blob:")
|
||||
await expect(resolveBlobUrl({ id: "history-cache-failure" })).rejects.toThrow("temporary storage failure")
|
||||
expect(await resolveBlobUrl({ id: "history-cache-failure" })).toStartWith("blob:")
|
||||
expect(reads).toBe(2)
|
||||
})
|
||||
|
||||
test("keeps different blob IDs independent", async () => {
|
||||
const reads: string[] = []
|
||||
const store = createDraftStore({
|
||||
get: async () => JSON.stringify(["history-cache-first", "history-cache-second"].map((id) => ({ blob: { id } }))),
|
||||
createDraftStore({
|
||||
get: async () => null,
|
||||
set: async () => [],
|
||||
remove: async () => {},
|
||||
putBlob: async () => "unused",
|
||||
@@ -124,10 +115,8 @@ test("keeps different blob IDs independent", async () => {
|
||||
return new Blob([id])
|
||||
},
|
||||
})
|
||||
const value = JSON.parse((await store.getItem("history"))!)
|
||||
expect(value[0].blob.url).not.toBe(value[1].blob.url)
|
||||
expect(
|
||||
await Promise.all(value.map((item: { blob: { url: string } }) => resolveObjectURL(item.blob.url)?.text())),
|
||||
).toEqual(reads)
|
||||
const urls = await Promise.all(["history-cache-first", "history-cache-second"].map((id) => resolveBlobUrl({ id })))
|
||||
expect(urls[0]).not.toBe(urls[1])
|
||||
expect(await Promise.all(urls.map((url) => resolveObjectURL(url!)?.text()))).toEqual(reads)
|
||||
expect(reads).toEqual(["history-cache-first", "history-cache-second"])
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Schema } from "effect"
|
||||
import type { Platform } from "@/runtime/platform/platform"
|
||||
import { createComposerReady, createComposerState } from "@/composer/state"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
import { createDraftStore } from "@/runtime/persistence/drafts"
|
||||
import { createDraftStore, resolveBlobUrl } from "@/runtime/persistence/drafts"
|
||||
import { flushPersisted } from "@/runtime/persistence/persist"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
|
||||
@@ -103,15 +103,11 @@ describe("prompt persistence", () => {
|
||||
}),
|
||||
}))
|
||||
await root.session.ready.promise
|
||||
// Bytes stay in the store until the image is shown or sent.
|
||||
expect(root.session.current()).toEqual([
|
||||
{
|
||||
type: "image",
|
||||
id: "image",
|
||||
filename: "image.png",
|
||||
mime: "image/png",
|
||||
blob: { id: "composer-image", url: expect.stringMatching(/^blob:/) },
|
||||
},
|
||||
{ type: "image", id: "image", filename: "image.png", mime: "image/png", blob: { id: "composer-image", url: "" } },
|
||||
])
|
||||
expect(await resolveBlobUrl(root.session.current()[0]!.blob)).toStartWith("blob:")
|
||||
root.session.set([{ type: "text", content: "hello", start: 0, end: 5 }, ...root.session.current()])
|
||||
flushPersisted()
|
||||
await Bun.sleep(0)
|
||||
@@ -237,7 +233,7 @@ describe("prompt persistence", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("moves image data URLs into blobs and hydrates object URLs", async () => {
|
||||
test("moves image data URLs into blobs and resolves object URLs on demand", async () => {
|
||||
const documents = new Map<string, string>()
|
||||
const blobs = new Map<string, Blob>()
|
||||
const store = createDraftStore({
|
||||
@@ -258,8 +254,8 @@ test("moves image data URLs into blobs and hydrates object URLs", async () => {
|
||||
await store.setItem("prompt", JSON.stringify({ prompt: [{ type: "image", dataUrl: "data:image/png;base64,YQ==" }] }))
|
||||
expect(documents.get("prompt")).not.toContain("dataUrl")
|
||||
const value = JSON.parse((await store.getItem("prompt"))!)
|
||||
expect(value.prompt[0].blob.id).toBe("1")
|
||||
expect(value.prompt[0].blob.url).toStartWith("blob:")
|
||||
expect(value.prompt[0].blob).toEqual({ id: "1" })
|
||||
expect(await resolveBlobUrl(value.prompt[0].blob)).toStartWith("blob:")
|
||||
})
|
||||
|
||||
test("does not let delayed blob migration overwrite a newer draft", async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"name": "@opencode/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -48,7 +48,8 @@ test("does not package external copies of bundled dependencies", () => {
|
||||
expect(pkg.devDependencies.effect).toBe("catalog:")
|
||||
expect(pkg.devDependencies["@effect/platform-node"]).toBe("catalog:")
|
||||
expect(pkg.devDependencies["drizzle-orm"]).toBe("catalog:")
|
||||
expect(pkg.optionalDependencies["msgpackr-extract"]).toBe("3.0.4")
|
||||
// IPC crosses the port by structured clone; no MessagePack runtime or native accelerator ships.
|
||||
expect(Object.keys(pkg.optionalDependencies)).not.toContain("msgpackr-extract")
|
||||
})
|
||||
|
||||
test("keeps PTY binaries without stale native packaging", () => {
|
||||
@@ -101,6 +102,6 @@ test("bundles one Effect runtime and Drizzle while keeping native dependencies e
|
||||
expect(imports).toContain("node:sqlite")
|
||||
expect(chunks.some((chunk) => chunk.dynamicImports.includes("@zip.js/zip.js"))).toBe(true)
|
||||
expect(imports).toContain(`@lydell/node-pty-${process.platform}-${process.arch}`)
|
||||
expect(modules.some((id) => id.includes("/node_modules/msgpackr-extract/"))).toBe(false)
|
||||
expect(chunks.some((chunk) => chunk.code.includes("msgpackr-extract"))).toBe(true)
|
||||
expect(modules.some((id) => id.includes("/node_modules/msgpackr"))).toBe(false)
|
||||
expect(chunks.some((chunk) => chunk.code.includes("msgpackr"))).toBe(false)
|
||||
}, 30_000)
|
||||
|
||||
@@ -58,9 +58,9 @@ const require = __cjs_mod__.createRequire(import.meta.url);
|
||||
},
|
||||
},
|
||||
externalizeDeps: {
|
||||
// Bundle the Effect family together; native MessagePack acceleration stays optional and external.
|
||||
// Bundle the Effect family together.
|
||||
exclude: ["effect", "@effect/platform-node", "@effect/platform-node-shared", "drizzle-orm"],
|
||||
include: [nodePtyPkg, "msgpackr-extract"],
|
||||
include: [nodePtyPkg],
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode/desktop",
|
||||
"private": true,
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
@@ -68,7 +68,6 @@
|
||||
"@lydell/node-pty-linux-arm64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-win32-x64": "1.2.0-beta.12",
|
||||
"msgpackr-extract": "3.0.4"
|
||||
"@lydell/node-pty-win32-x64": "1.2.0-beta.12"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,17 +3,22 @@ import { EventEmitter } from "node:events"
|
||||
import { MessageChannel } from "node:worker_threads"
|
||||
import type { MessagePortMain, WebContents } from "electron"
|
||||
import { Context, Effect, Layer, ManagedRuntime, Option, Queue, Schema, Stream } from "effect"
|
||||
import { Rpc, RpcClient, RpcClientError, RpcGroup, RpcMessage, RpcSerialization, RpcServer } from "effect/unstable/rpc"
|
||||
import { Rpc, RpcClient, RpcClientError, RpcGroup, RpcMessage, RpcServer } from "effect/unstable/rpc"
|
||||
import { Transferable } from "effect/unstable/workers"
|
||||
import { IpcPortHandoff, IpcServerProtocolLive } from "./ipc-transport"
|
||||
|
||||
describe("desktop RPC transport", () => {
|
||||
test("keeps multiple renderer ports independent", async () => {
|
||||
let received: unknown
|
||||
const handlers = TestRpcs.toLayer(
|
||||
Effect.gen(function* () {
|
||||
const handoff = yield* IpcPortHandoff
|
||||
return TestRpcs.of({
|
||||
"test.focused": (_request, context) => Effect.succeed(handoff.sender(context.client.id)?.id === 1),
|
||||
"test.blob.put": ({ data }) => Effect.succeed([...data].join(",")),
|
||||
"test.blob.put": ({ data }) => {
|
||||
received = data
|
||||
return Effect.succeed([...data].join(","))
|
||||
},
|
||||
"test.blob.get": () => Effect.succeed(new Uint8Array([3, 1, 4])),
|
||||
"test.events": () => Stream.make(new TestEvent({ value: "session.new" })),
|
||||
})
|
||||
@@ -34,6 +39,8 @@ describe("desktop RPC transport", () => {
|
||||
expect(focused).toBe(true)
|
||||
expect(unfocused).toBe(false)
|
||||
expect(await putBlob(firstClient, new Uint8Array([2, 7, 1]))).toBe("2,7,1")
|
||||
// Binary payloads arrive as bytes, not as base64 text.
|
||||
expect(received).toBeInstanceOf(Uint8Array)
|
||||
expect(await getBlob(firstClient)).toEqual(new Uint8Array([3, 1, 4]))
|
||||
expect(await firstEvent(firstClient)).toEqual(new TestEvent({ value: "session.new" }))
|
||||
|
||||
@@ -54,8 +61,8 @@ describe("desktop RPC transport", () => {
|
||||
class TestEvent extends Schema.TaggedClass<TestEvent>()("TestEvent", { value: Schema.String }) {}
|
||||
const TestRpcs = RpcGroup.make(
|
||||
Rpc.make("test.focused", { success: Schema.Boolean }),
|
||||
Rpc.make("test.blob.put", { payload: { data: Schema.Uint8Array }, success: Schema.String }),
|
||||
Rpc.make("test.blob.get", { success: Schema.Uint8Array }),
|
||||
Rpc.make("test.blob.put", { payload: { data: Transferable.Uint8Array }, success: Schema.String }),
|
||||
Rpc.make("test.blob.get", { success: Transferable.Uint8Array }),
|
||||
Rpc.make("test.events", { success: TestEvent, stream: true }),
|
||||
)
|
||||
type TestRpcClient = RpcClient.FromGroup<typeof TestRpcs, RpcClientError.RpcClientError>
|
||||
@@ -109,13 +116,9 @@ function clientProtocol(port: MessagePort) {
|
||||
RpcClient.Protocol,
|
||||
RpcClient.Protocol.make(
|
||||
Effect.fnUntraced(function* (writeResponse, clientIds) {
|
||||
const serialization = yield* RpcSerialization.RpcSerialization
|
||||
const parser = serialization.makeUnsafe()
|
||||
const inbound = yield* Queue.unbounded<RpcMessage.FromServerEncoded>()
|
||||
const onMessage = (event: MessageEvent) =>
|
||||
parser
|
||||
.decode(event.data)
|
||||
.forEach((message) => Queue.offerUnsafe(inbound, message as RpcMessage.FromServerEncoded))
|
||||
Queue.offerUnsafe(inbound, event.data as RpcMessage.FromServerEncoded)
|
||||
port.addEventListener("message", onMessage)
|
||||
port.start()
|
||||
yield* Effect.addFinalizer(() =>
|
||||
@@ -131,18 +134,15 @@ function clientProtocol(port: MessagePort) {
|
||||
Effect.forkScoped,
|
||||
)
|
||||
return {
|
||||
codecFor: serialization.codecFor,
|
||||
codecFor: Schema.toCodecJson,
|
||||
send: (_clientId: number, request: RpcMessage.FromClientEncoded) =>
|
||||
Effect.sync(() => {
|
||||
const encoded = parser.encode(request)
|
||||
if (encoded !== undefined) port.postMessage(encoded)
|
||||
}),
|
||||
Effect.sync(() => port.postMessage(request)),
|
||||
supportsAck: true,
|
||||
supportsTransferables: false,
|
||||
}
|
||||
}),
|
||||
),
|
||||
).pipe(Layer.provide(RpcSerialization.layerMsgPack))
|
||||
)
|
||||
}
|
||||
|
||||
function sender(id: number) {
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import type { MessagePortMain, WebContents } from "electron"
|
||||
import { Context, Effect, Layer, Option, Queue, Stream } from "effect"
|
||||
import { RpcMessage, RpcSerialization, RpcServer } from "effect/unstable/rpc"
|
||||
import { createIpcCodec } from "../shared/ipc-codec"
|
||||
import { Context, Effect, Layer, Option, Queue, Schema, Stream } from "effect"
|
||||
import { RpcMessage, RpcServer } from "effect/unstable/rpc"
|
||||
import { bindIpcEvents } from "./ipc-events"
|
||||
|
||||
type PortBinding = {
|
||||
readonly id: number
|
||||
readonly sender: WebContents
|
||||
readonly port: MessagePortMain
|
||||
readonly parser: ReturnType<typeof createIpcCodec>
|
||||
readonly onMessage: (event: Electron.MessageEvent) => void
|
||||
readonly onClose: () => void
|
||||
readonly unbindEvents: Effect.Effect<void>
|
||||
@@ -21,6 +19,9 @@ type Handoff = {
|
||||
|
||||
export class IpcPortHandoff extends Context.Service<IpcPortHandoff, Handoff>()("opencode/desktop/IpcPortHandoff") {}
|
||||
|
||||
// Messages cross the port by structured clone, like Effect's worker protocol: no serialization
|
||||
// layer, so binary payloads stay binary and nothing is packed into a shared buffer. Electron's
|
||||
// MessagePortMain can only transfer ports, so byte payloads are cloned in both directions.
|
||||
export const IpcServerProtocolLive = Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const handoffs = yield* Queue.unbounded<readonly [WebContents, MessagePortMain]>()
|
||||
@@ -31,7 +32,6 @@ export const IpcServerProtocolLive = Layer.unwrap(
|
||||
RpcServer.Protocol,
|
||||
RpcServer.Protocol.make(
|
||||
Effect.fnUntraced(function* (writeRequest) {
|
||||
const serialization = yield* RpcSerialization.RpcSerialization
|
||||
const disconnects = yield* Queue.unbounded<number>()
|
||||
const inbound = yield* Queue.unbounded<readonly [number, RpcMessage.FromClientEncoded]>()
|
||||
const runFork = Effect.runForkWith(yield* Effect.context())
|
||||
@@ -59,21 +59,12 @@ export const IpcServerProtocolLive = Layer.unwrap(
|
||||
}
|
||||
|
||||
const id = nextClientId++
|
||||
const parser = createIpcCodec(serialization)
|
||||
const onMessage = (event: Electron.MessageEvent) => {
|
||||
try {
|
||||
parser
|
||||
.decode(event.data)
|
||||
.forEach((message) =>
|
||||
Queue.offerUnsafe(inbound, [id, message as RpcMessage.FromClientEncoded] as const),
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
Queue.offerUnsafe(inbound, [id, event.data as RpcMessage.FromClientEncoded] as const)
|
||||
}
|
||||
const onClose = () => runFork(disconnect(id))
|
||||
const unbindEvents = yield* bindIpcEvents(sender.id)
|
||||
const binding = { id, sender, port, parser, onMessage, onClose, unbindEvents }
|
||||
const binding = { id, sender, port, onMessage, onClose, unbindEvents }
|
||||
bindings.set(id, binding)
|
||||
senderBindings.set(sender.id, id)
|
||||
port.on("message", onMessage)
|
||||
@@ -93,14 +84,11 @@ export const IpcServerProtocolLive = Layer.unwrap(
|
||||
yield* Effect.addFinalizer(() => Effect.forEach([...bindings.keys()], disconnect, { discard: true }))
|
||||
|
||||
return {
|
||||
codecFor: serialization.codecFor,
|
||||
codecFor: Schema.toCodecJson,
|
||||
disconnects,
|
||||
send: (clientId, response) =>
|
||||
Effect.sync(() => {
|
||||
const binding = bindings.get(clientId)
|
||||
if (!binding) return
|
||||
const encoded = binding.parser.encode(response)
|
||||
if (encoded !== undefined) binding.port.postMessage(encoded)
|
||||
bindings.get(clientId)?.port.postMessage(response)
|
||||
}),
|
||||
end: disconnect,
|
||||
clientIds: Effect.sync(() => new Set(bindings.keys())),
|
||||
@@ -124,4 +112,4 @@ export const IpcServerProtocolLive = Layer.unwrap(
|
||||
}),
|
||||
)
|
||||
}),
|
||||
).pipe(Layer.provide(RpcSerialization.layerMsgPack))
|
||||
)
|
||||
|
||||
@@ -98,8 +98,10 @@ export function createDraftStore(
|
||||
orphans = true
|
||||
return id
|
||||
},
|
||||
getBlob(id: string): Uint8Array | null {
|
||||
return db.select({ data: blobs.data }).from(blobs).where(eq(blobs.id, id)).get()?.data ?? null
|
||||
getBlob(id: string): Uint8Array<ArrayBuffer> | null {
|
||||
const data = db.select({ data: blobs.data }).from(blobs).where(eq(blobs.id, id)).get()?.data
|
||||
// node:sqlite allocates a dedicated ArrayBuffer per BLOB column value.
|
||||
return data ? (data as Uint8Array<ArrayBuffer>) : null
|
||||
},
|
||||
flush: writer.flush,
|
||||
close: writer.close,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Context, Effect, Layer, ManagedRuntime, Queue, Stream } from "effect"
|
||||
import { RpcClient, RpcMessage, RpcSerialization } from "effect/unstable/rpc"
|
||||
import { createIpcCodec } from "../shared/ipc-codec"
|
||||
import { Context, Effect, Layer, ManagedRuntime, Queue, Schema, Stream } from "effect"
|
||||
import { RpcClient, RpcMessage } from "effect/unstable/rpc"
|
||||
import { DesktopRpcs, type DesktopRpcClient } from "../shared/ipc-rpc"
|
||||
import type { DesktopEvent } from "../shared/ipc-rpc/events"
|
||||
import { IpcTransportPort } from "../shared/ipc-transport"
|
||||
@@ -80,22 +79,17 @@ export function listen<Tag extends EventTag>(tag: Tag, listener: (value: EventVa
|
||||
}
|
||||
}
|
||||
|
||||
// Structured clone over the port, like Effect's worker protocol: no serialization layer, so binary
|
||||
// payloads stay binary. Buffers are cloned rather than transferred: Electron's MessagePortMain
|
||||
// drops transferred ArrayBuffers, so a request carrying one would never arrive.
|
||||
function clientProtocol(value: MessagePort) {
|
||||
return Layer.effect(
|
||||
RpcClient.Protocol,
|
||||
RpcClient.Protocol.make(
|
||||
Effect.fnUntraced(function* (writeResponse, clientIds) {
|
||||
const serialization = yield* RpcSerialization.RpcSerialization
|
||||
const parser = createIpcCodec(serialization)
|
||||
const inbound = yield* Queue.unbounded<RpcMessage.FromServerEncoded>()
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
try {
|
||||
parser
|
||||
.decode(event.data)
|
||||
.forEach((message) => Queue.offerUnsafe(inbound, message as RpcMessage.FromServerEncoded))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
Queue.offerUnsafe(inbound, event.data as RpcMessage.FromServerEncoded)
|
||||
}
|
||||
value.addEventListener("message", onMessage)
|
||||
value.start()
|
||||
@@ -112,16 +106,15 @@ function clientProtocol(value: MessagePort) {
|
||||
Effect.forkScoped,
|
||||
)
|
||||
return {
|
||||
codecFor: serialization.codecFor,
|
||||
codecFor: Schema.toCodecJson,
|
||||
send: (_clientId, request) =>
|
||||
Effect.sync(() => {
|
||||
const encoded = parser.encode(request)
|
||||
if (encoded !== undefined) value.postMessage(encoded)
|
||||
value.postMessage(request)
|
||||
}),
|
||||
supportsAck: true,
|
||||
supportsTransferables: false,
|
||||
}
|
||||
}),
|
||||
),
|
||||
).pipe(Layer.provide(RpcSerialization.layerMsgPack))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { RpcSerialization } from "effect/unstable/rpc"
|
||||
import { createIpcCodec, ipcLargeMessageBytes } from "./ipc-codec"
|
||||
|
||||
type Serialization = RpcSerialization.RpcSerialization["Service"]
|
||||
|
||||
const msgpack = RpcSerialization.makeMsgPack()
|
||||
|
||||
function counting(serialization: Serialization) {
|
||||
let created = 0
|
||||
const counted: Serialization = {
|
||||
...serialization,
|
||||
makeUnsafe: () => {
|
||||
created++
|
||||
return serialization.makeUnsafe()
|
||||
},
|
||||
}
|
||||
return { created: () => created, serialization: counted }
|
||||
}
|
||||
|
||||
describe("ipc codec", () => {
|
||||
test("posts an exact-size buffer instead of a view into the shared target", () => {
|
||||
const codec = createIpcCodec(msgpack)
|
||||
// The msgpack target starts at 8 KiB, so a view would drag a larger backing store along.
|
||||
const encoded = codec.encode({ _tag: "Request", id: "1", tag: "Ping", payload: {} })
|
||||
expect(encoded).toBeInstanceOf(Uint8Array)
|
||||
const bytes = encoded as Uint8Array
|
||||
expect(bytes.byteOffset).toBe(0)
|
||||
expect(bytes.buffer.byteLength).toBe(bytes.byteLength)
|
||||
})
|
||||
|
||||
test("copies Node Buffers, whose slice is only a view", () => {
|
||||
const backing = new ArrayBuffer(1024 * 1024)
|
||||
const view = Buffer.from(backing, 16, 32)
|
||||
view.fill(7)
|
||||
const stub: Serialization = { ...msgpack, makeUnsafe: () => ({ decode: () => [], encode: () => view }) }
|
||||
const encoded = createIpcCodec(stub).encode({}) as Uint8Array
|
||||
expect(encoded.buffer).not.toBe(backing)
|
||||
expect(encoded.buffer.byteLength).toBe(32)
|
||||
expect([...encoded]).toEqual(Array(32).fill(7))
|
||||
})
|
||||
|
||||
test("replaces the encoder after a large message and keeps the decoder", () => {
|
||||
const spy = counting(msgpack)
|
||||
const codec = createIpcCodec(spy.serialization)
|
||||
expect(spy.created()).toBe(2)
|
||||
codec.encode({ small: true })
|
||||
expect(spy.created()).toBe(2)
|
||||
codec.encode({ large: "x".repeat(ipcLargeMessageBytes) })
|
||||
expect(spy.created()).toBe(3)
|
||||
codec.decode(codec.encode({ after: 1 }) as Uint8Array)
|
||||
expect(spy.created()).toBe(3)
|
||||
})
|
||||
|
||||
test("round-trips messages through the wrapped serialization", () => {
|
||||
const client = createIpcCodec(msgpack)
|
||||
const server = createIpcCodec(msgpack)
|
||||
const message = { _tag: "Request", id: "7", tag: "DraftsSet", payload: { key: "k", value: "v" } }
|
||||
expect(server.decode(client.encode(message) as Uint8Array)).toEqual([message])
|
||||
})
|
||||
})
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { RpcSerialization } from "effect/unstable/rpc"
|
||||
|
||||
// After a message this large the encoder is replaced so its grown target buffer can be collected.
|
||||
export const ipcLargeMessageBytes = 1024 * 1024
|
||||
|
||||
// The MessagePack parser packs into one shared, grow-only target buffer and returns a view into it.
|
||||
// Posting that view structured-clones the whole backing buffer, so after one large message every
|
||||
// later message (even a 55-byte ack) would copy the full grown buffer across processes on each
|
||||
// send. Encoding through this wrapper posts an exact-size copy instead. Decoding keeps a single
|
||||
// parser for the connection: record structures the peer defined inline must stay known.
|
||||
export function createIpcCodec(serialization: RpcSerialization.RpcSerialization["Service"]) {
|
||||
const decoder = serialization.makeUnsafe()
|
||||
let encoder = serialization.makeUnsafe()
|
||||
return {
|
||||
decode: (bytes: Uint8Array | string) => decoder.decode(bytes),
|
||||
encode(message: unknown) {
|
||||
const encoded = encoder.encode(message)
|
||||
if (!(encoded instanceof Uint8Array)) return encoded
|
||||
// Not `.slice()`: in the main process the packer hands out a Node Buffer, whose slice is a view.
|
||||
const copy = new Uint8Array(encoded)
|
||||
if (copy.byteLength > ipcLargeMessageBytes) encoder = serialization.makeUnsafe()
|
||||
return copy
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { Rpc, RpcGroup } from "effect/unstable/rpc"
|
||||
import { Transferable } from "effect/unstable/workers"
|
||||
|
||||
const OptionalString = Schema.optional(Schema.String)
|
||||
const PickerOptions = Schema.Struct({
|
||||
@@ -18,7 +19,7 @@ const PickedFiles = Schema.Struct({
|
||||
token: Schema.String,
|
||||
files: Schema.Array(Schema.Struct({ path: Schema.String, name: Schema.String, size: Schema.Number })),
|
||||
})
|
||||
const ClipboardImage = Schema.Struct({ buffer: Schema.Uint8Array, width: Schema.Number, height: Schema.Number })
|
||||
const ClipboardImage = Schema.Struct({ buffer: Transferable.Uint8Array, width: Schema.Number, height: Schema.Number })
|
||||
|
||||
export const FilesOpenDirectoryPicker = Rpc.make("FilesOpenDirectoryPicker", {
|
||||
payload: { options: Schema.optional(PickerOptions) },
|
||||
@@ -30,7 +31,7 @@ export const FilesOpenFilePicker = Rpc.make("FilesOpenFilePicker", {
|
||||
})
|
||||
export const FilesReadPickedFile = Rpc.make("FilesReadPickedFile", {
|
||||
payload: { token: Schema.String, path: Schema.String },
|
||||
success: Schema.Uint8Array,
|
||||
success: Transferable.Uint8Array,
|
||||
})
|
||||
export const FilesReleasePickedFiles = Rpc.make("FilesReleasePickedFiles", {
|
||||
payload: { token: Schema.String },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { Rpc, RpcGroup } from "effect/unstable/rpc"
|
||||
import { Transferable } from "effect/unstable/workers"
|
||||
|
||||
export const StorageItems = Rpc.make("StorageItems", {
|
||||
payload: { name: Schema.String },
|
||||
@@ -24,12 +25,12 @@ export const DraftsSet = Rpc.make("DraftsSet", {
|
||||
})
|
||||
export const DraftsDelete = Rpc.make("DraftsDelete", { payload: { key: Schema.String } })
|
||||
export const DraftsPutBlob = Rpc.make("DraftsPutBlob", {
|
||||
payload: { data: Schema.Uint8Array },
|
||||
payload: { data: Transferable.Uint8Array },
|
||||
success: Schema.String,
|
||||
})
|
||||
export const DraftsGetBlob = Rpc.make("DraftsGetBlob", {
|
||||
payload: { id: Schema.String },
|
||||
success: Schema.NullOr(Schema.Uint8Array),
|
||||
success: Schema.NullOr(Transferable.Uint8Array),
|
||||
})
|
||||
|
||||
export const StorageRpcs = RpcGroup.make(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"name": "@opencode/http-recorder",
|
||||
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"description": "OpenCode's desktop browser plugin",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"name": "@opencode/sdk",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,33 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
story("renders the line comment cancel action as a ghost button", async ({ mount }) => {
|
||||
story("renders the line comment content editor and compact actions", async ({ mount }) => {
|
||||
const root = await mount("ui-line-comment--editor-filled")
|
||||
await expect(root.getByRole("button", { name: "Cancel" })).toHaveAttribute("data-variant", "ghost")
|
||||
const editor = root.getByRole("textbox")
|
||||
expect(await editor.evaluate((element) => element.tagName)).toBe("TEXTAREA")
|
||||
await editor.fill("Updated comment\nwith context")
|
||||
await expect(editor).toHaveValue("Updated comment\nwith context")
|
||||
await editor.fill("x")
|
||||
await editor.press("Backspace")
|
||||
await expect(editor).toHaveValue("")
|
||||
expect(await editor.evaluate((element) => element.matches(":placeholder-shown"))).toBe(true)
|
||||
await expect(root.locator("textarea")).toHaveCount(1)
|
||||
await expect(root.locator('[data-slot="line-comment-v2-label"]')).toHaveCount(0)
|
||||
await expect(root.locator('[data-slot="line-comment-v2-footer-meta"]')).toHaveCount(0)
|
||||
await expect(root.locator('[data-slot="line-comment-v2-shell"]')).toHaveCSS("padding", "0px")
|
||||
await expect(editor).toHaveCSS("border-top-width", "0px")
|
||||
await expect(editor).toHaveCSS("background-color", "rgba(0, 0, 0, 0)")
|
||||
await expect(editor).toHaveCSS("padding", "12px")
|
||||
await expect(root.getByRole("button", { name: "Cancel" })).toHaveAttribute("data-variant", "ghost-muted")
|
||||
await expect(root.getByRole("button", { name: "Cancel" })).toHaveAttribute("data-size", "small")
|
||||
await expect(root.getByRole("button", { name: "Comment" })).toHaveAttribute("data-variant", "submit")
|
||||
await expect(root.getByRole("button", { name: "Comment" })).toHaveAttribute("data-size", "small")
|
||||
})
|
||||
|
||||
story("preserves native undo in the line comment editor", async ({ mount }) => {
|
||||
const root = await mount("ui-line-comment--editor")
|
||||
const editor = root.getByRole("textbox")
|
||||
await editor.pressSequentially("undo me")
|
||||
await editor.press("Meta+z")
|
||||
await expect(editor).toHaveValue("")
|
||||
expect(await editor.evaluate((element) => element.matches(":placeholder-shown"))).toBe(true)
|
||||
})
|
||||
|
||||
@@ -48,3 +48,136 @@ story("shows a comment button when a diff line is hovered", async ({ mount }) =>
|
||||
await expect(review.getByRole("textbox")).toBeVisible()
|
||||
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
|
||||
})
|
||||
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
story(`offers a comment action for selected review text in ${direction}`, async ({ mount, page }) => {
|
||||
const root = await mount("components-session-review--interactive-comments-panel", { globals: { direction } })
|
||||
const action = page.getByRole("button", { name: "Add comment", exact: true })
|
||||
await expect(async () => {
|
||||
await root.locator('[data-line-type="change-addition"] [data-diff-span]').selectText()
|
||||
await expect(action).toBeVisible()
|
||||
}).toPass()
|
||||
await expect(root.getByRole("textbox")).not.toBeVisible()
|
||||
|
||||
await expect(action).toHaveAttribute("data-variant", "submit")
|
||||
await expect(action).toHaveCSS("z-index", "110")
|
||||
const box = await action.boundingBox()
|
||||
const code = root.locator("[data-code]").first()
|
||||
const gutterRight = await code.evaluate((element) => element.firstElementChild?.getBoundingClientRect().right)
|
||||
expect((box?.x ?? 0) - (gutterRight ?? 0)).toBe(8)
|
||||
expect(box?.x).toBeGreaterThanOrEqual(0)
|
||||
expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual(
|
||||
await page.evaluate(() => document.documentElement.clientWidth),
|
||||
)
|
||||
await action.click()
|
||||
|
||||
await expect.poll(() => page.evaluate(() => window.getSelection()?.toString())).toBe("")
|
||||
await expect(root.getByRole("textbox")).toBeVisible()
|
||||
await expect(root.locator('[data-line="2"][data-line-type="change-addition"]')).toHaveAttribute(
|
||||
"data-selected-line",
|
||||
/.*/,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
for (const direction of ["up", "down"] as const) {
|
||||
story(
|
||||
`positions the comment action with ${direction === "up" ? "an upward" : "a downward"} selection`,
|
||||
async ({ mount, page }) => {
|
||||
const root = await mount("components-session-review--interactive-comments-panel")
|
||||
const action = page.getByRole("button", { name: "Add comment", exact: true })
|
||||
await expect(async () => {
|
||||
await root.getByText("export const first = 1", { exact: true }).evaluate((element, value) => {
|
||||
const root = element.getRootNode()
|
||||
if (!(root instanceof ShadowRoot)) throw new Error("Expected a shadow root")
|
||||
const text = (line: number) => {
|
||||
const row = root.querySelector(`[data-line="${line}"]`)
|
||||
if (!row) throw new Error(`Missing line ${line}`)
|
||||
const node = document.createTreeWalker(row, NodeFilter.SHOW_TEXT).nextNode()
|
||||
if (!node) throw new Error(`Missing text for line ${line}`)
|
||||
return node
|
||||
}
|
||||
const first = text(1)
|
||||
const last = text(3)
|
||||
const selection = window.getSelection()
|
||||
if (!selection) throw new Error("Missing selection")
|
||||
if (value === "up") {
|
||||
selection.setBaseAndExtent(last, last.textContent?.length ?? 0, first, 0)
|
||||
} else {
|
||||
selection.setBaseAndExtent(first, 0, last, last.textContent?.length ?? 0)
|
||||
}
|
||||
document.dispatchEvent(new Event("selectionchange"))
|
||||
}, direction)
|
||||
await expect(action).toHaveAttribute("data-placement", direction === "up" ? "top" : "bottom")
|
||||
await expect(action).toHaveClass(/transition-transform/)
|
||||
await expect(action).toHaveClass(/ease-out/)
|
||||
await expect(action).not.toHaveClass(/fade-in/)
|
||||
await expect
|
||||
.poll(() =>
|
||||
action.evaluate((button) => {
|
||||
const host = button.closest('[data-component="file"]')?.querySelector("diffs-container")
|
||||
const root = host?.shadowRoot
|
||||
if (!root) return NaN
|
||||
const selection =
|
||||
(root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ??
|
||||
window.getSelection()
|
||||
const source = (
|
||||
selection as unknown as {
|
||||
getComposedRanges?: (options: { shadowRoots: ShadowRoot[] }) => StaticRange[]
|
||||
}
|
||||
)?.getComposedRanges?.({ shadowRoots: [root] })?.[0]
|
||||
if (!source) return NaN
|
||||
const range = new Range()
|
||||
range.setStart(source.startContainer, source.startOffset)
|
||||
range.setEnd(source.endContainer, source.endOffset)
|
||||
const selected = range.getBoundingClientRect()
|
||||
const action = button.getBoundingClientRect()
|
||||
return button.getAttribute("data-placement") === "top"
|
||||
? selected.top - action.bottom
|
||||
: action.top - selected.bottom
|
||||
}),
|
||||
)
|
||||
.toBeCloseTo(8, 0)
|
||||
}).toPass()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
story("leaves a review code click as regular text interaction", async ({ mount }) => {
|
||||
const root = await mount("components-session-review--interactive-comments-panel")
|
||||
await root.locator('[data-line-type="change-addition"] [data-diff-span]').click()
|
||||
await expect(root.getByRole("textbox")).not.toBeVisible()
|
||||
})
|
||||
|
||||
story("keeps direct line-number range comments in the review panel", async ({ mount }) => {
|
||||
const root = await mount("components-session-review--interactive-comments-panel")
|
||||
await root.locator('[data-column-number="1"]').dragTo(root.locator('[data-column-number="3"]'))
|
||||
await expect(root.getByRole("textbox")).toBeVisible()
|
||||
await expect(root.locator("[data-selected-line]")).not.toHaveCount(0)
|
||||
})
|
||||
|
||||
story("keeps the direct gutter comment action in the review panel", async ({ mount }) => {
|
||||
const root = await mount("components-session-review--interactive-comments-panel")
|
||||
const comment = root.getByRole("button", { name: "Comment", exact: true, includeHidden: true })
|
||||
await expect(async () => {
|
||||
await root.getByText("export const first = 1", { exact: true }).hover()
|
||||
await expect(comment).toBeVisible()
|
||||
}).toPass()
|
||||
expect(await comment.evaluate((element) => (element as HTMLElement).style.background)).toBe(
|
||||
"var(--v2-background-bg-inverse)",
|
||||
)
|
||||
expect(await comment.evaluate((element) => (element as HTMLElement).style.left)).toBe("-4px")
|
||||
await expect(comment).toHaveCSS("z-index", "110")
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await comment.boundingBox()
|
||||
const gutterRight = await comment.evaluate(
|
||||
(element) => element.parentElement?.assignedSlot?.parentElement?.parentElement?.getBoundingClientRect().right,
|
||||
)
|
||||
return (box?.x ?? 0) + (box?.width ?? 0) - (gutterRight ?? 0)
|
||||
})
|
||||
.toBe(-4)
|
||||
await comment.dispatchEvent("click")
|
||||
await expect(root.getByRole("textbox")).toBeVisible()
|
||||
await expect(root.locator('[data-line="1"]')).toHaveAttribute("data-selected-line", /.*/)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -47,6 +47,7 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
|
||||
"onLineNumberSelectionEnd",
|
||||
"onRendered",
|
||||
"preloadedDiff",
|
||||
"textSelectionAction",
|
||||
])
|
||||
|
||||
const getRoot = () => fileDiffRef?.shadowRoot ?? undefined
|
||||
|
||||
@@ -21,10 +21,12 @@ import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pi
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { createDefaultOptions, styleVariables } from "../pierre"
|
||||
import { markCommentedDiffLines, markCommentedFileLines } from "../pierre/commented-lines"
|
||||
import { fixDiffSelection, findDiffSide, type DiffSelectionSide } from "../pierre/diff-selection"
|
||||
import { createFileFind } from "../pierre/file-find"
|
||||
import { LINE_COMMENT_ACTION_GAP } from "../pierre/comment-hover"
|
||||
import {
|
||||
applyViewerScheme,
|
||||
clearReadyWatcher,
|
||||
@@ -48,6 +50,8 @@ import { FileMedia, type FileMediaOptions } from "./file-media"
|
||||
import { FileSearchBar } from "./file-search"
|
||||
|
||||
const VIRTUALIZE_BYTES = 500_000
|
||||
const TEXT_SELECTION_ACTION_HEIGHT = 24
|
||||
const TEXT_SELECTION_ACTION_GAP = 8
|
||||
|
||||
const codeMetrics = {
|
||||
...DEFAULT_VIRTUAL_FILE_METRICS,
|
||||
@@ -65,6 +69,9 @@ type SharedProps<T> = {
|
||||
classList?: ComponentProps<"div">["classList"]
|
||||
media?: FileMediaOptions
|
||||
search?: FileSearchControl
|
||||
textSelectionAction?: {
|
||||
label: string
|
||||
}
|
||||
}
|
||||
|
||||
export type FileSearchHandle = {
|
||||
@@ -123,6 +130,7 @@ const sharedKeys = [
|
||||
"onLineNumberSelectionEnd",
|
||||
"onRendered",
|
||||
"preloadedDiff",
|
||||
"textSelectionAction",
|
||||
] as const
|
||||
|
||||
const textKeys = ["file", ...sharedKeys] as const
|
||||
@@ -140,6 +148,7 @@ type MouseHit = {
|
||||
|
||||
type ViewerConfig = {
|
||||
enableLineSelection: () => boolean
|
||||
textSelectionAction: () => { label: string } | undefined
|
||||
selectedLines: () => SelectedLineRange | null | undefined
|
||||
commentedLines: () => SelectedLineRange[]
|
||||
onLineSelectionEnd: (range: SelectedLineRange | null) => void
|
||||
@@ -148,6 +157,14 @@ type ViewerConfig = {
|
||||
lineFromMouseEvent: (event: MouseEvent) => MouseHit
|
||||
setSelectedLines: (range: SelectedLineRange | null, preserve?: { root: ShadowRoot; text: Range }) => void
|
||||
updateSelection: (preserveTextSelection: boolean) => void
|
||||
readTextSelection: () =>
|
||||
| {
|
||||
range: SelectedLineRange
|
||||
text: Range
|
||||
direction: "up" | "down" | "same"
|
||||
gutterRight?: number
|
||||
}
|
||||
| undefined
|
||||
buildDragSelection: () => SelectedLineRange | undefined
|
||||
buildClickSelection: () => SelectedLineRange | undefined
|
||||
onDragStart: (hit: MouseHit) => void
|
||||
@@ -162,6 +179,7 @@ function useFileViewer(config: ViewerConfig) {
|
||||
let overlay!: HTMLDivElement
|
||||
let selectionFrame: number | undefined
|
||||
let dragFrame: number | undefined
|
||||
let textSelectionFrame: number | undefined
|
||||
let dragStart: number | undefined
|
||||
let dragEnd: number | undefined
|
||||
let dragMoved = false
|
||||
@@ -171,6 +189,14 @@ function useFileViewer(config: ViewerConfig) {
|
||||
const ready = createReadyWatcher()
|
||||
const bridge = createLineNumberSelectionBridge()
|
||||
const [rendered, setRendered] = createSignal(0)
|
||||
const [textSelection, setTextSelection] = createSignal<{
|
||||
range: SelectedLineRange
|
||||
rect: DOMRect
|
||||
label: string
|
||||
below: boolean
|
||||
gutterEdge: number
|
||||
}>()
|
||||
const hasTextSelection = createMemo(() => textSelection() !== undefined)
|
||||
|
||||
const getRoot = () => getViewerRoot(container)
|
||||
const getHost = () => getViewerHost(container)
|
||||
@@ -204,6 +230,55 @@ function useFileViewer(config: ViewerConfig) {
|
||||
})
|
||||
}
|
||||
|
||||
const updateTextSelection = () => {
|
||||
textSelectionFrame = undefined
|
||||
const action = config.textSelectionAction()
|
||||
if (!action) {
|
||||
setTextSelection(undefined)
|
||||
return
|
||||
}
|
||||
const selected = config.readTextSelection()
|
||||
if (!selected) {
|
||||
setTextSelection(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const rect = selected.text.getBoundingClientRect()
|
||||
if (rect.width === 0 && rect.height === 0) {
|
||||
setTextSelection(undefined)
|
||||
return
|
||||
}
|
||||
const roomBelow = rect.bottom + TEXT_SELECTION_ACTION_HEIGHT + TEXT_SELECTION_ACTION_GAP <= window.innerHeight
|
||||
const roomAbove = rect.top - TEXT_SELECTION_ACTION_HEIGHT - TEXT_SELECTION_ACTION_GAP >= 0
|
||||
const preferBelow = selected.direction !== "up"
|
||||
const below = preferBelow ? roomBelow || !roomAbove : !roomAbove && roomBelow
|
||||
const gutterEdge =
|
||||
(selected.gutterRight ?? wrapper.getBoundingClientRect().left) - wrapper.getBoundingClientRect().left
|
||||
setTextSelection({ range: selected.range, rect, label: action.label, below, gutterEdge })
|
||||
}
|
||||
|
||||
const scheduleTextSelectionUpdate = () => {
|
||||
if (textSelectionFrame !== undefined) return
|
||||
textSelectionFrame = requestAnimationFrame(updateTextSelection)
|
||||
}
|
||||
|
||||
const clearTextSelection = () => {
|
||||
setTextSelection(undefined)
|
||||
const root = getRoot()
|
||||
const selection =
|
||||
(root as unknown as { getSelection?: () => Selection | null } | undefined)?.getSelection?.() ??
|
||||
window.getSelection()
|
||||
selection?.removeAllRanges()
|
||||
}
|
||||
|
||||
const activateTextSelection = () => {
|
||||
const selected = textSelection()
|
||||
if (!selected) return
|
||||
clearTextSelection()
|
||||
config.setSelectedLines(selected.range)
|
||||
config.onLineSelectionEnd(selected.range)
|
||||
}
|
||||
|
||||
// -- mouse handlers --
|
||||
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
@@ -218,6 +293,11 @@ function useFileViewer(config: ViewerConfig) {
|
||||
if (hit.line === undefined) return
|
||||
|
||||
bridge.begin(false, hit.line)
|
||||
if (config.textSelectionAction()) {
|
||||
setTextSelection(undefined)
|
||||
if (lastSelection) config.setSelectedLines(null)
|
||||
return
|
||||
}
|
||||
dragStart = hit.line
|
||||
dragEnd = hit.line
|
||||
dragMoved = false
|
||||
@@ -250,6 +330,10 @@ function useFileViewer(config: ViewerConfig) {
|
||||
const handleMouseUp = () => {
|
||||
if (!config.enableLineSelection()) return
|
||||
if (bridge.finish() === "numbers") return
|
||||
if (config.textSelectionAction()) {
|
||||
scheduleTextSelectionUpdate()
|
||||
return
|
||||
}
|
||||
if (dragStart === undefined) return
|
||||
|
||||
if (!dragMoved) {
|
||||
@@ -284,6 +368,10 @@ function useFileViewer(config: ViewerConfig) {
|
||||
|
||||
const handleSelectionChange = () => {
|
||||
if (!config.enableLineSelection()) return
|
||||
if (config.textSelectionAction()) {
|
||||
scheduleTextSelectionUpdate()
|
||||
return
|
||||
}
|
||||
if (dragStart === undefined) return
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.isCollapsed) return
|
||||
@@ -326,7 +414,9 @@ function useFileViewer(config: ViewerConfig) {
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
config.setSelectedLines(config.selectedLines() ?? null)
|
||||
const selected = config.selectedLines() ?? null
|
||||
if (selected && config.textSelectionAction()) clearTextSelection()
|
||||
config.setSelectedLines(selected)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -338,14 +428,26 @@ function useFileViewer(config: ViewerConfig) {
|
||||
makeEventListener(document, "selectionchange", handleSelectionChange)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!config.enableLineSelection() || !config.textSelectionAction() || !hasTextSelection()) return
|
||||
makeEventListener(document, "scroll", scheduleTextSelectionUpdate, true)
|
||||
makeEventListener(window, "resize", scheduleTextSelectionUpdate)
|
||||
makeEventListener(document, "keydown", (event) => {
|
||||
if (event.key !== "Escape") return
|
||||
clearTextSelection()
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
clearReadyWatcher(ready)
|
||||
|
||||
if (selectionFrame !== undefined) cancelAnimationFrame(selectionFrame)
|
||||
if (dragFrame !== undefined) cancelAnimationFrame(dragFrame)
|
||||
if (textSelectionFrame !== undefined) cancelAnimationFrame(textSelectionFrame)
|
||||
|
||||
selectionFrame = undefined
|
||||
dragFrame = undefined
|
||||
textSelectionFrame = undefined
|
||||
dragStart = undefined
|
||||
dragEnd = undefined
|
||||
dragMoved = false
|
||||
@@ -393,15 +495,21 @@ function useFileViewer(config: ViewerConfig) {
|
||||
getHost,
|
||||
find,
|
||||
scheduleSelectionUpdate,
|
||||
textSelection,
|
||||
activateTextSelection,
|
||||
}
|
||||
}
|
||||
|
||||
type Viewer = ReturnType<typeof useFileViewer>
|
||||
|
||||
type ModeAdapter = Omit<ViewerConfig, "enableLineSelection" | "selectedLines" | "commentedLines" | "onLineSelectionEnd">
|
||||
type ModeAdapter = Omit<
|
||||
ViewerConfig,
|
||||
"enableLineSelection" | "textSelectionAction" | "selectedLines" | "commentedLines" | "onLineSelectionEnd"
|
||||
>
|
||||
|
||||
type ModeConfig = {
|
||||
enableLineSelection: () => boolean
|
||||
textSelectionAction: () => { label: string } | undefined
|
||||
selectedLines: () => SelectedLineRange | null | undefined
|
||||
commentedLines: () => SelectedLineRange[] | undefined
|
||||
onLineSelectionEnd: (range: SelectedLineRange | null) => void
|
||||
@@ -424,6 +532,7 @@ type VirtualStrategy = {
|
||||
function useModeViewer(config: ModeConfig, adapter: ModeAdapter) {
|
||||
return useFileViewer({
|
||||
enableLineSelection: config.enableLineSelection,
|
||||
textSelectionAction: config.textSelectionAction,
|
||||
selectedLines: config.selectedLines,
|
||||
commentedLines: () => config.commentedLines() ?? [],
|
||||
onLineSelectionEnd: config.onLineSelectionEnd,
|
||||
@@ -728,6 +837,40 @@ function ViewerShell(props: {
|
||||
</Show>
|
||||
<div ref={(el) => (props.viewer.container = el)} />
|
||||
<div ref={(el) => (props.viewer.overlay = el)} class="pointer-events-none absolute inset-0 z-0" />
|
||||
<Show when={props.viewer.textSelection()}>
|
||||
{(selection) => (
|
||||
<Button
|
||||
data-slot="file-text-selection-action"
|
||||
data-placement={selection().below ? "bottom" : "top"}
|
||||
size="small"
|
||||
variant="submit"
|
||||
class="z-[110] whitespace-nowrap motion-safe:transition-transform duration-100 ease-out motion-reduce:transition-none"
|
||||
style={{
|
||||
position: "absolute",
|
||||
"--line-comment-gutter-edge": `${selection().gutterEdge}px`,
|
||||
left: `calc(var(--line-comment-gutter-edge) + ${LINE_COMMENT_ACTION_GAP}px)`,
|
||||
top: `${
|
||||
(selection().below ? selection().rect.bottom : selection().rect.top) -
|
||||
props.viewer.wrapper.getBoundingClientRect().top
|
||||
}px`,
|
||||
transform: selection().below
|
||||
? `translateY(${TEXT_SELECTION_ACTION_GAP}px)`
|
||||
: `translateY(calc(-100% - ${TEXT_SELECTION_ACTION_GAP}px))`,
|
||||
}}
|
||||
onPointerDown={(event: PointerEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onMouseDown={(event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={props.viewer.activateTextSelection}
|
||||
>
|
||||
{selection().label}
|
||||
</Button>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -845,6 +988,23 @@ function TextViewer<T>(props: TextFileProps<T>) {
|
||||
if (!preserveTextSelection || !selected.text) return
|
||||
restoreShadowTextSelection(root, selected.text)
|
||||
},
|
||||
readTextSelection: () => {
|
||||
const root = viewer.getRoot()
|
||||
if (!root) return
|
||||
const selected = readShadowLineSelection({
|
||||
root,
|
||||
lineForNode: findFileLineNumber,
|
||||
sideForNode: findCodeSelectionSide,
|
||||
preserveTextSelection: true,
|
||||
})
|
||||
if (!selected?.text) return
|
||||
return {
|
||||
range: selected.range,
|
||||
text: selected.text,
|
||||
direction: selected.direction,
|
||||
gutterRight: selected.gutterRight,
|
||||
}
|
||||
},
|
||||
buildDragSelection: () => {
|
||||
if (viewer.dragStart === undefined || viewer.dragEnd === undefined) return
|
||||
return { start: Math.min(viewer.dragStart, viewer.dragEnd), end: Math.max(viewer.dragStart, viewer.dragEnd) }
|
||||
@@ -862,6 +1022,7 @@ function TextViewer<T>(props: TextFileProps<T>) {
|
||||
viewer = useModeViewer(
|
||||
{
|
||||
enableLineSelection: () => props.enableLineSelection === true,
|
||||
textSelectionAction: () => local.textSelectionAction,
|
||||
selectedLines: () => local.selectedLines,
|
||||
commentedLines: () => local.commentedLines,
|
||||
onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
|
||||
@@ -1008,6 +1169,20 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
|
||||
|
||||
setSelectedLines(selected.range)
|
||||
},
|
||||
readTextSelection: () => {
|
||||
const root = viewer.getRoot()
|
||||
if (!root) return
|
||||
const selected = readShadowLineSelection({
|
||||
root,
|
||||
lineForNode: findDiffLineNumber,
|
||||
sideForNode: diffSelectionSide,
|
||||
preserveTextSelection: true,
|
||||
})
|
||||
if (!selected?.text) return
|
||||
const range = fixDiffSelection(root, selected.range)
|
||||
if (!range) return
|
||||
return { range, text: selected.text, direction: selected.direction, gutterRight: selected.gutterRight }
|
||||
},
|
||||
buildDragSelection: () => {
|
||||
if (viewer.dragStart === undefined || viewer.dragEnd === undefined) return
|
||||
const selected: SelectedLineRange = { start: viewer.dragStart, end: viewer.dragEnd }
|
||||
@@ -1038,6 +1213,7 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
|
||||
viewer = useModeViewer(
|
||||
{
|
||||
enableLineSelection: () => props.enableLineSelection === true,
|
||||
textSelectionAction: () => local.textSelectionAction,
|
||||
selectedLines: () => local.selectedLines,
|
||||
commentedLines: () => local.commentedLines,
|
||||
onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CurrentSessionProviders } from "../storybook/current-session-story"
|
||||
import { editThenTestDocument, reviewDiffs } from "../storybook/current-session-fixtures"
|
||||
import { File } from "./file"
|
||||
import { SessionReview, type SessionReviewComment } from "./session-review"
|
||||
import { SessionReviewFilePreviewV2 } from "../v2/components/session-review-file-preview-v2"
|
||||
|
||||
function ReviewStory(props: { split?: boolean }) {
|
||||
return (
|
||||
@@ -80,6 +81,36 @@ function InteractiveCommentsStory() {
|
||||
|
||||
export const InteractiveComments = { render: () => <InteractiveCommentsStory /> }
|
||||
|
||||
function InteractiveCommentsV2Story() {
|
||||
const [state, setState] = createStore({ comments: [] as SessionReviewComment[] })
|
||||
const file = "src/review.ts"
|
||||
const diff = {
|
||||
file,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified" as const,
|
||||
patch:
|
||||
"diff --git a/src/review.ts b/src/review.ts\n--- a/src/review.ts\n+++ b/src/review.ts\n@@ -1,3 +1,3 @@\n export const first = 1\n-export const value = 'before'\n+export const value = 'after'\n export const last = 3\n",
|
||||
}
|
||||
return (
|
||||
<CurrentSessionProviders document={editThenTestDocument}>
|
||||
<div class="mx-auto h-screen min-h-[620px] w-full max-w-[900px] overflow-auto bg-background-base">
|
||||
<SessionReviewFilePreviewV2
|
||||
file={file}
|
||||
diff={diff}
|
||||
diffStyle="unified"
|
||||
comments={state.comments}
|
||||
onLineComment={(comment) =>
|
||||
setState("comments", (comments) => [...comments, { id: `comment-${comments.length + 1}`, ...comment }])
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CurrentSessionProviders>
|
||||
)
|
||||
}
|
||||
|
||||
export const InteractiveCommentsPanel = { render: () => <InteractiveCommentsV2Story /> }
|
||||
|
||||
const gitDiffs = [
|
||||
{
|
||||
// OpenCode 93e1f383dd79683af4fc5ad139cea0516603c838, unchanged git-show output.
|
||||
|
||||
@@ -3,6 +3,9 @@ export type HoverCommentLine = {
|
||||
side?: "additions" | "deletions"
|
||||
}
|
||||
|
||||
export const LINE_COMMENT_ACTION_GAP = 8
|
||||
const LINE_COMMENT_ACTION_SIZE = 20
|
||||
|
||||
export function createHoverCommentUtility(props: {
|
||||
label: string
|
||||
getHoveredLine: () => HoverCommentLine | undefined
|
||||
@@ -14,21 +17,22 @@ export function createHoverCommentUtility(props: {
|
||||
button.type = "button"
|
||||
button.ariaLabel = props.label
|
||||
button.textContent = "+"
|
||||
button.style.width = "20px"
|
||||
button.style.height = "20px"
|
||||
button.style.width = `${LINE_COMMENT_ACTION_SIZE}px`
|
||||
button.style.height = `${LINE_COMMENT_ACTION_SIZE}px`
|
||||
button.style.display = "flex"
|
||||
button.style.alignItems = "center"
|
||||
button.style.justifyContent = "center"
|
||||
button.style.border = "none"
|
||||
button.style.borderRadius = "var(--radius-md)"
|
||||
button.style.background = "var(--icon-interactive-base)"
|
||||
button.style.color = "var(--white)"
|
||||
button.style.background = "var(--v2-background-bg-inverse)"
|
||||
button.style.color = "var(--v2-icon-icon-inverse)"
|
||||
button.style.boxShadow = "var(--shadow-xs)"
|
||||
button.style.fontSize = "14px"
|
||||
button.style.lineHeight = "1"
|
||||
button.style.cursor = "pointer"
|
||||
button.style.position = "relative"
|
||||
button.style.left = "30px"
|
||||
button.style.zIndex = "110"
|
||||
button.style.left = "-4px"
|
||||
button.style.top = "calc((var(--diffs-line-height, 24px) - 20px) / 2)"
|
||||
|
||||
let line: HoverCommentLine | undefined
|
||||
@@ -59,6 +63,29 @@ export function createHoverCommentUtility(props: {
|
||||
props.onSelect(next)
|
||||
}
|
||||
|
||||
const startLineSelection = (event: PointerEvent) => {
|
||||
const number = button.parentElement?.assignedSlot?.parentElement?.parentElement
|
||||
if (!(number instanceof HTMLElement)) return
|
||||
number.dispatchEvent(
|
||||
new PointerEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
pointerId: event.pointerId,
|
||||
pointerType: event.pointerType,
|
||||
isPrimary: event.isPrimary,
|
||||
button: event.button,
|
||||
buttons: event.buttons,
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
ctrlKey: event.ctrlKey,
|
||||
metaKey: event.metaKey,
|
||||
shiftKey: event.shiftKey,
|
||||
altKey: event.altKey,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
document.addEventListener("pointermove", onHoverInvalidated, { passive: true })
|
||||
document.addEventListener("scroll", onHoverInvalidated, { passive: true, capture: true })
|
||||
button.addEventListener("mouseenter", sync)
|
||||
@@ -67,6 +94,7 @@ export function createHoverCommentUtility(props: {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
sync()
|
||||
startLineSelection(event)
|
||||
})
|
||||
button.addEventListener("mousedown", (event) => {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -73,6 +73,19 @@ export function readShadowLineSelection(opts: {
|
||||
const startSide = opts.sideForNode?.(startNode)
|
||||
const endSide = opts.sideForNode?.(endNode)
|
||||
const side = startSide ?? endSide
|
||||
const anchorTop = findElement(selection.anchorNode)
|
||||
?.closest("[data-line], [data-alt-line]")
|
||||
?.getBoundingClientRect().top
|
||||
const focusElement = findElement(selection.focusNode)
|
||||
const focusTop = focusElement?.closest("[data-line], [data-alt-line]")?.getBoundingClientRect().top
|
||||
const code = focusElement?.closest("[data-code]")
|
||||
const gutterRight = code?.firstElementChild?.getBoundingClientRect().right
|
||||
const direction =
|
||||
anchorTop === undefined || focusTop === undefined || anchorTop === focusTop
|
||||
? ("same" as const)
|
||||
: focusTop < anchorTop
|
||||
? ("up" as const)
|
||||
: ("down" as const)
|
||||
|
||||
const range: SelectedLineRange = { start, end }
|
||||
if (side) range.side = side
|
||||
@@ -81,5 +94,7 @@ export function readShadowLineSelection(opts: {
|
||||
return {
|
||||
range,
|
||||
text: opts.preserveTextSelection && domRange ? toRange(domRange).cloneRange() : undefined,
|
||||
direction,
|
||||
gutterRight,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +130,12 @@ const unsafeCSS = `
|
||||
color: var(--diffs-selection-number-fg);
|
||||
}
|
||||
|
||||
[data-gutter-utility-slot] {
|
||||
left: unset;
|
||||
right: 0;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
[data-diff] [data-column-number][data-line-type='context'][data-selected-line],
|
||||
[data-diff] [data-column-number][data-line-type='context-expanded'][data-selected-line],
|
||||
[data-diff] [data-column-number][data-line-type='change-addition'][data-selected-line],
|
||||
|
||||
@@ -228,6 +228,7 @@ export function SessionReviewFilePreviewV2(props: SessionReviewFilePreviewV2Prop
|
||||
hunkSeparators={view().fileDiff.isPartial ? "simple" : "line-info-basic"}
|
||||
enableLineSelection={lineCommentsEnabled()}
|
||||
enableGutterUtility={lineCommentsEnabled()}
|
||||
textSelectionAction={lineCommentsEnabled() ? { label: i18n.t("ui.lineComment.add") } : undefined}
|
||||
onLineSelected={(range: SelectedLineRange | null) => {
|
||||
if (!lineCommentsEnabled()) return
|
||||
commentsUi.onLineSelected(range)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -117,85 +117,61 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 12px;
|
||||
gap: 12px;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-field"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-label"] {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-compact);
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--v2-text-text-base);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-textarea"] {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 80px;
|
||||
padding: 8px;
|
||||
height: 80px;
|
||||
padding: 12px;
|
||||
margin: 0;
|
||||
resize: vertical;
|
||||
border: 1px solid var(--v2-border-border-base);
|
||||
border-radius: 6px;
|
||||
background:
|
||||
linear-gradient(180deg, var(--v2-alpha-light-2) 0%, var(--v2-alpha-light-0) 100%), var(--v2-background-bg-base);
|
||||
resize: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 440;
|
||||
line-height: 1.35;
|
||||
line-height: var(--line-height-compact);
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--v2-text-text-base);
|
||||
font-variation-settings: "slnt" 0;
|
||||
scrollbar-width: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-textarea"]::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-textarea"]::placeholder {
|
||||
color: var(--v2-text-text-faint);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-textarea"]:focus {
|
||||
border-color: var(--v2-border-border-focus);
|
||||
}
|
||||
|
||||
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-footer"] {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
padding-block: 0 12px;
|
||||
padding-inline: 12px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-footer-meta"] {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 530;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.05px;
|
||||
color: var(--v2-text-text-faint);
|
||||
font-variation-settings: "slnt" 0;
|
||||
}
|
||||
|
||||
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-footer-actions"] {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
@@ -61,7 +61,7 @@ export type LineCommentEditorMention = {
|
||||
}
|
||||
|
||||
export interface LineCommentEditorProps extends Omit<ComponentProps<"div">, "children" | "onInput" | "onSubmit"> {
|
||||
/** Visible field label above the textarea (default: “Comment”). */
|
||||
/** Accessible editor label (default: “Comment”). */
|
||||
heading?: JSX.Element | string
|
||||
value: string
|
||||
onInput: (value: string) => void
|
||||
@@ -108,7 +108,6 @@ export function LineCommentEditor(props: LineCommentEditorProps) {
|
||||
"classList",
|
||||
])
|
||||
|
||||
const heading = () => local.heading ?? i18n.t("ui.lineComment.submit")
|
||||
const canSubmit = () => local.value.trim().length > 0
|
||||
|
||||
const closeMention = () => {
|
||||
@@ -206,19 +205,19 @@ export function LineCommentEditor(props: LineCommentEditorProps) {
|
||||
>
|
||||
<div data-slot="line-comment-v2-shell">
|
||||
<div data-slot="line-comment-v2-field">
|
||||
<div data-slot="line-comment-v2-label">{heading()}</div>
|
||||
<textarea
|
||||
ref={(el) => {
|
||||
textareaRef = el
|
||||
}}
|
||||
data-slot="line-comment-v2-textarea"
|
||||
aria-label={typeof local.heading === "string" ? local.heading : i18n.t("ui.lineComment.submit")}
|
||||
dir="auto"
|
||||
rows={local.rows ?? 3}
|
||||
placeholder={local.placeholder ?? i18n.t("ui.lineComment.contextPlaceholder")}
|
||||
value={local.value}
|
||||
style={{ "unicode-bidi": "plaintext", "text-align": "start" }}
|
||||
onInput={(e) => {
|
||||
local.onInput(e.currentTarget.value)
|
||||
onInput={(event) => {
|
||||
local.onInput(event.currentTarget.value)
|
||||
syncMention()
|
||||
}}
|
||||
onClick={() => syncMention()}
|
||||
@@ -292,12 +291,11 @@ export function LineCommentEditor(props: LineCommentEditorProps) {
|
||||
</Show>
|
||||
</div>
|
||||
<div data-slot="line-comment-v2-footer">
|
||||
<div data-slot="line-comment-v2-footer-meta">{local.selection}</div>
|
||||
<div data-slot="line-comment-v2-footer-actions">
|
||||
<Button type="button" size="normal" variant="ghost" onClick={() => local.onCancel()}>
|
||||
<Button type="button" size="small" variant="ghost-muted" onClick={() => local.onCancel()}>
|
||||
{local.cancelLabel ?? i18n.t("ui.lineComment.cancel")}
|
||||
</Button>
|
||||
<Button type="button" size="normal" variant="contrast" disabled={!canSubmit()} onClick={submit}>
|
||||
<Button type="button" size="small" variant="submit" disabled={!canSubmit()} onClick={submit}>
|
||||
{local.submitLabel ?? i18n.t("ui.lineComment.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -51,6 +51,7 @@ const source = {
|
||||
"ui.lineComment.editorLabel.prefix": "Commenting on ",
|
||||
"ui.lineComment.editorLabel.suffix": "",
|
||||
"ui.lineComment.placeholder": "Add comment",
|
||||
"ui.lineComment.add": "Add comment",
|
||||
"ui.lineComment.contextPlaceholder": "Add context for this change",
|
||||
"ui.lineComment.submit": "Comment",
|
||||
"ui.lineComment.cancel": "Cancel",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user