feat(desktop): comment on page elements from the in-app browser (#52217)

This commit is contained in:
Luke Parker
2026-09-30 16:52:42 +10:00
committed by GitHub
parent dd68d6eb79
commit 01efd22b2a
47 changed files with 1487 additions and 155 deletions
@@ -4,9 +4,9 @@ import { For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal, render } from "solid-js/web"
import { LanguageProvider, UiI18nBridge } from "../src/runtime/i18n/language"
import type { BrowserPaneLayout, BrowserPaneRegistration } from "../src/runtime/platform/browser-pane"
import type { BrowserPaneEvent, BrowserPaneLayout, BrowserPaneRegistration } from "../src/runtime/platform/browser-pane"
import type { createSessionBrowser } from "../src/session/browser/model"
import { SessionBrowserPane } from "../src/session/browser/pane"
import { SessionBrowserPane, type SessionBrowserComment } from "../src/session/browser/pane"
export function mountBrowserPane() {
const host = document.createElement("main")
@@ -30,9 +30,15 @@ export function mountBrowserPane() {
covered: false,
captures: 0,
holdCapture: false,
picker: {} as Record<string, boolean | undefined>,
highlights: [] as string[],
comments: [] as SessionBrowserComment[],
})
// Each capture waits until the fixture releases it, so a spec can observe the pending state.
const held: (() => void)[] = []
const inspectors = new Set<(event: Extract<BrowserPaneEvent, { type: "inspect" }>) => void>()
const emitInspect = (event: Extract<BrowserPaneEvent, { type: "inspect" }>) =>
inspectors.forEach((listener) => listener(event))
const tabs = ["Alpha", "Beta"].map((name) => ({
id: Browser.TabID.make(`tab_${name === "Alpha" ? "11111111" : "22222222"}-1111-1111-1111-111111111111`),
title: name,
@@ -60,6 +66,12 @@ export function mountBrowserPane() {
}
return canvas.convertToBlob()
},
// The desktop confirms each picker change, as the page does once inspect mode is armed.
inspect: (tabID, enabled) => {
setStore("picker", tab.title, enabled)
emitInspect({ type: "inspect", tabID, active: enabled })
},
highlight: (_tabID, ref) => setStore("highlights", (items) => [...items, ref ?? "clear"]),
close: () => undefined,
},
]),
@@ -97,7 +109,26 @@ export function mountBrowserPane() {
}
if (command.type === "stop") setStore("loading", false)
},
onInspect: (listener) => {
inspectors.add(listener)
return () => inspectors.delete(listener)
},
reveal: () => undefined,
}
const pick = () =>
emitInspect({
type: "inspect",
tabID: browser.active().id,
active: false,
element: {
ref: Browser.Ref.make("e7"),
selector: "main > button.primary",
label: "button.primary",
role: "button",
name: "Save changes",
rect: { x: 48, y: 40, width: 160, height: 36 },
},
})
return (
<>
<h1 style={{ "font-size": "24px", "margin-bottom": "16px" }}>Browser pane lifecycle</h1>
@@ -137,13 +168,30 @@ export function mountBrowserPane() {
<button onClick={() => setStore("holdCapture", true)}>Hold capture</button>
<button onClick={() => held.splice(0).forEach((resolve) => resolve())}>Release capture</button>
<button onClick={() => setStore("covered", (covered) => !covered)}>Toggle popover</button>
<button onClick={pick}>Pick element</button>
</nav>
<p>Captures: {store.captures}</p>
<p>Picker: {store.picker[store.session] ? "on" : "off"}</p>
<p>Highlights: {store.highlights.join(",")}</p>
<div style={{ position: "relative", width: "640px", height: "360px", border: "1px solid #555" }}>
<Show when={store.mounted}>
<SessionBrowserPane browser={browser} visible={store.visible} />
<SessionBrowserPane
browser={browser}
visible={store.visible}
onComment={(comment) => setStore("comments", (items) => [...items, comment])}
/>
</Show>
</div>
<ul data-testid="fixture-comments">
<For each={store.comments}>
{(comment) => (
<li>
{comment.element.label} {comment.element.ref ? `@${comment.element.ref}` : "(no ref)"}:{" "}
{comment.comment}
</li>
)}
</For>
</ul>
<Show when={store.covered}>
{/* Floating content portals into <body> like a menu or hover card over the page. */}
<Portal mount={document.body}>
@@ -79,6 +79,81 @@ story("keeps a still of the page under floating content that covers it", async (
await expect(root.getByText("Captures: 1", { exact: true })).toBeVisible()
})
story("comments on a picked element over a still of the page", async ({ page }, testInfo) => {
const root = page.getByTestId("browser-pane-fixture")
const picker = root.getByRole("button", { name: "Select an element to comment on", exact: true })
await picker.click()
await expect(picker).toHaveAttribute("aria-pressed", "true")
await expect(root.getByText("Picker: on", { exact: true })).toBeVisible()
await root.getByRole("button", { name: "Pick element", exact: true }).click()
await expect(picker).toHaveAttribute("aria-pressed", "false")
const editor = root.locator('[data-slot="browser-comment-editor"] textarea')
await expect(editor).toBeFocused()
await expect(root.locator('[data-slot="browser-comment-editor"]')).toContainText("button.primary")
// The native page hides behind its still so the editor can float over it.
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
await expect(root.locator("#browser-panel img")).toBeVisible()
await page.screenshot({ path: testInfo.outputPath("comment.png") })
await editor.fill("Make this the primary colour")
await editor.press("Enter")
await expect(root.getByTestId("fixture-comments")).toHaveText("button.primary @e7: Make this the primary colour")
await expect(root.locator('[data-component="browser-comment"]')).toHaveCount(0)
await expect(root.getByText("Highlights: clear", { exact: true })).toBeVisible()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
story("cancels the picker and a comment with Escape", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
const picker = root.getByRole("button", { name: "Select an element to comment on", exact: true })
await picker.click()
await expect(picker).toHaveAttribute("aria-pressed", "true")
await page.keyboard.press("Escape")
await expect(picker).toHaveAttribute("aria-pressed", "false")
await expect(root.getByText("Picker: off", { exact: true })).toBeVisible()
await picker.click()
await root.getByRole("button", { name: "Pick element", exact: true }).click()
const editor = root.locator('[data-slot="browser-comment-editor"] textarea')
await expect(editor).toBeFocused()
await editor.press("Escape")
await expect(root.locator('[data-component="browser-comment"]')).toHaveCount(0)
await expect(root.getByTestId("fixture-comments")).toHaveText("")
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
story("keeps a comment draft but drops its ref when the page navigates", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Select an element to comment on", exact: true }).click()
await root.getByRole("button", { name: "Pick element", exact: true }).click()
const editor = root.locator('[data-slot="browser-comment-editor"] textarea')
await editor.fill("Still misaligned after the reload")
await root.getByRole("button", { name: "Load current page", exact: true }).click()
await expect(editor).toHaveValue("Still misaligned after the reload")
await editor.press("Enter")
await expect(root.getByTestId("fixture-comments")).toHaveText(
"button.primary (no ref): Still misaligned after the reload",
)
})
story("keeps the comment editor and its actions inside the page", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Select an element to comment on", exact: true }).click()
await root.getByRole("button", { name: "Pick element", exact: true }).click()
const editor = root.locator('[data-slot="browser-comment-editor"]')
// As if the user dragged the textarea's resize handle far past the page.
await editor.locator("textarea").evaluate((element) => {
element.style.height = "900px"
})
const surface = await root.locator('[data-component="browser-comment"]').boundingBox()
const box = await editor.boundingBox()
expect(surface && box && box.y + box.height <= surface.y + surface.height).toBe(true)
const submit = editor.getByRole("button", { name: "Comment", exact: true })
await submit.scrollIntoViewIfNeeded()
await expect(submit).toBeInViewport()
})
story("shows the empty state over a blank native page and restores navigation", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Blank page", exact: true }).click()
+4
View File
@@ -30,6 +30,10 @@ export type ComposerControls = {
opened: () => boolean
open: () => void
}
/** Present in sessions with a desktop browser pane. */
browser?: {
reveal: (tabID: string, ref?: string) => void
}
}
}
@@ -0,0 +1,41 @@
import { describe, expect, test } from "bun:test"
import { commentContextItem, formatBrowserCommentNote, readPromptPresentation } from "./comment-note"
const browser = {
type: "browser" as const,
tabID: "tab_00000000-0000-4000-8000-000000000000",
url: "http://localhost:5173/",
element: { ref: "e42", selector: "#save", label: "button#save" },
comment: "Rename this",
}
describe("browser element comments", () => {
test("read from message metadata beside file comments and skip malformed entries", () => {
const value = readPromptPresentation({
displayText: "hi",
comments: [browser, { ...browser, element: { label: "button" } }, { path: "src/app.ts", comment: "Keep" }],
})
expect(value?.comments).toEqual([browser, { path: "src/app.ts", comment: "Keep" }])
})
test("explain a selector that crosses into a shadow root", () => {
expect(
formatBrowserCommentNote({ ...browser, element: { ...browser.element, selector: "#card >>> div > button" } }),
).toContain('selector "#card >>> div > button" (">>>" enters a shadow root)')
expect(formatBrowserCommentNote(browser)).not.toContain("shadow root")
})
test("leave out a selector that was too long to keep", () => {
expect(formatBrowserCommentNote({ ...browser, element: { ...browser.element, selector: "" } })).toBe(
'The user made the following comment regarding the "button#save" element in browser tab tab_00000000-0000-4000-8000-000000000000 at http://localhost:5173/ (browser ref @e42, usable as ref in any browser tool including browser.evaluate until the page navigates): Rename this',
)
})
test("return to the composer without their element ref", () => {
expect(commentContextItem(browser)).toEqual({
...browser,
element: { selector: "#save", label: "button#save" },
commentID: expect.any(String),
})
})
})
+42 -2
View File
@@ -1,12 +1,18 @@
import { Option, Schema } from "effect"
import type { FileSelection } from "@/workspaces/files/model"
import { BrowserComment, durableBrowserElement, type ContextItem } from "./schema"
export type PromptComment = {
export type PromptFileComment = {
type?: "file"
path: string
selection?: FileSelection
comment: string
preview?: string
origin?: "review" | "file"
}
export type PromptComment = PromptFileComment | BrowserComment
const decodeBrowserComment = Schema.decodeUnknownOption(BrowserComment)
/** An attachment the model receives as a path on the server rather than inline bytes. */
export type PromptAttachmentReference = {
@@ -30,7 +36,7 @@ function selection(selection: unknown) {
} satisfies FileSelection
}
export function createCommentMetadata(input: PromptComment) {
export function createCommentMetadata(input: PromptFileComment) {
return {
opencodeComment: {
path: input.path,
@@ -78,6 +84,7 @@ export function readPromptPresentation(value: unknown) {
}),
comments: comments.flatMap((item): PromptComment[] => {
if (!item || typeof item !== "object") return []
if ((item as { type?: unknown }).type === "browser") return Option.toArray(decodeBrowserComment(item))
const path = (item as { path?: unknown }).path
const comment = (item as { comment?: unknown }).comment
if (typeof path !== "string" || typeof comment !== "string") return []
@@ -100,6 +107,39 @@ export function formatAttachmentReference(input: PromptAttachmentReference) {
return `Attached file: \`${input.path}\``
}
export function formatBrowserCommentNote(input: BrowserComment) {
const element = input.element
// Page-provided strings are quoted so they read as data, not as part of the user's request.
const details = [
element.role ? `role ${element.role}` : undefined,
element.name ? `accessible name ${JSON.stringify(element.name)}` : undefined,
element.text && element.text !== element.name ? `text ${JSON.stringify(element.text.slice(0, 80))}` : undefined,
// A selector too long to keep is empty rather than cut into invalid syntax.
element.selector
? `selector ${JSON.stringify(element.selector)}${element.selector.includes(" >>> ") ? ' (">>>" enters a shadow root)' : ""}`
: undefined,
element.ref
? `browser ref @${element.ref}, usable as ref in any browser tool including browser.evaluate until the page navigates`
: undefined,
].filter((detail) => detail !== undefined)
return `The user made the following comment regarding the ${JSON.stringify(element.label)} element in browser tab ${input.tabID} at ${input.url}${details.length ? ` (${details.join("; ")})` : ""}: ${input.comment}`
}
/** Restores a sent comment to the composer, for example after a revert or fork. */
export function commentContextItem(comment: PromptComment): ContextItem {
// The message may predate the desktop process, so its element ref can no longer be trusted.
if (comment.type === "browser")
return { ...comment, element: durableBrowserElement(comment.element), commentID: crypto.randomUUID() }
return {
type: "file",
path: comment.path,
selection: comment.selection,
comment: comment.comment,
preview: comment.preview,
commentOrigin: comment.origin,
}
}
export function formatCommentNote(input: { path: string; selection?: FileSelection; comment: string }) {
const start = input.selection ? Math.min(input.selection.startLine, input.selection.endLine) : undefined
const end = input.selection ? Math.max(input.selection.startLine, input.selection.endLine) : undefined
@@ -279,6 +279,39 @@ export const MixedAttachments = {
),
}
export const BrowserElementComment = {
render: () => (
<ComposerStory
prompt={text("Tidy up the settings page")}
comments={[
{
type: "browser",
key: "browser:tab_story:c=save",
tabID: "tab_00000000-0000-4000-8000-000000000000",
url: "http://localhost:5173/settings",
title: "Settings",
element: {
ref: "e42",
selector: "#settings > form > button.btn.primary",
label: "button.btn.primary",
role: "button",
name: "Save changes",
},
comment: "Match the secondary button height",
commentID: "save",
},
{
type: "file",
key: "comment:src/app.tsx",
path: "src/app.tsx",
selection: { startLine: 12, startChar: 0, endLine: 14, endChar: 0 },
comment: "Keep the normal flow flat",
},
]}
/>
),
}
export const ModelAndVariant = { render: () => <ComposerStory prompt={text("Compare both variants")} /> }
export const SlashSuggestions = { render: () => <ComposerStory suggestions="command" /> }
+5 -2
View File
@@ -533,8 +533,11 @@ export function ComposerAttachments(props: {
>
<CommentCard
comment={comment.comment ?? ""}
path={comment.path}
selection={comment.selection}
target={
comment.type === "browser"
? { type: "browser", element: comment.element.label }
: { type: "file", path: comment.path, selection: comment.selection }
}
active={comment.key === props.activeCommentID}
onClick={() => props.onCommentClick?.(comment)}
/>
+12 -6
View File
@@ -98,7 +98,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
return prompt.context.items().flatMap((item) => {
const comment = item.comment?.trim()
if (!comment) return []
if (!comment || item.type !== "file") return []
const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined
const nextSelection =
selection ??
@@ -129,9 +129,11 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
time: item.time,
})),
)
prompt.context.replaceComments(
items.map((item) => ({
type: "file",
// History records file comments only; browser comments stay with the draft while it is browsed.
prompt.context.replaceComments([
...prompt.context.items().filter((item) => item.type === "browser"),
...items.map((item) => ({
type: "file" as const,
path: item.path,
selection: selectionFromLines(item.selection),
comment: item.comment,
@@ -139,7 +141,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
commentOrigin: item.origin,
preview: item.preview,
})),
)
])
}
const referenceDescription = (reference: ReferenceInfo) =>
@@ -298,7 +300,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
mention: { type: "file", path, content: `@${path}`, start: 0, end: 0 },
})),
onContextRemove(item) {
if (item?.commentID) comments.remove(item.path, item.commentID)
if (item.type === "file" && item.commentID) comments.remove(item.path, item.commentID)
},
openAttachment: (attachment) => {
if (attachment.type !== "image") return
@@ -308,6 +310,10 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
},
openContext(key) {
const item = controller.contextItem(key)
if (item?.type === "browser") {
adapter.controls().session.browser?.reveal(item.tabID, item.element.ref)
return
}
if (item) openComment(item, adapter.controls(), layout, files, comments)
},
onEditor(element) {
+2 -1
View File
@@ -28,6 +28,7 @@ export {
} from "./state"
export type {
AgentPart,
BrowserContextItem,
ContentPart,
ContextItem,
FileAttachmentPart,
@@ -160,7 +161,7 @@ export const { use: useComposerState, provider: ComposerPersistenceProvider } =
removeComment: (path: string, commentID: string) => session().context.removeComment(path, commentID),
updateComment: (path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) =>
session().context.updateComment(path, commentID, next),
replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items),
replaceComments: (items: ContextItem[]) => session().context.replaceComments(items),
},
set: (prompt: Prompt, cursorPosition?: number, scope?: PromptScope) => pick(scope).set(prompt, cursorPosition),
reset: (scope?: PromptScope) => pick(scope).reset(),
+51
View File
@@ -144,6 +144,57 @@ describe("buildPromptRequest", () => {
expect(result.files.some((file) => file.uri === "file:///repo/src/shared.ts")).toBe(true)
})
test("sends a browser element comment as a note the browser tools can act on", () => {
const result = buildPromptRequest({
prompt: [{ type: "text", content: "tidy up", start: 0, end: 7 }],
context: [
{
key: "browser:tab:c=1",
type: "browser",
tabID: "tab_00000000-0000-4000-8000-000000000000",
url: "http://localhost:5173/settings",
title: "Settings",
element: {
ref: "e42",
selector: "#settings > button.primary",
label: "button.primary",
role: "button",
name: "Save",
text: "Save",
},
comment: "Match @src/button.css",
commentID: "1",
},
],
images: [],
text: "tidy up",
sessionDirectory: "/repo",
})
expect(result.text).toBe(
'tidy up\nThe user made the following comment regarding the "button.primary" element in browser tab tab_00000000-0000-4000-8000-000000000000 at http://localhost:5173/settings (role button; accessible name "Save"; selector "#settings > button.primary"; browser ref @e42, usable as ref in any browser tool including browser.evaluate until the page navigates): Match @src/button.css',
)
expect(result.comments).toEqual([
{
type: "browser",
tabID: "tab_00000000-0000-4000-8000-000000000000",
url: "http://localhost:5173/settings",
title: "Settings",
element: {
ref: "e42",
selector: "#settings > button.primary",
label: "button.primary",
role: "button",
name: "Save",
text: "Save",
},
comment: "Match @src/button.css",
},
])
// A browser comment has no file of its own; only files it mentions are attached.
expect(result.files).toEqual([{ uri: "file:///repo/src/button.css", mime: "text/plain", name: "button.css" }])
})
test("handles Windows paths correctly (simulated on macOS)", () => {
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
+35 -21
View File
@@ -1,9 +1,18 @@
import { getFilename } from "@opencode/util/path"
import type { FileSelection } from "@/workspaces/files/model"
import { encodeFilePath } from "@/workspaces/files/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, PathAttachmentPart, Prompt, SkillPart } from "@/composer/state"
import type {
AgentPart,
ContextItem,
FileAttachmentPart,
ImageAttachmentPart,
PathAttachmentPart,
Prompt,
SkillPart,
} from "@/composer/state"
import {
formatAttachmentReference,
formatBrowserCommentNote,
formatCommentNote,
type PromptAttachmentReference,
type PromptComment,
@@ -20,20 +29,9 @@ type PromptRequest = {
attachments: PromptAttachmentReference[]
}
type ContextFile = {
key: string
type: "file"
path: string
selection?: FileSelection
comment?: string
commentID?: string
commentOrigin?: "review" | "file"
preview?: string
}
type BuildPromptRequestInput = {
prompt: Prompt
context: ContextFile[]
context: (ContextItem & { key: string })[]
images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[]
text: string
sessionDirectory: string
@@ -87,7 +85,27 @@ export function buildPromptRequest(input: BuildPromptRequestInput): PromptReques
const used = new Set(files.map((file) => file.uri))
const comments: PromptComment[] = []
const mentioned = (comment: string) =>
parseCommentMentions(comment).flatMap((path) => {
const uri = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
if (used.has(uri)) return []
used.add(uri)
return [{ uri, mime: "text/plain", name: getFilename(path) }]
})
const context = input.context.flatMap((item) => {
if (item.type === "browser") {
const comment = item.comment.trim()
if (!comment) return []
comments.push({
type: "browser",
tabID: item.tabID,
url: item.url,
...(item.title ? { title: item.title } : {}),
element: { ...item.element },
comment,
})
return mentioned(comment)
}
const path = absolute(input.sessionDirectory, item.path)
const uri = `file://${encodeFilePath(path)}${fileQuery(item.selection)}`
const comment = item.comment?.trim()
@@ -104,13 +122,7 @@ export function buildPromptRequest(input: BuildPromptRequestInput): PromptReques
preview: item.preview,
origin: item.commentOrigin,
})
const mentions = parseCommentMentions(comment).flatMap((path) => {
const uri = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
if (used.has(uri)) return []
used.add(uri)
return [{ uri, mime: "text/plain", name: getFilename(path) }]
})
return [file, ...mentions]
return [file, ...mentioned(comment)]
})
const inline = input.images.map((attachment) => ({
@@ -127,7 +139,9 @@ export function buildPromptRequest(input: BuildPromptRequestInput): PromptReques
text: [
...(input.text.trim() ? [input.text] : []),
...attachments.map(formatAttachmentReference),
...comments.map(formatCommentNote),
...comments.map((comment) =>
comment.type === "browser" ? formatBrowserCommentNote(comment) : formatCommentNote(comment),
),
].join("\n"),
displayText: input.text,
files: [...files, ...context, ...inline],
+22
View File
@@ -53,6 +53,28 @@ describe("composer persistence schemas", () => {
expect(decode({}).prompt).toEqual(DEFAULT_PROMPT)
})
test("restores browser element comments without the process-bound element ref", () => {
const decode = Schema.decodeUnknownSync(
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
)
const browser = {
type: "browser" as const,
tabID: "tab_00000000-0000-4000-8000-000000000000",
url: "http://localhost:5173/",
element: { ref: "e42", selector: "#save", label: "button#save", role: 42 },
comment: "Rename this",
commentID: "note",
}
const value = decode({ context: { items: [browser, { ...browser, url: null }] } })
expect(value.context.items).toEqual([
{
...browser,
element: { selector: "#save", label: "button#save" },
key: "browser:tab_00000000-0000-4000-8000-000000000000:c=note",
},
])
})
test("drops invalid parts without losing valid mentions or optional field recovery", () => {
const value = Schema.decodeUnknownSync(
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
+57 -2
View File
@@ -134,9 +134,49 @@ export const FileContextItem = Persistence.struct({
preview: Persistence.optional(Schema.String),
})
export type FileContextItem = typeof FileContextItem.Type
export type ContextItem = FileContextItem
/** An element the user picked in a desktop browser tab. The ref is valid until the page navigates. */
export const BrowserElement = Persistence.struct({
ref: Persistence.optional(Schema.String),
selector: Schema.String,
label: Schema.String,
role: Persistence.optional(Schema.String),
name: Persistence.optional(Schema.String),
text: Persistence.optional(Schema.String),
})
export type BrowserElement = typeof BrowserElement.Type
/**
* A ref names an element only inside the desktop process that picked it; a later process can hand
* the same ref to another element. Anything that may outlive the pick keeps the description only.
*/
export function durableBrowserElement(element: BrowserElement): BrowserElement {
return {
selector: element.selector,
label: element.label,
...(element.role ? { role: element.role } : {}),
...(element.name ? { name: element.name } : {}),
...(element.text ? { text: element.text } : {}),
}
}
const BrowserCommentFields = {
type: Schema.Literal("browser"),
tabID: Schema.String,
url: Schema.String,
title: Persistence.optional(Schema.String),
element: BrowserElement,
comment: Schema.String,
}
/** A comment on a browser element as sent in message metadata. */
export const BrowserComment = Persistence.struct(BrowserCommentFields)
export type BrowserComment = typeof BrowserComment.Type
export const BrowserContextItem = Persistence.struct({ ...BrowserCommentFields, commentID: Schema.String })
export type BrowserContextItem = typeof BrowserContextItem.Type
export type ContextItem = FileContextItem | BrowserContextItem
export function contextItemKey(item: ContextItem) {
if (item.type === "browser") return `browser:${item.tabID}:c=${item.commentID}`
const key = `${item.type}:${item.path}:${item.selection?.startLine}:${item.selection?.endLine}`
if (item.commentID) return `${key}:c=${item.commentID}`
const comment = item.comment?.trim()
@@ -145,12 +185,27 @@ export function contextItemKey(item: ContextItem) {
return `${key}:c=${digest.slice(0, 8)}`
}
const ContextEntry = Schema.Struct({ ...FileContextItem.fields, key: Persistence.optional(Schema.String) }).pipe(
const FileContextEntry = Schema.Struct({ ...FileContextItem.fields, key: Persistence.optional(Schema.String) }).pipe(
Schema.decodeTo(Persistence.struct({ ...FileContextItem.fields, key: Schema.String }).pipe(Schema.toType), {
decode: SchemaGetter.transform((item) => ({ ...item, key: contextItemKey(item) })),
encode: SchemaGetter.transform((item) => item),
}),
)
const BrowserContextEntry = Schema.Struct({
...BrowserContextItem.fields,
key: Persistence.optional(Schema.String),
}).pipe(
Schema.decodeTo(Persistence.struct({ ...BrowserContextItem.fields, key: Schema.String }).pipe(Schema.toType), {
// A stored draft can outlive the desktop process that picked the element.
decode: SchemaGetter.transform((item) => ({
...item,
element: durableBrowserElement(item.element),
key: contextItemKey(item),
})),
encode: SchemaGetter.transform((item) => item),
}),
)
const ContextEntry = Schema.Union([FileContextEntry, BrowserContextEntry])
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
+6 -1
View File
@@ -12,7 +12,11 @@ import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } fro
import { useComposerState } from "./persistence"
import { useConfiguredModel } from "@/providers/models/configured"
export function createComposerControls(input: { sessionKey: Accessor<string>; model?: ModelSelection }) {
export function createComposerControls(input: {
sessionKey: Accessor<string>
model?: ModelSelection
browser?: ComposerControls["session"]["browser"]
}) {
const layout = useLayout()
const local = useLocal()
const sdk = useWorkspaceLocation()
@@ -40,6 +44,7 @@ export function createComposerControls(input: { sessionKey: Accessor<string>; mo
session: {
tabs: layout.tabs(input.sessionKey),
reviewPanel: view.reviewPanel,
browser: input.browser,
},
}
})
+5 -2
View File
@@ -17,6 +17,9 @@ import {
export { DEFAULT_PROMPT } from "./schema"
export type {
AgentPart,
BrowserComment,
BrowserContextItem,
BrowserElement,
ComposerStore,
ContentPart,
ContextItem,
@@ -38,7 +41,7 @@ type InitialPrompt = {
}
export function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
return item.type === "file" && !!item.comment?.trim()
return !!item.comment?.trim()
}
function createComposerActions(setStore: SetStoreFunction<ComposerStore>) {
@@ -137,7 +140,7 @@ function createComposerStateValue(store: ComposerStore, setStore: SetStoreFuncti
)
clearRetry()
},
replaceComments(items: FileContextItem[]) {
replaceComments(items: ContextItem[]) {
setStore("context", "items", (current) => [
...current.filter((item) => !isCommentItem(item)),
...items.map((item) => ({ ...item, key: contextItemKey(item) })),
+53 -26
View File
@@ -1,6 +1,7 @@
import { SessionMessage } from "@opencode/schema/session-message"
import type { SessionMessageUser } from "@opencode/client/promise"
import type { Accessor } from "solid-js"
import type { PromptComment } from "./comment-note"
import type { PromptHistoryComment } from "./history/entry"
import type { ImageAttachmentPart, Prompt } from "./state"
import { clonePrompt, promptLength } from "./prompt-parts"
@@ -73,10 +74,13 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
const submission = createComposerSubmission({
target: input.adapter.state,
prompt,
context: input.adapter.state.context.items().map((item) => ({
...item,
selection: item.selection ? { ...item.selection } : undefined,
})),
context: input.adapter.state.context
.items()
.map((item) =>
item.type === "browser"
? { ...item, element: { ...item.element } }
: { ...item, selection: item.selection ? { ...item.selection } : undefined },
),
})
const read = readSubmission(input, submission.prompt, submission.context, text, options?.alternate ?? false)
if (!read) {
@@ -195,19 +199,30 @@ function handoffMessage(value: ComposerSubmission): SessionMessageUser {
attachments: value.prompt.flatMap((part) =>
part.type === "path" ? [{ name: part.filename, mime: part.mime, path: part.path }] : [],
),
comments: value.context.flatMap((item) =>
item.comment?.trim()
? [
{
path: item.path,
comment: item.comment.trim(),
...(item.selection ? { selection: { ...item.selection } } : {}),
...(item.preview !== undefined ? { preview: item.preview } : {}),
...(item.commentOrigin ? { origin: item.commentOrigin } : {}),
},
]
: [],
),
comments: value.context.flatMap((item): PromptComment[] => {
const comment = item.comment?.trim()
if (!comment) return []
if (item.type === "browser")
return [
{
type: "browser",
tabID: item.tabID,
url: item.url,
...(item.title ? { title: item.title } : {}),
element: { ...item.element },
comment,
},
]
return [
{
path: item.path,
comment,
...(item.selection ? { selection: { ...item.selection } } : {}),
...(item.preview !== undefined ? { preview: item.preview } : {}),
...(item.commentOrigin ? { origin: item.commentOrigin } : {}),
},
]
}),
agent: value.selection.agent,
model: {
...value.selection.model,
@@ -287,15 +302,27 @@ function restoreSubmission(
restored.target.context.replaceComments(
restored.context
.filter((item) => !!item.comment?.trim())
.map((item) => ({
type: "file",
path: item.path,
selection: item.selection,
comment: item.comment,
commentID: item.commentID,
commentOrigin: item.commentOrigin,
preview: item.preview,
})),
.map((item) =>
item.type === "browser"
? {
type: "browser",
tabID: item.tabID,
url: item.url,
title: item.title,
element: item.element,
comment: item.comment,
commentID: item.commentID,
}
: {
type: "file",
path: item.path,
selection: item.selection,
comment: item.comment,
commentID: item.commentID,
commentOrigin: item.commentOrigin,
preview: item.preview,
},
),
)
// A recovered follow-up changes the payload, so it must use a new admission ID.
if (value.mode === "normal" && restored.prompt === submission.prompt) {
+1
View File
@@ -5,6 +5,7 @@ export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./run
export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./runtime/platform/platform"
export type {
BrowserPaneCommand,
BrowserPaneElement,
BrowserPaneEndpoint,
BrowserPaneEvent,
BrowserPaneLayout,
+36
View File
@@ -445,6 +445,42 @@
}
}
@keyframes browser-comment-enter {
from {
opacity: 0;
}
}
@keyframes browser-comment-editor-enter {
from {
opacity: 0;
transform: translateY(4px) scale(0.98);
}
}
[data-component="browser-comment"] {
animation: browser-comment-enter 160ms cubic-bezier(0.215, 0.61, 0.355, 1);
}
/* Dim the frozen page around the picked element, whose DevTools highlight stays in the still. */
[data-component="browser-comment"] [data-slot="browser-comment-spotlight"] {
box-shadow:
0 0 0 1px var(--v2-border-border-focus),
0 0 0 100vmax color-mix(in srgb, var(--v2-overlay-simple-overlay-scrim) 50%, transparent);
}
[data-component="browser-comment"] [data-slot="browser-comment-editor"] {
animation: browser-comment-editor-enter 200ms cubic-bezier(0.215, 0.61, 0.355, 1);
transform-origin: top left;
}
@media (prefers-reduced-motion: reduce) {
[data-component="browser-comment"],
[data-component="browser-comment"] [data-slot="browser-comment-editor"] {
animation: none;
}
}
[data-slot="home-projects-scroll"] {
timeline-scope: --home-projects-scroll;
}
+4
View File
@@ -111,6 +111,7 @@ export const dict = {
"command.file.open": "Open file",
"command.browser.open": "Open browser",
"command.browser.reload": "Reload browser page",
"command.browser.inspect": "Select an element in the browser page",
"command.tab.close": "Close tab",
"command.tab.reopenClosed": "Reopen closed tab",
"command.context.addSelection": "Add selection to context",
@@ -1022,6 +1023,9 @@ export const dict = {
"session.browser.unsupported": "This desktop app does not support the browser pane.",
"session.browser.suspended": "Browser suspended. Interact with this session to reconnect.",
"session.browser.address.placeholder": "Enter URL",
"session.browser.inspect": "Select an element to comment on",
"session.browser.inspect.active": "Click an element in the page to comment on it. Press Escape to cancel.",
"session.browser.inspect.pageShortcut": "While the page has focus",
"session.browser.empty.title": "Enter URL",
"session.browser.empty.description": 'Or prompt "Open in the app browser"',
"session.browser.failed.title": "URL can't be reached",
@@ -17,16 +17,32 @@ export type BrowserPaneLayout = {
export type BrowserPaneCommand = Browser.Action
export type BrowserPaneState = Browser.State | null
/** An element the user picked in the page. The ref stays valid for browser tools until the page navigates. */
export type BrowserPaneElement = {
ref: Browser.Ref
selector: string
label: string
role?: string
name?: string
text?: string
/** Border box in the native view's DIPs. */
rect: { x: number; y: number; width: number; height: number }
}
export type BrowserPaneEvent =
| { type: "focus"; tabID: Browser.TabID }
| { type: "preview"; path: string }
| { type: "state"; state: BrowserPaneState; error?: string }
| { type: "inspect"; tabID: Browser.TabID; active: boolean; element?: BrowserPaneElement }
export type BrowserPaneRegistration = {
setLayout(layout?: BrowserPaneLayout): void
command(command: BrowserPaneCommand): Promise<void>
/** Captures the shown page, or resolves null when nothing is on screen. */
capture(tabID: Browser.TabID): Promise<Blob | null>
/** Starts or stops the page's element picker; the page reports picks and exits as inspect events. */
inspect(tabID: Browser.TabID, enabled: boolean): void
/** Highlights a picked element briefly, or clears any highlight when ref is omitted. */
highlight(tabID: Browser.TabID, ref?: Browser.Ref): void
close(): void
}
@@ -2,7 +2,8 @@ import { batch, createEffect, createMemo, createRoot, getOwner, on, onCleanup, r
import { createStore, reconcile } from "solid-js/store"
import { createSimpleContext } from "@opencode/ui/context"
import { useLanguage } from "@/runtime/i18n/language"
import type { BrowserPaneCommand } from "@/runtime/platform/browser-pane"
import type { Browser } from "@opencode/plugin-browser/rpc"
import type { BrowserPaneCommand, BrowserPaneEvent } from "@/runtime/platform/browser-pane"
import { usePlatform } from "@/runtime/platform/platform"
import type { useServer } from "@/runtime/server/current"
import type { SessionStateKey } from "@/runtime/server/scope"
@@ -13,6 +14,7 @@ import { createEventListener } from "@solid-primitives/event-listener"
import { createBrowserConnection, type BrowserConnectionState } from "./connection"
type Server = ReturnType<typeof useServer>
type InspectEvent = Extract<BrowserPaneEvent, { type: "inspect" }>
export type BrowserAttachment = BrowserConnectionState
@@ -42,6 +44,7 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
const [unsupported, setUnsupported] = createStore<Record<string, true | undefined>>({})
const live = new Map<string, Live>()
const preview = new Map<string, Set<(path: string) => void>>()
const inspect = new Map<string, Set<(event: InspectEvent) => void>>()
const key = (server: Server, sessionID: string) => `${server.key}\n${sessionID}`
const enabled = createMemo(() => !!platform.browserPane)
const close = (id: string) => {
@@ -110,6 +113,7 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
})
},
preview: (path) => preview.get(id)?.forEach((listener) => listener(path)),
inspect: (event) => inspect.get(id)?.forEach((listener) => listener(event)),
change: (state) => {
if (state.error === "browser.pane.unsupported") {
setUnsupported(server.key, true)
@@ -152,20 +156,34 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
},
/** Agent requests to show a file in this session's Review pane. */
onPreview(server: Server, sessionID: string, listener: (path: string) => void) {
const id = key(server, sessionID)
const listeners = preview.get(id) ?? new Set()
listeners.add(listener)
preview.set(id, listeners)
return () => {
listeners.delete(listener)
if (!listeners.size) preview.delete(id)
}
return subscribe(preview, key(server, sessionID), listener)
},
/** The page's element picker starting, stopping, or picking an element. */
onInspect(server: Server, sessionID: string, listener: (event: InspectEvent) => void) {
return subscribe(inspect, key(server, sessionID), listener)
},
command(server: Server, sessionID: string, command: BrowserPaneCommand) {
const connection = live.get(key(server, sessionID))?.connection
if (!connection) return Promise.reject(new Error("browser.pane.unavailable"))
return connection.command(command)
},
highlight(server: Server, sessionID: string, tabID: Browser.TabID, ref?: Browser.Ref) {
live.get(key(server, sessionID))?.connection.highlight(tabID, ref)
},
}
},
})
function subscribe<Value>(
listeners: Map<string, Set<(value: Value) => void>>,
id: string,
listener: (value: Value) => void,
) {
const set = listeners.get(id) ?? new Set()
set.add(listener)
listeners.set(id, set)
return () => {
set.delete(listener)
if (!set.size) listeners.delete(id)
}
}
@@ -29,11 +29,14 @@ function fixture() {
}[] = []
const endpoint = { url: "http://localhost:4096" }
const previews: string[] = []
const inspections: Extract<BrowserPaneEvent, { type: "inspect" }>[] = []
const highlights: (Browser.Ref | undefined)[] = []
const connection = createBrowserConnection({
target: () => ({ serverKey: "browser-test", sessionID: "ses_browser", endpoint: { ...endpoint } }),
change: (state) => states.push(state),
focus: () => {},
preview: (path) => previews.push(path),
inspect: (event) => inspections.push(event),
pane: {
register(target, emit) {
const call = { target, emit, closed: false, commands: [] as Browser.Action[] }
@@ -46,6 +49,10 @@ function fixture() {
async capture() {
return null
},
inspect() {},
highlight(_tabID, ref) {
highlights.push(ref)
},
close() {
call.closed = true
},
@@ -55,9 +62,30 @@ function fixture() {
})
connection.wake()
calls[0].emit({ type: "state", state: browser })
return { connection, calls, states, endpoint, previews }
return { connection, calls, states, endpoint, previews, inspections, highlights }
}
test("picked elements reach the session and highlights reach the page without touching connection state", () => {
const app = fixture()
try {
const before = app.states.length
const element = {
ref: Browser.Ref.make("e4"),
selector: "#save",
label: "button#save",
rect: { x: 1, y: 2, width: 3, height: 4 },
}
app.calls[0].emit({ type: "inspect", tabID, active: false, element })
expect(app.inspections).toEqual([{ type: "inspect", tabID, active: false, element }])
app.connection.highlight(tabID, element.ref)
app.connection.highlight(tabID)
expect(app.highlights).toEqual([element.ref, undefined])
expect(app.states).toHaveLength(before)
} finally {
app.connection.dispose()
}
})
test("preview requests reach the session without touching connection state", () => {
const app = fixture()
try {
@@ -1,4 +1,5 @@
import type {
BrowserPaneEvent,
BrowserPanePlatform,
BrowserPaneRegistration,
BrowserPaneState,
@@ -20,6 +21,7 @@ export function createBrowserConnection(input: {
change: (state: BrowserConnectionState) => void
focus: (tabID: Browser.TabID) => void
preview: (path: string) => void
inspect: (event: Extract<BrowserPaneEvent, { type: "inspect" }>) => void
}) {
const state: BrowserConnectionState = { browser: null, suspended: false }
let disposed = false
@@ -35,6 +37,7 @@ export function createBrowserConnection(input: {
if (disposed || state.registration !== registration) return
if (event.type === "focus") return input.focus(event.tabID)
if (event.type === "preview") return input.preview(event.path)
if (event.type === "inspect") return input.inspect(event)
if (event.error === "browser.pane.unsupported" || event.error === "browser.pane.replaced") {
blocked = true
registration.close()
@@ -72,6 +75,9 @@ export function createBrowserConnection(input: {
register()
return state.registration?.command(command) ?? Promise.reject(new Error("browser.pane.unavailable"))
},
highlight(tabID: Browser.TabID, ref?: Browser.Ref) {
state.registration?.highlight(tabID, ref)
},
dispose() {
disposed = true
clearTimeout(retry)
+20 -1
View File
@@ -2,7 +2,7 @@ import { batch, createEffect, createMemo, on } from "solid-js"
import type { Browser } from "@opencode/plugin-browser/rpc"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import type { BrowserPaneCommand } from "@/runtime/platform/browser-pane"
import type { BrowserPaneCommand, BrowserPaneEvent } from "@/runtime/platform/browser-pane"
import { useServer } from "@/runtime/server/current"
import { useCommand } from "@/shell/commands/command"
import type { SessionModel } from "../model"
@@ -120,5 +120,24 @@ export function createSessionBrowser(session: SessionModel) {
close: (tabID: Browser.TabID) => session.layout.tabs().close(sessionBrowserTab(tabID)),
open,
command,
onInspect(listener: (event: Extract<BrowserPaneEvent, { type: "inspect" }>) => void) {
const sessionID = session.identity.sessionID()
return sessionID ? attachments.onInspect(server, sessionID, listener) : () => undefined
},
/** Shows the browser tab that owns a picked element and flashes the element. */
reveal(tabID: string, ref?: string) {
const sessionID = session.identity.sessionID()
const tab = browserTabs().find((tab) => tab.id === tabID)
if (!sessionID || !tab) return
const view = session.layout.view()
if (!view.reviewPanel.opened()) view.reviewPanel.open()
session.layout.tabs().setActive(sessionBrowserTab(tab.id))
if (ref && isRef(ref)) attachments.highlight(server, sessionID, tab.id, ref)
},
}
}
// Stored comments keep refs as plain strings; this matches the desktop's ref format.
function isRef(value: string): value is Browser.Ref {
return /^e[1-9][0-9]*$/.test(value)
}
+263 -5
View File
@@ -1,20 +1,36 @@
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { LineCommentEditor, type LineCommentEditorMention } from "@opencode/ui/line-comment"
import { Loader } from "@opencode/ui/loader"
import { Keybind } from "@opencode/ui/keybind"
import { Tooltip } from "@opencode/ui/tooltip"
import { useDialog } from "@opencode/ui/context/dialog"
import { createEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createEffect, For, on, onCleanup, Show } from "solid-js"
import { createEffect, For, on, onCleanup, Show, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import type { BrowserPaneElement } from "@/runtime/platform/browser-pane"
import { usePlatform } from "@/runtime/platform/platform"
import { useCommand } from "@/shell/commands/command"
import { formatKeybindParts, useCommand } from "@/shell/commands/command"
import type { Browser } from "@opencode/plugin-browser/rpc"
import type { createSessionBrowser } from "./model"
export function SessionBrowserPane(props: { browser: ReturnType<typeof createSessionBrowser>; visible: boolean }) {
/** A comment on an element the user picked in the page. The ref is absent once the page navigated. */
export type SessionBrowserComment = {
tabID: Browser.TabID
url: string
title: string
element: Omit<BrowserPaneElement, "rect" | "ref"> & { ref?: Browser.Ref }
comment: string
}
export function SessionBrowserPane(props: {
browser: ReturnType<typeof createSessionBrowser>
visible: boolean
onComment?: (comment: SessionBrowserComment) => void
mention?: LineCommentEditorMention
}) {
const platform = usePlatform()
const language = useLanguage()
const dialog = useDialog()
@@ -33,8 +49,91 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
visible: typeof document === "undefined" || document.visibilityState === "visible",
// A still of the page shown in the DOM while floating content covers the hidden native view.
snapshot: undefined as { tabID: Browser.TabID; url: string } | undefined,
// The tab whose element picker is on.
picking: undefined as Browser.TabID | undefined,
// A picked element awaiting its comment. The page stays frozen as a still until it closes.
comment: undefined as
| {
tabID: Browser.TabID
url: string
title: string
// The tab's navigation count at the pick; the element's ref dies when it changes.
generation: number
element: BrowserPaneElement
draft: string
}
| undefined,
size: { width: 0, height: 0 },
editorHeight: 0,
})
const empty = () => !address() && !state()?.loading && !store.navigating
const inspectable = () => !!props.onComment && !!address() && !failed() && !props.browser.suspended()
const picking = () => !!state() && store.picking === state()?.id
const setPicking = (tabID: Browser.TabID, enabled: boolean) => {
registration()?.inspect(tabID, enabled)
setStore("picking", enabled ? tabID : undefined)
}
const toggleInspect = () => {
const tab = state()
if (!tab || !inspectable()) return
if (store.comment) closeComment()
setPicking(tab.id, !picking())
}
const closeComment = () => {
const current = store.comment
if (!current) return
registration()?.highlight(current.tabID)
setStore("comment", undefined)
}
const submitComment = (value: string) => {
const current = store.comment
if (!current) return
const tab = state()
// The draft outlives a reload or agent navigation, but the ref no longer names anything.
const live = tab?.id === current.tabID && tab.generation === current.generation
props.onComment?.({
tabID: current.tabID,
url: current.url,
title: current.title,
element: {
...(live ? { ref: current.element.ref } : {}),
selector: current.element.selector,
label: current.element.label,
...(current.element.role ? { role: current.element.role } : {}),
...(current.element.name ? { name: current.element.name } : {}),
...(current.element.text ? { text: current.element.text } : {}),
},
comment: value,
})
closeComment()
}
// The picked element in surface pixels, and the editor anchored below it, above it, or over it.
const spotlight = () => {
const rect = store.comment?.element.rect
if (!rect) return
const zoom = platform.webviewZoom?.() ?? 1
return { x: rect.x / zoom, y: rect.y / zoom, width: rect.width / zoom, height: rect.height / zoom }
}
const placement = () => {
const rect = spotlight()
if (!rect) return
const gap = 8
const width = Math.max(0, Math.min(400, store.size.width - gap * 2))
// The editor scrolls rather than growing past the surface, so its actions stay reachable.
const maxHeight = Math.max(0, store.size.height - gap * 2)
// Until the editor has been measured once, assume its default three-row height.
const height = Math.min(store.editorHeight || 176, maxHeight)
const left = Math.min(Math.max(gap, rect.x), Math.max(gap, store.size.width - width - gap))
const below = rect.y + rect.height + gap
const above = rect.y - gap - height
const top =
below + height <= store.size.height - gap
? below
: above >= gap
? above
: Math.max(gap, store.size.height - height - gap)
return { left, top, width, maxHeight }
}
let surface: HTMLDivElement | undefined
let addressDisplay: HTMLDivElement | undefined
let frame: number | undefined
@@ -65,8 +164,62 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
if (tab) props.browser.command({ type: "reload", tabID: tab.id })
},
},
// Ctrl+Shift+C copies in the terminal, so only the focused page claims it, as in Chromium.
{
id: "browser.inspect",
title: language.t("command.browser.inspect"),
category: language.t("command.category.view"),
disabled: !props.visible || !inspectable(),
onSelect: toggleInspect,
},
])
createEffect(() => {
onCleanup(
props.browser.onInspect((event) => {
if (event.active) {
setStore("picking", event.tabID)
return
}
if (store.picking === event.tabID) setStore("picking", undefined)
if (!event.element) return
const tab = state()
if (!props.onComment || tab?.id !== event.tabID || !props.visible || !store.visible) {
registration()?.highlight(event.tabID)
return
}
setStore("comment", {
tabID: tab.id,
url: tab.url,
title: tab.title,
generation: tab.generation,
element: event.element,
draft: "",
})
}),
)
})
// A picker or comment belongs to the page on screen; switching tabs or hiding the pane ends it.
createEffect(
on([() => state()?.id, () => props.visible && store.visible], ([id, shown]) => {
const tabID = untrack(() => store.picking)
if (tabID && (tabID !== id || !shown)) setPicking(tabID, false)
if (untrack(() => store.comment)?.tabID !== id) closeComment()
}),
)
// The page does not have focus while the picker waits for a hover, so Escape reaches the app.
createEventListener(
window,
"keydown",
(event) => {
if (event.key !== "Escape" || !store.picking) return
event.preventDefault()
event.stopPropagation()
setPicking(store.picking, false)
},
{ capture: true },
)
// The native page always paints above the DOM, so hide it while a floating
// menu, select, or popover overlaps it. Tooltips are excluded.
const covered = (rect: DOMRect) =>
@@ -129,7 +282,8 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
// The desktop page hides blank and loading documents itself; only hide here
// while the pane shows its own empty or failed state over the surface.
const shown = props.visible && store.visible && !empty() && !failed() && !dialog.active
const cover = covered(rect)
// A comment on a picked element freezes the page so its editor can float above it.
const cover = store.comment?.tabID === tab.id || covered(rect)
if (shown && cover) freeze(tab.id)
if (!cover) thaw()
const visible = shown && !(cover && store.snapshot?.tabID === tab.id)
@@ -202,6 +356,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
() => store.visible,
() => props.visible,
() => state()?.id,
() => store.comment?.tabID,
empty,
failed,
registration,
@@ -220,7 +375,13 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
)
// ResizeObserver runs after layout in the same frame; measuring here instead of on the next
// animation frame keeps the native view in step with a pane drag.
createResizeObserver(() => surface, measure)
createResizeObserver(
() => surface,
(rect) => {
measure()
setStore("size", { width: rect.width, height: rect.height })
},
)
createEventListener(window, "resize", () => schedule(300))
// Floating content portals directly into <body>; keep measuring briefly so
// the positioner has settled before the overlap check runs.
@@ -290,6 +451,39 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
}
/>
</Tooltip>
<Show when={props.onComment}>
<Tooltip
placement="top"
value={
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<span>{language.t("session.browser.inspect")}</span>
<Show when={command.keybindParts("browser.inspect").length > 0}>
<Keybind keys={command.keybindParts("browser.inspect")} variant="neutral" />
</Show>
</div>
{/* The page claims Chromium's picker chord itself; the app leaves it to the terminal. */}
<div class="flex items-center gap-2">
<span>{language.t("session.browser.inspect.pageShortcut")}</span>
<Keybind keys={formatKeybindParts("mod+shift+c", language.t)} variant="neutral" />
</div>
</div>
}
>
{/* The ghost variant sets the button color, so the active accent needs precedence over it. */}
<IconButton
{...button}
data-action="browser-inspect"
disabled={!inspectable()}
state={picking() ? "pressed" : undefined}
classList={{ "!text-v2-icon-icon-accent": picking() || !!store.comment }}
aria-pressed={picking()}
aria-label={language.t("session.browser.inspect")}
onClick={toggleInspect}
icon={<Icon name="select-element" size="small" />}
/>
</Tooltip>
</Show>
<form
dir="ltr"
class="relative min-w-0 flex-1 h-7 rounded-md hover:bg-v2-overlay-simple-overlay-hover focus-within:bg-v2-overlay-simple-overlay-hover text-12-regular"
@@ -378,7 +572,71 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
{language.t("session.browser.suspended")}
</p>
</Show>
<Show when={store.comment?.tabID === state()?.id && store.comment}>
{(current) => (
<div
data-component="browser-comment"
class="absolute inset-0 z-10"
onPointerDown={(event) => {
// A click beside the editor dismisses it unless it would discard a draft.
if (event.target === event.currentTarget && !current().draft.trim()) closeComment()
}}
>
<Show when={spotlight()}>
{(rect) => (
<div
data-slot="browser-comment-spotlight"
class="pointer-events-none absolute rounded-[2px]"
style={{
left: `${rect().x}px`,
top: `${rect().y}px`,
width: `${rect().width}px`,
height: `${rect().height}px`,
}}
/>
)}
</Show>
<Show when={placement()}>
{(position) => (
<div
ref={(element) =>
createResizeObserver(element, (rect) => setStore("editorHeight", Math.ceil(rect.height)))
}
data-slot="browser-comment-editor"
data-prevent-autofocus
class="absolute overflow-y-auto rounded-[6px] shadow-[var(--v2-elevation-raised)]"
style={{
left: `${position().left}px`,
top: `${position().top}px`,
width: `${position().width}px`,
"max-height": `${position().maxHeight}px`,
}}
>
<LineCommentEditor
value={current().draft}
onInput={(value) => setStore("comment", "draft", value)}
onCancel={closeComment}
onSubmit={submitComment}
mention={props.mention}
selection={
<span class="flex min-w-0 items-center gap-1" dir="ltr">
<Icon name="select-element" size="small" class="shrink-0" />
<span class="min-w-0 truncate leading-[var(--line-height-tight)]">
{current().element.label}
</span>
</span>
}
/>
</div>
)}
</Show>
</div>
)}
</Show>
</div>
<p class="sr-only" role="status" aria-live="polite">
{picking() ? language.t("session.browser.inspect.active") : ""}
</p>
</aside>
)
}
@@ -9,6 +9,7 @@ import { showToast } from "@/shell/notifications/toast"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
import { base64Encode } from "@opencode/util/encode"
import { commentContextItem } from "@/composer/comment-note"
import { extractPromptComments, extractPromptFromMessage } from "@/composer/prompt"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServer } from "@/runtime/server/current"
@@ -75,16 +76,7 @@ export const DialogFork: Component = () => {
dialog.close()
const target = prompt.capture({ dir, id: forked.id })
target.set(restored)
target.context.replaceComments(
extractPromptComments(message).map((comment) => ({
type: "file",
path: comment.path,
selection: comment.selection,
comment: comment.comment,
preview: comment.preview,
commentOrigin: comment.origin,
})),
)
target.context.replaceComments(extractPromptComments(message).map(commentContextItem))
navigate(sessionHref(server.key, forked.id))
})
.catch((err: unknown) => {
@@ -18,6 +18,7 @@ import { useWorkspaceLocation } from "@/workspaces/location"
import { requireServerKey, sessionHref } from "@/shell/routes/session"
import { useComposerCommands } from "@/composer/commands"
import { useSessionCommands } from "../commands/use-session-commands"
import type { createSessionBrowser } from "../browser/model"
import type { SessionModel } from "../model"
import type { SessionScreenLayout } from "../screen-layout"
import { syncPromptModel, syncSessionModel } from "../session-model-helpers"
@@ -34,6 +35,7 @@ export function createActiveSessionRegion(input: {
screen: SessionScreenLayout
timeline: SessionTimelineInteraction
visible: Accessor<boolean>
browser: Pick<ReturnType<typeof createSessionBrowser>, "reveal">
}) {
const command = useCommand()
const dialog = useDialog()
@@ -46,6 +48,7 @@ export function createActiveSessionRegion(input: {
const state = createSessionRequestModel()
const controls = createComposerControls({
sessionKey: input.session.identity.sessionKey,
browser: { reveal: input.browser.reveal },
})
let promptRef: HTMLDivElement | undefined
@@ -29,6 +29,7 @@ import { SessionContextTab } from "@/session/files/session-context-tab"
import { SortableTab } from "@/session/files/tab"
import { OpenInAppButton } from "@/session/files/open-in-app-button"
import { useCommand } from "@/shell/commands/command"
import { useComposerState } from "@/composer/persistence"
import { useFile, type SelectedLineRange } from "@/workspaces/files/model"
import { useLanguage } from "@/runtime/i18n/language"
import { useLayout } from "@/shell/state/layout"
@@ -80,6 +81,7 @@ export function SessionSidePanel(props: {
const layout = useLayout()
const settings = useSettings()
const file = useFile()
const prompt = useComposerState()
const language = useLanguage()
const command = useCommand()
const sdk = useWorkspaceLocation()
@@ -614,6 +616,10 @@ export function SessionSidePanel(props: {
<SessionBrowserPane
browser={props.browser}
visible={reviewOpen() && isSessionBrowserTab(activeTab())}
mention={{ items: file.searchFilesAndDirectories }}
onComment={(comment) =>
prompt.context.add({ type: "browser", ...comment, commentID: crypto.randomUUID() })
}
/>
</div>
</Show>
+2 -10
View File
@@ -4,6 +4,7 @@ import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useLanguage } from "@/runtime/i18n/language"
import { commentContextItem } from "@/composer/comment-note"
import { extractPromptComments, extractPromptFromMessage } from "@/composer/prompt"
import { showToast } from "@/shell/notifications/toast"
import type { SessionModel } from "./model"
@@ -34,16 +35,7 @@ export function createSessionRevert(input: {
directory: location().directory,
}),
)
target.context.replaceComments(
extractPromptComments(message).map((comment) => ({
type: "file",
path: comment.path,
selection: comment.selection,
comment: comment.comment,
preview: comment.preview,
commentOrigin: comment.origin,
})),
)
target.context.replaceComments(extractPromptComments(message).map(commentContextItem))
}
const stage = async (message: SessionMessageUser, previous: SessionMessageUser | undefined) => {
+1
View File
@@ -178,6 +178,7 @@ function SessionScreenContent(props: { session: SessionModel; browser: ReturnTyp
screen,
timeline,
visible: conversationVisible,
browser,
})
useUsageExceededDialogs()
+329 -21
View File
@@ -1,3 +1,4 @@
import type { BrowserPaneElement } from "@opencode/app/desktop"
import { Browser } from "@opencode/plugin-browser/rpc"
import electron, { type BrowserWindow, type WebContents } from "electron"
import type { Protocol } from "devtools-protocol"
@@ -33,6 +34,38 @@ const retainedOperations = new Set<Browser.Method>([
"heap.object",
"heap.compare",
])
// Input the page receives while the element picker is on would pick an element instead.
const pointerOperations = new Set<Browser.Method>([
"click",
"hover",
"drag",
"fill",
"fill_form",
"select",
"check",
"press",
"scroll",
"files.drop",
])
// Chromium DevTools' element picker colors, so the overlay matches the Elements panel.
const inspectHighlight: Protocol.Overlay.HighlightConfig = {
showInfo: true,
showStyles: true,
showAccessibilityInfo: true,
colorFormat: "hex",
contrastAlgorithm: "aa",
contentColor: { r: 111, g: 168, b: 220, a: 0.66 },
paddingColor: { r: 147, g: 196, b: 125, a: 0.55 },
borderColor: { r: 255, g: 229, b: 153, a: 0.66 },
marginColor: { r: 246, g: 178, b: 107, a: 0.66 },
eventTargetColor: { r: 255, g: 196, b: 196, a: 0.66 },
shapeColor: { r: 96, g: 82, b: 177, a: 0.8 },
shapeMarginColor: { r: 96, g: 82, b: 127, a: 0.6 },
}
const pickedHighlight = { ...inspectHighlight, showInfo: false, showStyles: false, showAccessibilityInfo: false }
// Chromium rejects mode "none" without a config. A rejected call leaves the picker armed, and the
// next hideHighlight would put its hover tool back.
const inspectOff = { mode: "none", highlightConfig: inspectHighlight }
export type BrowserPage = ReturnType<typeof createBrowserPage>
export function createBrowserPage(
@@ -42,6 +75,8 @@ export function createBrowserPage(
partition: string
network: BrowserNetwork | null
publish: (error?: string) => void
/** Reports the element picker starting, stopping, or picking an element. */
inspect?: (event: { active: boolean; element?: BrowserPaneElement }) => void
fail: () => void
popup: (options: Electron.BrowserWindowConstructorOptions) => WebContents
initialize?: boolean
@@ -80,7 +115,18 @@ export function createBrowserPage(
contents.reload()
return
}
if (input.key === "Escape" && inspecting) {
event.preventDefault()
void toggleInspect(false)
return
}
if (input.alt || !(process.platform === "darwin" ? input.meta : input.control)) return
// The same chord as Chromium DevTools' element picker.
if (input.shift && input.code === "KeyC") {
event.preventDefault()
void toggleInspect(!inspecting)
return
}
const step =
input.key === "=" || input.key === "+" || input.code === "NumpadAdd"
? 0.5
@@ -98,6 +144,13 @@ export function createBrowserPage(
const diagnostics = createDiagnostics(cdp)
const profiling = createProfiling(contents, cdp, files, sourceURLs)
const refs = new Map<string, Element>()
// Elements the user picked. Their refs outlive later snapshots, so a comment keeps pointing at
// the element until the document changes.
const picked = new Map<string, Element>()
let inspecting = false
// Bumped by every picker toggle and hide, so a pick still being described can tell it was cancelled.
let picks = 0
let flash: ReturnType<typeof setTimeout> | undefined
const sessions = new Map<string, string>()
const parents = new Map<string, string>()
const contexts = new Map<string, { id: number; sessionID?: string }>()
@@ -141,7 +194,9 @@ export function createBrowserPage(
generation++
documents.clear()
refs.clear()
picked.clear()
diagnostics.clear()
if (inspecting) void toggleInspect(false)
publish()
}
const settle = () => {
@@ -264,8 +319,17 @@ export function createBrowserPage(
},
sessionId,
),
...(inspecting ? [arm(sessionId)] : []),
]).catch(() => undefined)
})
// Each out-of-process frame runs its own picker; the frame under the pointer reports the click.
cdp.on("Overlay.inspectNodeRequested", ({ backendNodeId }, sessionID) => {
if (!inspecting) return
void inspected(backendNodeId, sessionID).catch(() => {
void hideHighlight()
options.inspect?.({ active: false })
})
})
cdp.on("Target.detachedFromTarget", ({ sessionId }) => {
sessions.forEach((id, frameID) => {
if (id === sessionId) {
@@ -358,6 +422,12 @@ export function createBrowserPage(
})
},
setVisible(value: boolean) {
// A hidden page cannot be picked from, and a comment's frozen still already shows the
// picked element, so hiding ends the picker, any pick in progress, and the highlight.
if (visible && !value) {
picks++
void (inspecting ? toggleInspect(false) : hideHighlight())
}
visible = value
updateVisibility()
},
@@ -367,6 +437,15 @@ export function createBrowserPage(
const image = await contents.capturePage()
return image.isEmpty() ? undefined : new Uint8Array(image.toJPEG(90))
},
async inspect(enabled: boolean) {
if (closed) return
await ready
await toggleInspect(enabled)
},
async highlight(ref?: Browser.Ref) {
if (closed) return
await highlightPicked(ref).catch(() => undefined)
},
async execute(command: Browser.Command, signal: AbortSignal): Promise<Browser.Result> {
await ready
abortError(signal)
@@ -374,6 +453,7 @@ export function createBrowserPage(
throw new Error(
"Browser tab was closed. Call browser.tabs.list({}) and choose an existing tabID; do not reuse the closed tab's refs.",
)
if (inspecting && pointerOperations.has(command.action.type)) await toggleInspect(false)
if (dialog && command.action.type !== "dialog")
throw new Error(
'A JavaScript dialog is open. Inspect it with browser.dialog({tabID,action:"get"}), then explicitly accept or dismiss it before continuing.',
@@ -430,11 +510,13 @@ export function createBrowserPage(
async dispose() {
if (closed) return
closed = true
clearTimeout(flash)
detachNetwork?.()
contents.session.off("will-download", download)
await profiling.dispose()
cdp.dispose()
refs.clear()
picked.clear()
if (!win.isDestroyed()) {
corners.forEach((corner) => win.contentView.removeChildView(corner))
win.contentView.removeChildView(view)
@@ -491,25 +573,47 @@ export function createBrowserPage(
case "find":
return result({ tab: state(), ...(await snapshot(action)) })
case "evaluate": {
if (action.ref && action.frameID)
throw new Error(
"Pass either ref or frameID to browser.evaluate, not both. A ref already runs in its element's frame.",
)
const context = action.frameID ? contexts.get(action.frameID) : undefined
if (action.frameID && !context)
throw new Error(
"Frame context is unavailable. Call browser.frames({tabID}) and use a current frameID from this tab, or omit frameID to target the main frame.",
)
const value = await cdp.send(
"Runtime.evaluate",
{
expression: action.script,
contextId: context?.id,
awaitPromise: true,
returnByValue: true,
userGesture: true,
},
context?.sessionID,
)
const element = action.ref ? target(action.ref) : undefined
const objectId = element ? await resolve(element) : undefined
// With a ref, the script is a function that receives the element as its argument and as `this`.
const value = await (element && objectId
? cdp
.send(
"Runtime.callFunctionOn",
{
objectId,
functionDeclaration: action.script,
arguments: [{ objectId }],
awaitPromise: true,
returnByValue: true,
userGesture: true,
},
element.sessionID,
)
.finally(() => cdp.send("Runtime.releaseObject", { objectId }, element.sessionID).catch(() => undefined))
: cdp.send(
"Runtime.evaluate",
{
expression: action.script,
contextId: context?.id,
awaitPromise: true,
returnByValue: true,
userGesture: true,
},
context?.sessionID,
))
if (value.exceptionDetails)
throw new Error(
`Page JavaScript threw an exception. Check the script and frameID; inspect the page before repeating code with side effects. Details: ${(value.exceptionDetails.exception?.description ?? value.exceptionDetails.text).slice(0, 800)}`,
`Page JavaScript threw an exception. Check the script and ${action.ref ? "ref" : "frameID"}; inspect the page before repeating code with side effects. Details: ${(value.exceptionDetails.exception?.description ?? value.exceptionDetails.text).slice(0, 800)}`,
)
abortError(signal)
return result({ tab: state(), value: value.result.value ?? null })
@@ -824,7 +928,8 @@ export function createBrowserPage(
}
function target(ref: Browser.Ref): Element {
const value = refs.get(ref.replace(/^@/, ""))
const key = ref.replace(/^@/, "")
const value = refs.get(key) ?? picked.get(key)
if (!value)
throw new Error(
"Element ref is stale or belongs to another tab. Call browser.snapshot({tabID}) and use a ref from that tab's newest snapshot. Do not reuse refs after navigation or a newer snapshot.",
@@ -859,12 +964,7 @@ export function createBrowserPage(
}
async function call(element: Element, functionDeclaration: string, args: unknown[] = []) {
const object = await cdp.send("DOM.resolveNode", { backendNodeId: element.backendID }, element.sessionID)
const objectId = object.object.objectId
if (!objectId)
throw new Error(
"Element is no longer available. Call browser.snapshot({tabID}) and use a fresh ref; the page may have replaced the element.",
)
const objectId = await resolve(element)
try {
const result = await cdp.send(
"Runtime.callFunctionOn",
@@ -886,6 +986,15 @@ export function createBrowserPage(
}
}
async function resolve(element: Element) {
const object = await cdp.send("DOM.resolveNode", { backendNodeId: element.backendID }, element.sessionID)
if (!object.object.objectId)
throw new Error(
"Element is no longer available. Call browser.snapshot({tabID}) and use a fresh ref; the page may have replaced the element.",
)
return object.object.objectId
}
async function rect(element: Element, scroll = false) {
if (scroll) {
await cdp.send("DOM.scrollIntoViewIfNeeded", { backendNodeId: element.backendID }, element.sessionID)
@@ -1079,6 +1188,7 @@ export function createBrowserPage(
"Element is absent from this frame's accessibility snapshot. Retry browser.snapshot with the same tabID and no ref to refresh the frame, then choose a returned ref.",
)
refs.clear()
const pinned = new Map(Array.from(picked, ([ref, element]) => [`${element.frameID}:${element.backendID}`, ref]))
const lines: string[] = []
let truncated = false
const walk = async (node: Protocol.Accessibility.AXNode, level: number): Promise<void> => {
@@ -1094,9 +1204,11 @@ export function createBrowserPage(
const actionable =
role !== "RootWebArea" &&
(properties.get("focusable") || /^(button|link|textbox|combobox|checkbox|radio|option)$/.test(role))
const ref = actionable && node.backendDOMNodeId ? `e${++nextRef}` : ""
// A picked element keeps the ref its comment names, even when it is not otherwise actionable.
const known = node.backendDOMNodeId ? pinned.get(`${frameID}:${node.backendDOMNodeId}`) : undefined
const ref = known ?? (actionable && node.backendDOMNodeId ? `e${++nextRef}` : "")
const element = node.backendDOMNodeId ? { backendID: node.backendDOMNodeId, frameID, sessionID } : undefined
if (ref && element) refs.set(ref, element)
if (ref && element && !known) refs.set(ref, element)
const flags = (["checked", "disabled", "expanded", "selected"] as const).flatMap((name) =>
properties.has(name) ? [`${name}=${properties.get(name)}`] : [],
)
@@ -1124,4 +1236,200 @@ export function createBrowserPage(
).join("\n")
return { content: content.slice(0, Browser.MAX_TEXT), truncated: truncated || content.length > Browser.MAX_TEXT }
}
async function toggleInspect(enabled: boolean) {
picks++
if (enabled !== inspecting) {
inspecting = enabled
clearTimeout(flash)
await Promise.all(
[undefined, ...sessions.values()].map((sessionID) =>
(enabled ? arm(sessionID) : cdp.send("Overlay.setInspectMode", inspectOff, sessionID)).catch(() => undefined),
),
)
if (!enabled) await hideHighlight()
}
// A later toggle may have finished first; report where the picker ended up.
options.inspect?.({ active: inspecting })
}
async function arm(sessionID?: string) {
// The main target enables DOM at startup; frame targets only need it for the picker.
if (sessionID) await cdp.send("DOM.enable", {}, sessionID)
await cdp.send("Overlay.enable", {}, sessionID)
// The picker may have been turned off while the domains were enabling.
if (!inspecting) return
await cdp.send("Overlay.setInspectMode", { mode: "searchForNode", highlightConfig: inspectHighlight }, sessionID)
}
async function inspected(backendID: number, sessionID?: string) {
inspecting = false
const pick = ++picks
const navigation = generation
await Promise.all(
[undefined, ...sessions.values()].map((id) =>
cdp.send("Overlay.setInspectMode", inspectOff, id).catch(() => undefined),
),
)
// Keep the box model on the picked element, without the tooltip, while the renderer freezes the page.
await cdp.send("Overlay.highlightNode", { backendNodeId: backendID, highlightConfig: pickedHighlight }, sessionID)
const element = { backendID, frameID: await frameOf(backendID, sessionID), sessionID }
const [details, box, accessible] = await Promise.all([describe(element), rect(element), accessibility(element)])
await painted(sessionID)
// Escape, a toggle, hiding, or navigation while the element was described cancels the pick.
if (closed) return
if (pick !== picks || navigation !== generation) {
if (!inspecting) await hideHighlight()
options.inspect?.({ active: inspecting })
return
}
const ref = `e${++nextRef}`
picked.set(ref, element)
const zoom = contents.getZoomFactor()
// The pick focused the page; the comment editor opens in the app window. Focus only moves
// within a focused window, so synthetic input never raises a background window.
if (!win.isDestroyed() && win.isFocused()) win.webContents.focus()
options.inspect?.({
active: false,
element: {
ref: Browser.Ref.make(ref),
...details,
...accessible,
rect: { x: box.x * zoom, y: box.y * zoom, width: box.width * zoom, height: box.height * zoom },
},
})
}
async function highlightPicked(ref?: Browser.Ref) {
clearTimeout(flash)
const element = ref ? picked.get(ref.replace(/^@/, "")) : undefined
if (!element) return hideHighlight()
await cdp.send("DOM.scrollIntoViewIfNeeded", { backendNodeId: element.backendID }, element.sessionID)
await cdp.send(
"Overlay.highlightNode",
{ backendNodeId: element.backendID, highlightConfig: pickedHighlight },
element.sessionID,
)
flash = setTimeout(() => void hideHighlight(), 1_500)
}
async function hideHighlight() {
await Promise.all(
[undefined, ...sessions.values()].map((sessionID) =>
cdp.send("Overlay.hideHighlight", {}, sessionID).catch(() => undefined),
),
)
}
// The picker names a node but not its frame. A target owns its root frame plus any same-process
// child frames, so match the node's document against each child frame's content document.
async function frameOf(backendID: number, sessionID?: string) {
const tree = await frames()
const owned = tree.filter((frame) => sessionFor(frame.id, tree) === sessionID)
const root = owned.find((frame) => !frame.parentID || sessionFor(frame.parentID, tree) !== sessionID) ?? tree[0]
const nested = owned.filter((frame) => frame !== root)
if (!nested.length) return root.id
const node = await cdp.send("DOM.resolveNode", { backendNodeId: backendID }, sessionID)
const document = node.object.objectId
? await cdp.send(
"Runtime.callFunctionOn",
{ objectId: node.object.objectId, functionDeclaration: "function() { return this.ownerDocument; }" },
sessionID,
)
: undefined
const described = document?.result.objectId
? await cdp.send("DOM.describeNode", { objectId: document.result.objectId }, sessionID)
: undefined
void Promise.all(
[node.object.objectId, document?.result.objectId].flatMap((objectId) =>
objectId ? [cdp.send("Runtime.releaseObject", { objectId }, sessionID).catch(() => undefined)] : [],
),
)
const owners = await Promise.all(
nested.map(async (frame) => {
const owner = await cdp.send("DOM.getFrameOwner", { frameId: frame.id }, sessionID).catch(() => undefined)
const iframe = owner
? await cdp.send("DOM.describeNode", { backendNodeId: owner.backendNodeId }, sessionID).catch(() => undefined)
: undefined
return { id: frame.id, document: iframe?.node.contentDocument?.backendNodeId }
}),
)
return owners.find((owner) => owner.document === described?.node.backendNodeId)?.id ?? root.id
}
async function describe(element: Element) {
return Schema.decodeUnknownSync(
Schema.Struct({ label: Schema.String, selector: Schema.String, text: Schema.optionalKey(Schema.String) }),
)(
await call(
element,
`function() {
const clip = (value, max) => (value.length > max ? value.slice(0, max - 1) + "\u2026" : value)
const label = this.localName + (this.id ? "#" + this.id : "") + Array.from(this.classList, (name) => "." + name).join("")
// One segment per document or shadow root, outermost first; " >>> " steps into a host's shadow root.
const segments = []
for (let start = this; start; ) {
const root = start.getRootNode()
const path = []
for (let node = start; node; node = node.parentElement) {
if (node.id && root.querySelectorAll("#" + CSS.escape(node.id)).length === 1) {
path.unshift("#" + CSS.escape(node.id))
break
}
if (node === node.ownerDocument.body) {
path.unshift("body")
break
}
const same = Array.from(node.parentNode?.children ?? []).filter((child) => child.localName === node.localName)
path.unshift(CSS.escape(node.localName) + (same.length > 1 ? ":nth-of-type(" + (same.indexOf(node) + 1) + ")" : ""))
}
segments.unshift(path.join(" > "))
start = root instanceof ShadowRoot ? root.host : undefined
}
const selector = segments.join(" >>> ")
const text = clip((this.innerText ?? this.textContent ?? "").replace(/\\s+/g, " ").trim(), 160)
// A cut selector is invalid syntax; leave it out rather than pass it off as usable.
return { label: clip(label, 200), selector: selector.length > 2000 ? "" : selector, ...(text ? { text } : {}) }
}`,
),
)
}
async function accessibility(element: Element) {
const tree = await cdp
.send(
"Accessibility.getPartialAXTree",
{ backendNodeId: element.backendID, fetchRelatives: false },
element.sessionID,
)
.catch(() => undefined)
const node = tree?.nodes.find((node) => node.backendDOMNodeId === element.backendID && !node.ignored)
const role = String(node?.role?.value ?? "")
const name = String(node?.name?.value ?? "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 160)
return {
...(role && !["generic", "none", "presentation"].includes(role) ? { role: role.slice(0, 128) } : {}),
...(name ? { name } : {}),
}
}
// Wait until the compositor shows the picked highlight without the hover tooltip, so the
// renderer's still of the page includes it. A stalled page must not hold the comment back.
async function painted(sessionID?: string) {
await Promise.race([
cdp
.send(
"Runtime.evaluate",
{
expression: "new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))",
awaitPromise: true,
},
sessionID,
)
.catch(() => undefined),
new Promise((resolve) => setTimeout(resolve, 150)),
])
}
}
@@ -291,6 +291,12 @@ export function createBrowserPane(storage: StateStore) {
async capture(win: BrowserWindow, bindingID: string, tabID: Browser.TabID) {
return (await owned(win, bindingID).pages.get(tabID)?.capture()) ?? null
},
async inspect(win: BrowserWindow, bindingID: string, tabID: Browser.TabID, enabled: boolean) {
await owned(win, bindingID).pages.get(tabID)?.inspect(enabled)
},
async highlight(win: BrowserWindow, bindingID: string, tabID: Browser.TabID, ref?: Browser.Ref) {
await owned(win, bindingID).pages.get(tabID)?.highlight(ref)
},
async command(win: BrowserWindow, bindingID: string, command: BrowserPaneCommand) {
const entry = owned(win, bindingID)
await execute(entry, { action: command, files: [] }, new AbortController().signal)
@@ -419,6 +425,9 @@ export function createBrowserPane(storage: StateStore) {
publish: (error) => {
if (entry.pages.has(id)) publishState(entry, error)
},
inspect: (event) => {
if (entry.pages.has(id)) report(entry, { type: "inspect", tabID: id, ...event })
},
popup: (popupOptions) => {
const popup = create(entry, false, popupOptions)
focus(entry, popup.state().id)
+9 -7
View File
@@ -5,13 +5,15 @@ export function protocolError(method: string, error: unknown) {
const recovery =
method === "DOM.setFileInputFiles" && /not.*file input/i.test(detail)
? "Target is not a file input. Call browser.snapshot({tabID}) and choose an input[type=file] ref for browser.files.upload, or use browser.files.drop for a drop area."
: /(?:node|object).*(?:not found|not exist)|(?:find|resolve).*(?:node|object)|detached/i.test(detail)
? "The element may have detached. Call browser.snapshot({tabID}) and use a fresh ref from that tab."
: /context.*(?:destroyed|not found)|find.*context|session.*not found/i.test(detail)
? "The document or frame changed. Call browser.frames({tabID}) and browser.snapshot({tabID}); use current frame IDs and refs."
: /wasn't found|method not found|not implemented|not allowed/i.test(detail)
? "This Chromium target does not support or allow the operation. Check desktop/plugin compatibility and report it; do not retry unchanged or disable browser security."
: "Inspect browser.tabs.list({}) and the target tab before deciding to retry; a partially completed action is not automatically safe to repeat."
: method === "Runtime.callFunctionOn" && /not evaluate to a function/i.test(detail)
? "With ref, browser.evaluate needs a function that receives the element, for example (element) => element.textContent."
: /(?:node|object).*(?:not found|not exist)|(?:find|resolve).*(?:node|object)|detached/i.test(detail)
? "The element may have detached. Call browser.snapshot({tabID}) and use a fresh ref from that tab."
: /context.*(?:destroyed|not found)|find.*context|session.*not found/i.test(detail)
? "The document or frame changed. Call browser.frames({tabID}) and browser.snapshot({tabID}); use current frame IDs and refs."
: /wasn't found|method not found|not implemented|not allowed/i.test(detail)
? "This Chromium target does not support or allow the operation. Check desktop/plugin compatibility and report it; do not retry unchanged or disable browser security."
: "Inspect browser.tabs.list({}) and the target tab before deciding to retry; a partially completed action is not automatically safe to repeat."
return new Error(`${recovery} Chromium command ${method} failed: ${detail}`, { cause: error })
}
@@ -42,6 +42,10 @@ export const eventHandlers = EventRpcs.toLayer(
if (request.type === "register") return target.pane.register(target.win, request.bindingID, request.target)
if (request.type === "layout") return target.pane.layout(target.win, request.bindingID, request.layout)
if (request.type === "command") return target.pane.command(target.win, request.bindingID, request.command)
if (request.type === "inspect")
return target.pane.inspect(target.win, request.bindingID, request.tabID, request.enabled)
if (request.type === "highlight")
return target.pane.highlight(target.win, request.bindingID, request.tabID, request.ref)
return target.pane.close(target.win, request.bindingID)
}).pipe(Effect.orDie),
BrowserPaneCapture: (request, context) =>
@@ -50,6 +50,20 @@ export function createDesktopPlatform(
ready
.then(() => api.browserPane.capture(bindingID, tabID))
.then((data) => data && new Blob([data], { type: "image/jpeg" })),
inspect(tabID, enabled) {
if (!closed)
void ready
.then(() => api.browserPane.send({ type: "inspect", bindingID, tabID, enabled }))
.catch(() => undefined)
},
highlight(tabID, ref) {
if (!closed)
void ready
.then(() =>
api.browserPane.send({ type: "highlight", bindingID, tabID, ...(ref === undefined ? {} : { ref }) }),
)
.catch(() => undefined)
},
close() {
if (closed) return
closed = true
@@ -29,10 +29,27 @@ export const BrowserPaneRequestSchema = Schema.Union([
Schema.Struct({ type: Schema.Literal("register"), bindingID, target }),
Schema.Struct({ type: Schema.Literal("layout"), bindingID, layout: Schema.optionalKey(layout) }),
Schema.Struct({ type: Schema.Literal("command"), bindingID, command: Browser.Action }),
Schema.Struct({ type: Schema.Literal("inspect"), bindingID, tabID: Browser.TabID, enabled: Schema.Boolean }),
Schema.Struct({
type: Schema.Literal("highlight"),
bindingID,
tabID: Browser.TabID,
ref: Schema.optionalKey(Browser.Ref),
}),
Schema.Struct({ type: Schema.Literal("close"), bindingID }),
])
export type BrowserPaneRequest = Schema.Schema.Type<typeof BrowserPaneRequestSchema>
const detail = (maximum: number) => Schema.String.check(Schema.isMaxLength(maximum))
export const BrowserPaneElementSchema = Schema.Struct({
ref: Browser.Ref,
selector: detail(2_048),
label: detail(512),
role: Schema.optionalKey(detail(128)),
name: Schema.optionalKey(detail(512)),
text: Schema.optionalKey(detail(512)),
rect: bounds,
})
export const BrowserPaneEventSchema = Schema.Union([
Schema.Struct({ type: Schema.Literal("focus"), tabID: Browser.TabID }),
Schema.Struct({ type: Schema.Literal("preview"), path: text(2_048) }),
@@ -41,6 +58,12 @@ export const BrowserPaneEventSchema = Schema.Union([
state: Schema.NullOr(Browser.State),
error: Schema.optionalKey(Schema.String),
}),
Schema.Struct({
type: Schema.Literal("inspect"),
tabID: Browser.TabID,
active: Schema.Boolean,
element: Schema.optionalKey(BrowserPaneElementSchema),
}),
])
export const BrowserPaneRpc = Rpc.make("BrowserPane", { payload: { request: BrowserPaneRequestSchema } })
export const BrowserPaneCaptureRpc = Rpc.make("BrowserPaneCapture", {
@@ -27,6 +27,9 @@ test("native protocol errors keep the cause and give a valid recovery operation"
expect(protocolError("Runtime.evaluate", new Error("Cannot find context with specified id")).message).toContain(
"browser.frames({tabID})",
)
expect(
protocolError("Runtime.callFunctionOn", new Error("Given expression does not evaluate to a function")).message,
).toContain("(element) => element.textContent")
const unsupported = protocolError("Target.getBrowserContexts", new Error("Not allowed"))
expect(unsupported.message).toContain("does not support or allow")
expect(unsupported.message).toContain("do not retry unchanged")
+2
View File
@@ -75,6 +75,8 @@ async function main() {
ready
.then(() => pane.capture(win, bindingID, tabID))
.then((data) => data && new Blob([data], { type: "image/jpeg" })),
inspect: (tabID, enabled) => void ready.then(() => pane.inspect(win, bindingID, tabID, enabled)),
highlight: (tabID, ref) => void ready.then(() => pane.highlight(win, bindingID, tabID, ref)),
close: () => {
listeners.delete(bindingID)
void ready.then(() => pane.close(win, bindingID)).catch(() => {})
+93
View File
@@ -1,5 +1,6 @@
import assert from "node:assert/strict"
import type { BrowserWindow } from "electron"
import type { BrowserPaneElement } from "@opencode/app/desktop"
import { Browser } from "@opencode/plugin-browser/rpc"
import { Schema } from "effect"
import { createBrowserPage } from "../../src/main/browser-chromium"
@@ -8,11 +9,13 @@ import { createCornerImages } from "../../src/main/browser/corners"
export async function verifyTargets(win: BrowserWindow, url: string) {
const children = win.contentView.children.length
const tabID = Browser.TabID.make(`tab_${crypto.randomUUID()}`)
const inspections: { active: boolean; element?: BrowserPaneElement }[] = []
const page = createBrowserPage(win, {
id: tabID,
partition: `target-test-${crypto.randomUUID()}`,
network: null,
publish() {},
inspect: (event) => inspections.push(event),
fail() {
throw new Error("Target test page failed")
},
@@ -161,7 +164,87 @@ export async function verifyTargets(win: BrowserWindow, url: string) {
assert.deepEqual(await inspect(fileAction), original)
assert.equal(Buffer.from((await execute(fileAction, original)).files[0].data).toString(), "desktop download bytes")
// The element picker names the exact node, here one inside a shadow root.
await execute({ type: "navigate", tabID, url })
await execute({
type: "evaluate",
tabID,
script: `const host = document.createElement('x-card'); host.id = 'card'; host.style.cssText = 'position:fixed;top:0;left:0;display:block;background:white'; host.attachShadow({ mode: 'open' }).innerHTML = '<div>Title</div><div><button class="act">Shadow action</button></div>'; document.body.prepend(host); null`,
})
page.setVisible(true)
await page.inspect(true)
assert.deepEqual(inspections.at(-1), { active: true })
const center = Schema.decodeUnknownSync(
Schema.Struct({ value: Schema.Struct({ x: Schema.Finite, y: Schema.Finite }) }),
)(
(
await execute({
type: "evaluate",
tabID,
script:
"(() => { const box = document.querySelector('#card').shadowRoot.querySelector('button').getBoundingClientRect(); return { x: box.x + box.width / 2, y: box.y + box.height / 2 } })()",
})
).value,
).value
const clickShadowButton = async () => {
for (const type of ["mouseMoved", "mousePressed", "mouseReleased"])
await page.contents.debugger.sendCommand("Input.dispatchMouseEvent", {
type,
...center,
button: "left",
clickCount: 1,
})
}
await clickShadowButton()
const element = await until(() => inspections.find((event) => event.element)?.element)
assert.equal(element.selector, "#card >>> div:nth-of-type(2) > button")
assert.equal(element.label, "button.act")
assert.equal(element.role, "button")
assert.equal(element.name, "Shadow action")
const onElement = async (script: string) =>
Schema.decodeUnknownSync(Schema.Struct({ value: Schema.Unknown }))(
(await execute({ type: "evaluate", tabID, ref: element.ref, script })).value,
).value
assert.equal(await onElement("(element) => element.textContent"), "Shadow action")
assert.equal(
await onElement(
`(element) => { const [host, inner] = ${JSON.stringify(element.selector)}.split(" >>> "); return document.querySelector(host).shadowRoot.querySelector(inner) === element }`,
),
true,
"Each selector segment resolves within its own root",
)
const pickedSnapshot = Schema.decodeUnknownSync(Schema.Struct({ content: Schema.String }))(
(await execute({ type: "snapshot", tabID })).value,
).content
assert(pickedSnapshot.includes(`@${element.ref} [button] "Shadow action"`), pickedSnapshot)
assert.equal(await onElement("function () { return this.className }"), "act", "A snapshot keeps picked refs")
assert.deepEqual(
(await inspect({ type: "evaluate", tabID, ref: element.ref, script: "(element) => element.id" })).resources,
[url + "/"],
)
await assert.rejects(onElement("document.title"), /needs a function that receives the element/)
await assert.rejects(
execute({ type: "evaluate", tabID, ref: element.ref, frameID: "main", script: "(element) => element.id" }),
/not both/,
)
await page.inspect(true)
await execute({ type: "hover", tabID, ref: element.ref })
assert.deepEqual(inspections.at(-1), { active: false }, "Agent pointer input turns the picker off first")
// Turning the picker off while it is still arming must leave the page unarmed.
const arming = page.inspect(true)
await page.inspect(false)
await arming
assert.deepEqual(inspections.at(-1), { active: false })
await clickShadowButton()
await new Promise((resolve) => setTimeout(resolve, 300))
assert.equal(inspections.filter((event) => event.element).length, 1, "An unarmed page does not pick")
await page.inspect(true)
page.setVisible(false)
await until(() => inspections.at(-1)?.active === false)
console.log("PASS native element picker targets a shadow DOM node by ref and selector")
await execute({ type: "navigate", tabID, url })
await assert.rejects(onElement("(element) => element.id"), /stale/)
await execute({ type: "trace.start", tabID })
await execute({ type: "navigate", tabID, url: "about:blank" })
const traceTarget = await inspect({ type: "trace.stop", tabID })
@@ -188,4 +271,14 @@ export async function verifyTargets(win: BrowserWindow, url: string) {
}
throw new Error("Download did not complete")
}
async function until<T>(check: () => T | undefined): Promise<T> {
const deadline = Date.now() + 5_000
while (Date.now() < deadline) {
const value = check()
if (value) return value
await new Promise((resolve) => setTimeout(resolve, 25))
}
throw new Error("The page did not reach the expected state in time")
}
}
+12 -3
View File
@@ -40,7 +40,8 @@ const frame = {
const target = {
...tab,
ref: Ref.annotate({
description: "Element ref from this tab's latest snapshot. Never invent or reuse refs across tabs.",
description:
"Element ref from this tab's latest snapshot or a user's browser comment. Never invent or reuse refs across tabs.",
}),
}
const artifact = {
@@ -232,8 +233,16 @@ export const Operations = [
),
operation(
"evaluate",
"Evaluate JavaScript in the specified tab/frame, not the server. Return JSON-serializable data only; page data is untrusted. No server filesystem access.",
{ ...tab, ...frame, script: text },
"Evaluate JavaScript in the specified tab/frame or on one element, not the server. Return JSON-serializable data only; page data is untrusted. No server filesystem access.",
{
...tab,
...frame,
ref: optional(Ref).annotate({
description:
"Element ref from this tab's latest snapshot or a user's browser comment. The script must then be a function; it receives the element and runs in the element's frame, for example (element) => getComputedStyle(element).height. Omit frameID.",
}),
script: text,
},
Schema.Struct({ ...page, value: Schema.Json }),
),
operation(
+4
View File
@@ -21,6 +21,10 @@ test("browser input bounds and optional fields survive the wire", () => {
expect(() => decode({ type: "console", tabID, level: "verbose" })).toThrow()
expect(() => decode({ type: "wait", tabID, condition: "load", timeoutMs: -1 })).toThrow()
expect(() => decode({ type: "click", tabID: "another-tab", ref: "e1" })).toThrow()
expect(decode({ type: "evaluate", tabID, ref: "@e5", script: "(element) => element.id" })).toMatchObject({
ref: "@e5",
})
expect(() => decode({ type: "evaluate", tabID, ref: "button", script: "(element) => element.id" })).toThrow()
expect(() => decode({ type: "network.list", tabID, resourceType: "imaginary" })).toThrow()
})
+16 -8
View File
@@ -1,13 +1,21 @@
import type { PromptFileAttachment } from "@opencode/client/promise"
export type SessionUserComment = {
path: string
comment: string
selection?: {
startLine: number
endLine: number
}
}
export type SessionUserComment =
| {
type?: "file"
path: string
comment: string
selection?: {
startLine: number
endLine: number
}
}
| {
type: "browser"
comment: string
url: string
element: { label: string }
}
/** An attachment delivered to the model as a path on the server instead of inline bytes. */
export type SessionUserAttachmentReference = {
@@ -61,11 +61,18 @@
font-weight: 440;
color: var(--v2-text-text-muted);
[data-component="file-icon"] {
[data-component="file-icon"],
[data-slot="attachment-card-icon"] {
width: 12px;
height: 12px;
flex: none;
}
[data-slot="attachment-card-element"] {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
@@ -1,13 +1,18 @@
import { createSignal, onCleanup, onMount, Show } from "solid-js"
import { FileIcon } from "@opencode/ui/file-icon"
import { Icon } from "@opencode/ui/icon"
import { getFilenameTruncated } from "@opencode/util/path"
import { Tooltip } from "@opencode/ui/tooltip"
import { AttachmentCard } from "./attachment-card"
/** What a comment is about: lines of a file, or an element picked in a browser page. */
export type CommentCardTarget =
| { type: "file"; path: string; selection?: { startLine: number; endLine: number } }
| { type: "browser"; element: string }
export function CommentCard(props: {
comment: string
path: string
selection?: { startLine: number; endLine: number }
target: CommentCardTarget
active?: boolean
title?: string
tooltip?: boolean
@@ -49,15 +54,37 @@ export function CommentCard(props: {
}}
onClick={props.onClick}
>
<FileIcon node={{ path: props.path, type: "file" }} />
<span>
{getFilenameTruncated(props.path, 14)}
<Show when={props.selection}>
{(sel) =>
sel().startLine === sel().endLine ? `:${sel().startLine}` : `:${sel().startLine}-${sel().endLine}`
}
</Show>
</span>
<Show
when={props.target.type === "browser" ? props.target : undefined}
fallback={
<Show when={props.target.type === "file" ? props.target : undefined}>
{(file) => (
<>
<FileIcon node={{ path: file().path, type: "file" }} />
<span>
{getFilenameTruncated(file().path, 14)}
<Show when={file().selection}>
{(sel) =>
sel().startLine === sel().endLine
? `:${sel().startLine}`
: `:${sel().startLine}-${sel().endLine}`
}
</Show>
</span>
</>
)}
</Show>
}
>
{(browser) => (
<>
<Icon name="select-element" data-slot="attachment-card-icon" />
<span data-slot="attachment-card-element" dir="ltr">
{browser().element}
</span>
</>
)}
</Show>
</AttachmentCard>
</Tooltip>
)
@@ -183,8 +183,11 @@ function UserMessageComments(props: { comments: SessionUserComment[]; bounded: b
{(comment) => (
<CommentCard
comment={comment.comment}
path={comment.path}
selection={comment.selection}
target={
comment.type === "browser"
? { type: "browser", element: comment.element.label }
: { type: "file", path: comment.path, selection: comment.selection }
}
title={comment.comment}
tooltip
wide
@@ -32,6 +32,10 @@ export function formatKeybind(config: string) {
return config === "none" ? "" : config
}
export function formatKeybindParts(config: string) {
return !config || config === "none" ? [] : config.split(",")[0].split("+")
}
export function useCommand() {
return {
options: [],
+4
View File
@@ -12,6 +12,10 @@ const icons = {
viewBox: "0 0 16 16",
body: `<circle cx="8" cy="8" r="6" stroke="currentColor"/><ellipse cx="8" cy="8" rx="2.5" ry="6" stroke="currentColor"/><path d="M2 8h12" stroke="currentColor"/>`,
},
"select-element": {
viewBox: "0 0 16 16",
body: `<path d="M12.5 6.5V2.5C12.5 1.94772 12.0523 1.5 11.5 1.5H2.5C1.94772 1.5 1.5 1.94772 1.5 2.5V11.5C1.5 12.0523 1.94772 12.5 2.5 12.5H6.5" stroke="currentColor" stroke-linecap="round"/><path d="M7.5 7.5L14.5 10.1L11.1 11.1L10.1 14.5L7.5 7.5Z" stroke="currentColor" stroke-linejoin="round"/>`,
},
flask: {
viewBox: "0 0 16 16",
body: `<path d="M5.5 2H10.5M6 2V6L2.5 12C2 13 2.5 14 3.5 14H12.5C13.5 14 14 13 13.5 12L10 6V2M4.25 9H11.75" stroke="currentColor" stroke-linecap="square" stroke-linejoin="round"/>`,