chore(app): incorporate current v2 into worktree recovery PR

This commit is contained in:
Hona
2026-09-30 08:05:50 +00:00
60 changed files with 1784 additions and 188 deletions
+15 -17
View File
@@ -599,8 +599,8 @@
},
"peerDependencies": {
"@opencode/theme": "workspace:*",
"@opentui/core": ">=0.5.12",
"@opentui/solid": ">=0.5.12",
"@opentui/core": ">=0.5.13",
"@opentui/solid": ">=0.5.13",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
@@ -1117,9 +1117,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.5.12",
"@opentui/keymap": "0.5.12",
"@opentui/solid": "0.5.12",
"@opentui/core": "0.5.13",
"@opentui/keymap": "0.5.13",
"@opentui/solid": "0.5.13",
"@pierre/diffs": "1.5.1",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.71.0",
@@ -2255,27 +2255,25 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
"@opentui/core": ["@opentui/core@0.5.12", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.12", "@opentui/core-darwin-x64": "0.5.12", "@opentui/core-linux-arm64": "0.5.12", "@opentui/core-linux-arm64-musl": "0.5.12", "@opentui/core-linux-x64": "0.5.12", "@opentui/core-linux-x64-musl": "0.5.12", "@opentui/core-win32-arm64": "0.5.12", "@opentui/core-win32-x64": "0.5.12" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-ZXBE5gmvdovmV8zJQrOQf6E44v1tJRDEgrM2MYhEglzgXZ+smIUp95O8zeRYGsuIzQIiMPMgQqKtTJuzvAb7BQ=="],
"@opentui/core": ["@opentui/core@0.5.13", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.13", "@opentui/core-darwin-x64": "0.5.13", "@opentui/core-linux-arm64": "0.5.13", "@opentui/core-linux-arm64-musl": "0.5.13", "@opentui/core-linux-x64": "0.5.13", "@opentui/core-linux-x64-musl": "0.5.13", "@opentui/core-win32-arm64": "0.5.13", "@opentui/core-win32-x64": "0.5.13" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-3XNXJx3J9I+Ce170oh7GJ3leBZZA7QYv4OAhwiMCPQtKMkhy3Wra99NM26HXcEGme0zjkkJLownwLLnxuc0U/Q=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YdVnP0tAyerBNl0mIcmQEOotPeZzW1VnSXKBl5cyZ5e6nDd2Y+ui/8eRPpn1oqcamf1NCnzS4ohMgejOvna8Zg=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5X9U2v5qn4+vKj31zpn0RMoBV3Q9ttdM4t5KN41NPg6XgtIOZgKwox5yOsvBaiQ1KGbuk5wMvXCxfWJ30Mj/nw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-uRrQJdHmLUSj3PV23QPi3WSimYTTxcXnVouxF6U4xMXlOv4N3SxnHfVwMRQkPqbGOfvVWHeLE6FdK4C+ubU0sQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-9IgQHLKSn3quVsU0gibkRjOPegv1QH4573KWYXQNDmSxBXw84Nd1mDNsbK9f72SblAILhOCYK685pmk3X/z0Ig=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-XeKhuIaEtgipvuPHbl4qPOBj+Ut+2zObmsxMVM1jDcjz/FatG9PGeGQPx1G1SnvH2AgpT4K+eCu7DUF0+yIqoQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-jMXGEpB/2Ch/IR0QzYBM5eCr1tUzicxoTP1AbMCbOduJXcGRCHTWqIdQIgRErwPF92gBcDIs4JugSn/Jd0Ki7g=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-VZ2sNMw1d/r1SLPjUbOP9LKscKz1CQjID8adTL6gG8Lrrq+mYcIUxutyB+P/eG0J/7oRZLPR6OMt7dUOap6RTg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.13", "", { "os": "linux", "cpu": "x64" }, "sha512-7ytcKy7pQxvQer3PADGYNK+HRhSDyhZvp475GRBEhlBJIdhdL8PNU6hjd2R1dnieifiW7Mpc4EiC1kGKe69gYg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.12", "", { "os": "linux", "cpu": "x64" }, "sha512-eZiCjEzwbb6qClPPfk32Nha9xmr9obt69Xj0+9SKsXxWLBKkjQEGOMRoh/R9ObaQF4aq8If1xV3VEY0sD9W9vg=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.13", "", { "os": "linux", "cpu": "x64" }, "sha512-C2ZC7WA7YH/GS+rJnbeVGe6Kxeor0aTD2qcS6l32KmN4XuY7TXm192XJMfxxnKaTA6oKp3mW27ezolJKUcGtMw=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.12", "", { "os": "linux", "cpu": "x64" }, "sha512-WWW0hVBoSYZ3D6AgZ4u2Y5/u/IyIq2pDb+4yI3WgJ70Wyt6ofHy+6kRGRgbXFn1p+rPInAHjCXD2v6C7iEKSrA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-IB6eLh6I/gYXrJ0AYxZpD+Dr+yfZJck0ZfLnIapbD+fykaT0umOtvc/BL+CtlC/aSc7jpe0q4eXdn6XIA8HtLw=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-aLbm6870Ybls6CYL4zMOCImTBPLZHZMUXJFGqMI44lIWxitkAtT6zg5lYA4oRqFRzzryDclxr29+hDgT3p3Blw=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.13", "", { "os": "win32", "cpu": "x64" }, "sha512-FCucDN8A4iFDPdwqLK99ISs42pcbcObkxhN1jtsnmcYM9/Ptron7Hhapx2ObQWYjSrJNnntmfZQd2ewpE8YqTQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.12", "", { "os": "win32", "cpu": "x64" }, "sha512-KTwtwpfd2zF9opVh3SyRJYDd1o3Xv4XL8OZb8Zi+CqWUel6Y2IDCiVivCv8fGJt3J7wOIXXtuZI9ZUkLyKJCiQ=="],
"@opentui/keymap": ["@opentui/keymap@0.5.13", "", { "dependencies": { "@opentui/core": "0.5.13" }, "peerDependencies": { "@opentui/react": "0.5.13", "@opentui/solid": "0.5.13", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-bbpQ5N8000CuvVDhptGuHxUE92sivChjUjLkvYT1VnLlDTiGbQ4mPsHfnqsaEokc7O7jBcoJXJQsLxw7rO/qkg=="],
"@opentui/keymap": ["@opentui/keymap@0.5.12", "", { "dependencies": { "@opentui/core": "0.5.12" }, "peerDependencies": { "@opentui/react": "0.5.12", "@opentui/solid": "0.5.12", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-yWPvJjRhJTRoRSUucQq9Ua8ZW7n/2YQ/j6JxWq5Qekm4WuFiTplEkebR/Aj2/xA8tX68NOE5qv1LrY0Jk3NLNQ=="],
"@opentui/solid": ["@opentui/solid@0.5.12", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.12", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-hAiVlVMtT7AkHGblKwcW1YAuXtxkSy1XSf/RRc4j3IlG3mTNX0bhJdnGOo3Xw14EqeZMp41Mcp5WzHAzMm/DzA=="],
"@opentui/solid": ["@opentui/solid@0.5.13", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.13", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-MGMYQYeoZNn9uq3zsLPqq/G+nBQXgGwpylsrYux0nJ+C+6oflDEE6t8huSTZaAUizC/3WG8sVSVFutczeRbnPg=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
+3 -3
View File
@@ -52,9 +52,9 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.12",
"@opentui/keymap": "0.5.12",
"@opentui/solid": "0.5.12",
"@opentui/core": "0.5.13",
"@opentui/keymap": "0.5.13",
"@opentui/solid": "0.5.13",
"@tanstack/solid-virtual": "3.13.37",
"@shikijs/stream": "4.4.3",
"@standard-schema/spec": "1.1.0",
@@ -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()
@@ -0,0 +1,43 @@
import { expect, test } from "@playwright/test"
import { base64Encode } from "@opencode/util/encode"
import {
assistantMessage,
session,
setupTimeline,
textPart,
userMessage,
} from "../performance/timeline-stability/fixture"
const target = "ses_0123456789abcdefghijklmnop"
const missing = "ses_abcdefghijklmnopqrstuvwxyz"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("opens a verified session from agent prose or inline code with the keyboard", async ({ page }) => {
await setupTimeline(page, {
sessions: [session(), session({ id: target, title: "Linked session" })],
messages: [
userMessage(),
assistantMessage([textPart("prt_session_links", `Visit ${target} or \`${target}\` to see the result.`)]),
],
})
const markdown = page.locator('[data-component="markdown"]').filter({ hasText: `Visit ${target}` })
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
await expect(markdown.getByRole("button", { name: target })).toHaveCount(2)
await markdown
.getByRole("button", { name: target })
.filter({ has: page.locator("code") })
.press("Enter")
await expect(page).toHaveURL(`/server/${base64Encode(server)}/session/${target}`)
await expect(page.locator(`[data-titlebar-tab-link][href$="/session/${target}"]`)).toContainText("Linked session")
})
test("does not navigate to an ID that is absent from the current server", async ({ page }) => {
await setupTimeline(page, {
messages: [userMessage(), assistantMessage([textPart("prt_session_missing", `See ${missing}.`)])],
})
const markdown = page.locator('[data-component="markdown"]').filter({ hasText: `See ${missing}.` })
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
await markdown.getByRole("button", { name: missing }).click()
await expect(page.getByText("This session cannot be found")).toBeVisible()
await expect(page).toHaveURL(/\/session\/ses_timeline_stability$/)
})
+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;
}
@@ -62,6 +62,7 @@ export function createComposerProjectControls(props: { draftId: string; worktree
return createMemo<PromptProjectControls>(() => ({
available: projects(),
directory: location().directory,
projectID: location().current?.project.id,
server: servers.list.length > 1 ? ServerConnection.key(projectServer()) : undefined,
select: selectProject,
add: addProject,
@@ -0,0 +1,51 @@
import { describe, expect, mock, test } from "bun:test"
import { createRoot } from "solid-js"
import type { PromptProjectControls } from "./selector"
mock.module("@/runtime/i18n/language", () => ({ useLanguage: () => ({ t: (key: string) => key }) }))
const { createPromptProjectController } = await import("./selector")
describe("new session project selection", () => {
test("shows the current project's appearance before its worktree inventory loads", () => {
const generic = { id: "other", worktree: "/other" }
const current = {
id: "current",
worktree: "/repo",
name: "My custom name",
icon: { override: "data:image/png;base64,AAAA" },
}
const controls: PromptProjectControls = {
available: [generic, current],
directory: "/repo/.opencode/worktree/feature",
projectID: "current",
select() {},
add() {},
}
createRoot((dispose) => {
const controller = createPromptProjectController({ controls: () => controls, onDone() {} })
expect(controller.selected()).toEqual(current)
dispose()
})
})
test("does not select a matching project ID from another server", () => {
const remote = { id: "shared", worktree: "/remote", server: { key: "remote", name: "Remote" } }
const local = { id: "shared", worktree: "/local", server: { key: "local", name: "Local" } }
const controls: PromptProjectControls = {
available: [remote, local],
directory: "/local/feature",
projectID: "shared",
server: "local",
select() {},
add() {},
}
createRoot((dispose) => {
const controller = createPromptProjectController({ controls: () => controls, onDone() {} })
expect(controller.selected()).toEqual(local)
dispose()
})
})
})
@@ -31,6 +31,7 @@ export type PromptProject = {
export type PromptProjectControls = {
available: PromptProject[]
directory: string
projectID?: string
server?: string
select: (worktree: string, server?: string) => void
add: (title: string, server?: string) => void
@@ -56,14 +57,15 @@ export function createPromptProjectController(input: {
let searchRef: HTMLInputElement | undefined
const current = () => {
const key = pathKey(input.controls().directory)
return input
.controls()
.available.find(
const controls = input.controls()
const key = pathKey(controls.directory)
const projects = controls.available.filter((project) => !project.server || project.server.key === controls.server)
return (
projects.find(
(project) =>
(!project.server || project.server.key === input.controls().server) &&
(pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)),
)
pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
) ?? projects.find((project) => controls.projectID && project.id === controls.projectID)
)
}
const selected = () => current() ?? input.controls().available[0]
const projects = () => {
+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"
@@ -35,6 +36,7 @@ export function createActiveSessionRegion(input: {
screen: SessionScreenLayout
timeline: SessionTimelineInteraction
visible: Accessor<boolean>
browser: Pick<ReturnType<typeof createSessionBrowser>, "reveal">
}) {
const command = useCommand()
const dialog = useDialog()
@@ -47,6 +49,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
@@ -184,6 +184,7 @@ function SessionScreenContent(props: {
screen,
timeline,
visible: conversationVisible,
browser,
})
useUsageExceededDialogs()
@@ -10,6 +10,8 @@ import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useTabs } from "@/shell/tabs/tabs"
import { readLocalImage } from "@/runtime/server/image"
import { useLanguage } from "@/runtime/i18n/language"
import { showToast } from "@/shell/notifications/toast"
export function SessionUIProvider(
props: ParentProps<{
@@ -22,6 +24,7 @@ export function SessionUIProvider(
const data = useData()
const serverSDK = useServerSDK()
const tabs = useTabs()
const language = useLanguage()
const directory = () => props.directory
const readImage = createMemo<ReadMarkdownImage>(() => {
const dir = directory()
@@ -39,6 +42,17 @@ export function SessionUIProvider(
await data.session.sync(sessionID).catch(() => undefined)
navigate(href(sessionID))
}
const openReferencedSession = async (sessionID: string) => {
// The transcript may mention a session from another server (or one that was deleted).
// Resolve it on this server before touching tabs or the current route.
const session = await serverSDK.api.session.get({ sessionID }).catch(() => undefined)
if (!session || session.time.archived) {
showToast({ title: language.t("session.error.notFound") })
return
}
data.session.remember(session)
tabs.select(tabs.addSessionTab({ server: props.server, sessionId: session.id }))
}
const providers = useProviders(directory)
const sessionUIData = createMemo(() => ({
provider: providers.ready()
@@ -66,7 +80,7 @@ export function SessionUIProvider(
onNavigateToSession={navigateToSession}
onSessionHref={href}
>
<MarkdownProvider readImage={readImage()}>
<MarkdownProvider readImage={readImage()} openSession={openReferencedSession}>
<LocalProvider>{props.children}</LocalProvider>
</MarkdownProvider>
</DataProvider>
+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()
})
+2 -2
View File
@@ -39,8 +39,8 @@
},
"peerDependencies": {
"@opencode/theme": "workspace:*",
"@opentui/core": ">=0.5.12",
"@opentui/solid": ">=0.5.12",
"@opentui/core": ">=0.5.13",
"@opentui/solid": ">=0.5.13",
"solid-js": ">=1.9.0"
},
"peerDependenciesMeta": {
+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 = {
@@ -0,0 +1,54 @@
import { GlobalRegistrator } from "@happy-dom/global-registrator"
import { afterAll, beforeAll, expect, test } from "bun:test"
import { markSessionLinks, setupSessionLinks } from "./markdown-session-links"
const first = "ses_0123456789abcdefghijklmnop"
const second = "ses_abcdefghijklmnopqrstuvwxyz"
beforeAll(() => GlobalRegistrator.register())
afterAll(() => GlobalRegistrator.unregister())
test("links complete IDs in prose and inline code without changing fenced code or existing links", () => {
const root = document.createElement("div")
root.innerHTML = `<p>Open ${first}, then <code>${second}</code>. See <a href="/docs">${first}</a>.</p><pre><code>${first}</code></pre>`
markSessionLinks(root)
const links = root.querySelectorAll<HTMLButtonElement>("button[data-session-id]")
expect([...links].map((button) => button.dataset.sessionId)).toEqual([first, second])
expect(links[0]?.textContent).toBe(first)
expect(links[1]?.innerHTML).toBe(`<code>${second}</code>`)
expect(root.querySelector("pre button")).toBeNull()
expect(root.querySelector("a button")).toBeNull()
expect(root.textContent).toBe(`Open ${first}, then ${second}. See ${first}.${first}`)
})
test("ignores incomplete IDs, identifiers, code snippets and IDs in URLs", () => {
const root = document.createElement("div")
root.innerHTML = `<p>ses_123 and x${first} and ${first}more and <code>${first}extra</code> and <a href="https://example.com/${first}">${first}</a></p>`
markSessionLinks(root)
expect(root.querySelector("button")).toBeNull()
})
test("does not link an unparsed fenced block while the markdown worker is pending", () => {
const root = document.createElement("div")
root.innerHTML = `\`\`\`text<br>${first}<br>\`\`\``
markSessionLinks(root)
expect(root.querySelector("button")).toBeNull()
})
test("delegates keyboard-activated clicks to the latest rendered ID", () => {
const root = document.createElement("div")
const opened: string[] = []
const dispose = setupSessionLinks(root, () => (id) => opened.push(id))
root.innerHTML = `<p>${first}</p>`
markSessionLinks(root)
root.querySelector("button")?.click()
root.innerHTML = `<p><code>${second}</code></p>`
markSessionLinks(root)
root.querySelector("button")?.click()
expect(opened).toEqual([first, second])
expect(root.querySelector("button")?.getAttribute("type")).toBe("button")
dispose()
root.querySelector("button")?.click()
expect(opened).toEqual([first, second])
})
@@ -0,0 +1,61 @@
// Generated session IDs have a 26-character alphanumeric suffix. Require a complete token
// so a partially streamed ID, an identifier suffix, or a path does not become a link.
const sessionID = /(?<![A-Za-z0-9_])ses_[A-Za-z0-9]{26}(?![A-Za-z0-9_])/g
const exactSessionID = /^ses_[A-Za-z0-9]{26}$/
export function markSessionLinks(root: HTMLElement) {
if (!root.textContent?.includes("ses_")) return
// The worker's escaped-raw fallback has no markdown elements; linking its text
// would turn IDs inside still-unparsed fenced code into buttons.
if (!root.querySelector("p, li, blockquote, td, th, h1, h2, h3, h4, h5, h6")) return
root.querySelectorAll("code").forEach((code) => {
if (code.closest("pre, a, button") || !exactSessionID.test(code.textContent ?? "")) return
const link = sessionButton(code.textContent!)
code.replaceWith(link)
link.replaceChildren(code)
})
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
const nodes: Text[] = []
while (walker.nextNode()) {
const node = walker.currentNode as Text
if (node.parentElement?.closest("a, button, code, pre")) continue
if (node.data.includes("ses_")) nodes.push(node)
}
nodes.forEach((node) => {
const matches = [...node.data.matchAll(sessionID)]
if (!matches.length) return
const fragment = document.createDocumentFragment()
let start = 0
matches.forEach((match) => {
fragment.append(node.data.slice(start, match.index), sessionButton(match[0]))
start = match.index + match[0].length
})
fragment.append(node.data.slice(start))
node.replaceWith(fragment)
})
}
function sessionButton(id: string) {
const button = document.createElement("button")
button.type = "button"
button.className = "markdown-session-link"
button.dataset.sessionId = id
button.textContent = id
return button
}
export function setupSessionLinks(root: HTMLElement, open: () => ((id: string) => void) | undefined) {
const click = (event: MouseEvent) => {
if (event.defaultPrevented || !(event.target instanceof Element)) return
const button = event.target.closest("button[data-session-id]")
if (!(button instanceof HTMLButtonElement) || !root.contains(button)) return
const handler = open()
if (!handler) return
event.stopPropagation()
handler(button.dataset.sessionId!)
}
root.addEventListener("click", click)
return () => root.removeEventListener("click", click)
}
@@ -154,6 +154,26 @@
cursor: pointer;
}
.markdown-session-link {
display: inline;
padding: 0;
border: 0;
background: none;
color: var(--text-interactive-base);
font: inherit;
cursor: pointer;
}
.markdown-session-link:hover {
text-decoration: underline;
text-underline-offset: 2px;
}
.markdown-session-link:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
&[data-local-links] :not(pre) > code[data-inline-code-kind="path"]:not(a > code) {
cursor: pointer;
}
@@ -40,6 +40,7 @@ import { createMarkdownRenderer } from "./markdown-solid"
import { useMarkdown, type OpenMarkdownLocalFile, type ReadMarkdownImage } from "../context/markdown"
import { createMarkdownImages } from "./markdown-image"
import { createImagePreview } from "./image-preview"
import { markSessionLinks, setupSessionLinks } from "./markdown-session-links"
type RenderedBlock =
| (MarkdownCacheEntry & { key: string; mode: Exclude<Block["mode"], "code"> })
@@ -599,6 +600,7 @@ export function Markdown(
let copyCleanup: (() => void) | undefined
let linkCleanup: (() => void) | undefined
let faviconCleanup: (() => void) | undefined
let sessionLinkCleanup: (() => void) | undefined
let readImage: ReadMarkdownImage | undefined
let images: ReturnType<typeof createMarkdownImages> | undefined
@@ -634,7 +636,7 @@ export function Markdown(
})
activeCodeKeys.clear()
nextCodeKeys.forEach((key) => activeCodeKeys.add(key))
content.forEach((block, index) => updateBlock(container, index, block, labels))
content.forEach((block, index) => updateBlock(container, index, block, labels, !!markdown?.openSession))
while (container.children.length > content.length) {
const child = container.lastElementChild
if (!child) break
@@ -656,6 +658,7 @@ export function Markdown(
copied: i18n.t("ui.message.copied"),
}))
if (!linkCleanup) linkCleanup = setupLocalLinks(container, () => markdown?.openLocalFile)
if (!sessionLinkCleanup) sessionLinkCleanup = setupSessionLinks(container, () => markdown?.openSession)
if (!faviconCleanup) faviconCleanup = setupExternalLinkFavicons(container)
container.toggleAttribute("data-local-links", !!markdown?.openLocalFile)
if (result?.ready && result.text === local.text) container.dataset.markdownReady = ""
@@ -666,6 +669,7 @@ export function Markdown(
images?.dispose()
if (copyCleanup) copyCleanup()
if (linkCleanup) linkCleanup()
if (sessionLinkCleanup) sessionLinkCleanup()
if (faviconCleanup) faviconCleanup()
const container = root()
if (container) disposeRenderedMarkdown(container)
@@ -723,7 +727,13 @@ function disposeCode(key: string) {
disposeStreamingCode(key)
}
function updateBlock(container: HTMLDivElement, index: number, block: RenderedBlock, labels: CopyLabels) {
function updateBlock(
container: HTMLDivElement,
index: number,
block: RenderedBlock,
labels: CopyLabels,
sessionLinks: boolean,
) {
const current = container.children[index]
if (block.mode === "code") {
updateCodeBlock(container, current, block, labels)
@@ -747,6 +757,7 @@ function updateBlock(container: HTMLDivElement, index: number, block: RenderedBl
source.innerHTML = block.html
markInlineCode(source)
markCodeLinks(source)
if (sessionLinks) markSessionLinks(source)
markExternalLinkFavicons(source)
if (rendered) {
+10 -1
View File
@@ -7,11 +7,17 @@ export type OpenMarkdownLocalFile = (path: string) => void
const context = createContext<{
readonly readImage?: ReadMarkdownImage
readonly openLocalFile?: OpenMarkdownLocalFile
readonly openSession?: (sessionID: string) => void
}>()
export function MarkdownProvider(
props: ParentProps<{ readImage?: ReadMarkdownImage; openLocalFile?: OpenMarkdownLocalFile }>,
props: ParentProps<{
readImage?: ReadMarkdownImage
openLocalFile?: OpenMarkdownLocalFile
openSession?: (id: string) => void
}>,
) {
const parent = useMarkdown()
return (
<context.Provider
value={{
@@ -21,6 +27,9 @@ export function MarkdownProvider(
get openLocalFile() {
return props.openLocalFile
},
get openSession() {
return props.openSession ?? parent?.openSession
},
}}
>
{props.children}
@@ -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"/>`,