diff --git a/bun.lock b/bun.lock index a70dc438c21..4279ceda10a 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index 31e407aba73..27b03ec2bd8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/app/component-tests/browser-pane.fixture.tsx b/packages/app/component-tests/browser-pane.fixture.tsx index d52265cdca4..89982f8e1f8 100644 --- a/packages/app/component-tests/browser-pane.fixture.tsx +++ b/packages/app/component-tests/browser-pane.fixture.tsx @@ -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, + 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) => void>() + const emitInspect = (event: Extract) => + 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 ( <>

Browser pane lifecycle

@@ -137,13 +168,30 @@ export function mountBrowserPane() { +

Captures: {store.captures}

+

Picker: {store.picker[store.session] ? "on" : "off"}

+

Highlights: {store.highlights.join(",")}

- + setStore("comments", (items) => [...items, comment])} + />
+
    + + {(comment) => ( +
  • + {comment.element.label} {comment.element.ref ? `@${comment.element.ref}` : "(no ref)"}:{" "} + {comment.comment} +
  • + )} +
    +
{/* Floating content portals into like a menu or hover card over the page. */} diff --git a/packages/app/component-tests/browser-pane.spec.ts b/packages/app/component-tests/browser-pane.spec.ts index f8f434c57d9..bcd534d1da4 100644 --- a/packages/app/component-tests/browser-pane.spec.ts +++ b/packages/app/component-tests/browser-pane.spec.ts @@ -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() diff --git a/packages/app/e2e/regression/session-id-links.spec.ts b/packages/app/e2e/regression/session-id-links.spec.ts new file mode 100644 index 00000000000..80b456416e2 --- /dev/null +++ b/packages/app/e2e/regression/session-id-links.spec.ts @@ -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$/) +}) diff --git a/packages/app/src/composer/adapter.ts b/packages/app/src/composer/adapter.ts index cadfcf0462b..430580ebe2a 100644 --- a/packages/app/src/composer/adapter.ts +++ b/packages/app/src/composer/adapter.ts @@ -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 + } } } diff --git a/packages/app/src/composer/comment-note.test.ts b/packages/app/src/composer/comment-note.test.ts new file mode 100644 index 00000000000..b9068fab8fb --- /dev/null +++ b/packages/app/src/composer/comment-note.test.ts @@ -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), + }) + }) +}) diff --git a/packages/app/src/composer/comment-note.ts b/packages/app/src/composer/comment-note.ts index b4058919584..c2231858de0 100644 --- a/packages/app/src/composer/comment-note.ts +++ b/packages/app/src/composer/comment-note.ts @@ -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 diff --git a/packages/app/src/composer/composer.stories.tsx b/packages/app/src/composer/composer.stories.tsx index 492b30f1bc8..578fd1bc021 100644 --- a/packages/app/src/composer/composer.stories.tsx +++ b/packages/app/src/composer/composer.stories.tsx @@ -279,6 +279,39 @@ export const MixedAttachments = { ), } +export const BrowserElementComment = { + render: () => ( + 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: () => } export const SlashSuggestions = { render: () => } diff --git a/packages/app/src/composer/editor/editor.tsx b/packages/app/src/composer/editor/editor.tsx index bec7da700e2..83a8899747d 100644 --- a/packages/app/src/composer/editor/editor.tsx +++ b/packages/app/src/composer/editor/editor.tsx @@ -533,8 +533,11 @@ export function ComposerAttachments(props: { > props.onCommentClick?.(comment)} /> diff --git a/packages/app/src/composer/model.ts b/packages/app/src/composer/model.ts index 30979df177e..65e1196c22d 100644 --- a/packages/app/src/composer/model.ts +++ b/packages/app/src/composer/model.ts @@ -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) { diff --git a/packages/app/src/composer/persistence.tsx b/packages/app/src/composer/persistence.tsx index 3e47ce46f3a..eee07b2e945 100644 --- a/packages/app/src/composer/persistence.tsx +++ b/packages/app/src/composer/persistence.tsx @@ -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 & { 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(), diff --git a/packages/app/src/composer/request.test.ts b/packages/app/src/composer/request.test.ts index 879989d7ae8..7313ce1a86c 100644 --- a/packages/app/src/composer/request.test.ts +++ b/packages/app/src/composer/request.test.ts @@ -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 }] diff --git a/packages/app/src/composer/request.ts b/packages/app/src/composer/request.ts index b9c6f23e161..5976ca78eb7 100644 --- a/packages/app/src/composer/request.ts +++ b/packages/app/src/composer/request.ts @@ -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 & { 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], diff --git a/packages/app/src/composer/schema.test.ts b/packages/app/src/composer/schema.test.ts index a0a41807584..488fe983650 100644 --- a/packages/app/src/composer/schema.test.ts +++ b/packages/app/src/composer/schema.test.ts @@ -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: [] } }), diff --git a/packages/app/src/composer/schema.ts b/packages/app/src/composer/schema.ts index 6738a7882ae..e8989480218 100644 --- a/packages/app/src/composer/schema.ts +++ b/packages/app/src/composer/schema.ts @@ -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 }] diff --git a/packages/app/src/composer/selection.ts b/packages/app/src/composer/selection.ts index d342581d939..b4e2a848d66 100644 --- a/packages/app/src/composer/selection.ts +++ b/packages/app/src/composer/selection.ts @@ -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; model?: ModelSelection }) { +export function createComposerControls(input: { + sessionKey: Accessor + 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; mo session: { tabs: layout.tabs(input.sessionKey), reviewPanel: view.reviewPanel, + browser: input.browser, }, } }) diff --git a/packages/app/src/composer/state.ts b/packages/app/src/composer/state.ts index 8865f209f2e..c24daddda45 100644 --- a/packages/app/src/composer/state.ts +++ b/packages/app/src/composer/state.ts @@ -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) { @@ -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) })), diff --git a/packages/app/src/composer/submit.ts b/packages/app/src/composer/submit.ts index f327aa6a0f1..0add43eae67 100644 --- a/packages/app/src/composer/submit.ts +++ b/packages/app/src/composer/submit.ts @@ -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) { diff --git a/packages/app/src/desktop.ts b/packages/app/src/desktop.ts index 1f79856633e..31d607ac9ce 100644 --- a/packages/app/src/desktop.ts +++ b/packages/app/src/desktop.ts @@ -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, diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 05e0ab19ef2..ebe602f6014 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -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; } diff --git a/packages/app/src/new-session/project/controller.ts b/packages/app/src/new-session/project/controller.ts index cf803202167..f3a9cc9d0d0 100644 --- a/packages/app/src/new-session/project/controller.ts +++ b/packages/app/src/new-session/project/controller.ts @@ -62,6 +62,7 @@ export function createComposerProjectControls(props: { draftId: string; worktree return createMemo(() => ({ available: projects(), directory: location().directory, + projectID: location().current?.project.id, server: servers.list.length > 1 ? ServerConnection.key(projectServer()) : undefined, select: selectProject, add: addProject, diff --git a/packages/app/src/new-session/project/selector.test.tsx b/packages/app/src/new-session/project/selector.test.tsx new file mode 100644 index 00000000000..952bdc2ea0d --- /dev/null +++ b/packages/app/src/new-session/project/selector.test.tsx @@ -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() + }) + }) +}) diff --git a/packages/app/src/new-session/project/selector.tsx b/packages/app/src/new-session/project/selector.tsx index 99e207ed33e..0845fb12889 100644 --- a/packages/app/src/new-session/project/selector.tsx +++ b/packages/app/src/new-session/project/selector.tsx @@ -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 = () => { diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 5a726a7b59b..2dc85a779ce 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -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", diff --git a/packages/app/src/runtime/platform/browser-pane.ts b/packages/app/src/runtime/platform/browser-pane.ts index 75505affbce..2faa0dbeb3c 100644 --- a/packages/app/src/runtime/platform/browser-pane.ts +++ b/packages/app/src/runtime/platform/browser-pane.ts @@ -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 /** Captures the shown page, or resolves null when nothing is on screen. */ capture(tabID: Browser.TabID): Promise + /** 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 } diff --git a/packages/app/src/session/browser/attachments.ts b/packages/app/src/session/browser/attachments.ts index 9cdd14701e3..cd78d183aff 100644 --- a/packages/app/src/session/browser/attachments.ts +++ b/packages/app/src/session/browser/attachments.ts @@ -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 +type InspectEvent = Extract export type BrowserAttachment = BrowserConnectionState @@ -42,6 +44,7 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider const [unsupported, setUnsupported] = createStore>({}) const live = new Map() const preview = new Map void>>() + const inspect = new Map 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( + listeners: Map 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) + } +} diff --git a/packages/app/src/session/browser/connection.test.ts b/packages/app/src/session/browser/connection.test.ts index 028be2e5b56..7a89313756f 100644 --- a/packages/app/src/session/browser/connection.test.ts +++ b/packages/app/src/session/browser/connection.test.ts @@ -29,11 +29,14 @@ function fixture() { }[] = [] const endpoint = { url: "http://localhost:4096" } const previews: string[] = [] + const inspections: Extract[] = [] + 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 { diff --git a/packages/app/src/session/browser/connection.ts b/packages/app/src/session/browser/connection.ts index 267a0b8f79b..9a8c90bd1f3 100644 --- a/packages/app/src/session/browser/connection.ts +++ b/packages/app/src/session/browser/connection.ts @@ -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) => 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) diff --git a/packages/app/src/session/browser/model.ts b/packages/app/src/session/browser/model.ts index 6a4d37e8f01..aa71553695c 100644 --- a/packages/app/src/session/browser/model.ts +++ b/packages/app/src/session/browser/model.ts @@ -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) => 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) +} diff --git a/packages/app/src/session/browser/pane.tsx b/packages/app/src/session/browser/pane.tsx index f7f16f857c6..4e0f066f172 100644 --- a/packages/app/src/session/browser/pane.tsx +++ b/packages/app/src/session/browser/pane.tsx @@ -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; 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 & { ref?: Browser.Ref } + comment: string +} + +export function SessionBrowserPane(props: { + browser: ReturnType + 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