Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
a2dca5f
fix(canvas): enable hover selection before source capture finishes
Owen-Richard Oct 23, 2026
49adba2
feat(canvas): toggle annotation selection with V
Owen-Richard Oct 23, 2026
e44297d
fix(canvas): use S for annotation selection
Owen-Richard Oct 23, 2026
4c1d2c7
fix(canvas): preserve focus when toggling selection
Owen-Richard Oct 23, 2026
5cb8fab
fix(canvas): dismiss improvement conversations outside
Owen-Richard Oct 23, 2026
ab2adb4
fix(canvas): improve delete action contrast
Owen-Richard Oct 23, 2026
6d52b58
feat(canvas): submit improvement notes by shortcut
Owen-Richard Oct 23, 2026
51ee10c
refactor(canvas): title notes by target
Owen-Richard Oct 23, 2026
91a5d3c
refactor(canvas): simplify single note actions
Owen-Richard Oct 23, 2026
7705d77
fix(canvas): call improvements notes in deletion
Owen-Richard Oct 23, 2026
94d60f9
fix(canvas): simplify notes sidebar copy
Owen-Richard Oct 23, 2026
ef63731
fix(canvas): align Notes sidebar label
Owen-Richard Oct 23, 2026
85aa18f
fix(canvas): rename note picker tooltip
Owen-Richard Oct 23, 2026
68dca4d
fix(canvas): toggle Notes from count
Owen-Richard Oct 23, 2026
acf5afd
style(canvas): format note integration test
Owen-Richard Oct 23, 2026
87346e5
fix(canvas): return note dismiss to header
Owen-Richard Oct 23, 2026
e159c95
fix(canvas): add Retry action icon
Owen-Richard Oct 23, 2026
e7e77b4
fix(canvas): keep one note pin focus ring
Owen-Richard Oct 23, 2026
b084264
style(canvas): polish Save shortcut keycaps
Owen-Richard Oct 23, 2026
5535971
style(canvas): format note UI checks
Owen-Richard Oct 23, 2026
b62ecc2
fix(canvas): name Notes toggle actions
Owen-Richard Oct 23, 2026
8a31009
fix(canvas): align note accessibility labels
Owen-Richard Oct 23, 2026
4f51aab
fix(canvas): simplify note composer copy
Owen-Richard Oct 23, 2026
b7bed2d
chore(deps): update crass
Owen-Richard Oct 23, 2026
1006549
test(canvas): stabilize renderer route teardown
Owen-Richard Oct 23, 2026
c5ddc55
ci(deps): allow crass security release
Owen-Richard Oct 23, 2026
6b42230
style(canvas): center Save shortcut keycaps
Owen-Richard Oct 23, 2026
ee9e649
fix(canvas): harden annotation shortcut events
Owen-Richard Oct 23, 2026
a4d6a28
fix(canvas): scope prototype pointer dismissal
Owen-Richard Oct 23, 2026
0608ed2
fix(canvas): guard Notes counter close
Owen-Richard Oct 23, 2026
a33f139
fix(canvas): cancel pending target with S
Owen-Richard Oct 23, 2026
e547089
fix(canvas): cancel pending target on Escape
Owen-Richard Oct 23, 2026
6526e5d
fix(canvas): preserve Notes sidebar controls
Owen-Richard Oct 23, 2026
075dff3
fix(canvas): validate preview dismissal payload
Owen-Richard Oct 23, 2026
21fdf9d
fix(deps): keep dependency age strict
Owen-Richard Oct 23, 2026
06a7638
test(canvas): focus Notes regression coverage
Owen-Richard Oct 23, 2026
8c64257
merge: update branch from main
Owen-Richard Oct 23, 2026
1a05726
chore(deps): defer crass upgrade
Owen-Richard Oct 23, 2026
b45feca
Merge remote-tracking branch 'origin/main' into owen/10-22-canvas-polish
Owen-Richard Oct 23, 2026
95fc11d
Potential fix for pull request finding
Owen-Richard Oct 23, 2026
4de7113
test(canvas): cover trusted shortcut events
Owen-Richard Oct 23, 2026
207a583
build(extension): refresh shortcut assets
Owen-Richard Oct 23, 2026
64a6515
fix(canvas): require gesture for preview toggle
Owen-Richard Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion .github/extensions/replica-prototype/assets/renderer.css

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions .github/extensions/replica-prototype/assets/renderer.js

Large diffs are not rendered by default.

Comment thread
Owen-Richard marked this conversation as resolved.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,12 +1,42 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest"

import type { AnnotationShortcutModule } from "@/test/as-trusted-keyboard-event"
import { REVIEW_ANCHOR_SCHEMA } from "@utils/review-anchor/schemas"
import { installBridge } from "./index"

vi.mock("@utils/is-annotation-shortcut", async (importOriginal) => {
const actual = await importOriginal<AnnotationShortcutModule>()
const { asTrustedKeyboardEvent } = await import("@/test/as-trusted-keyboard-event")
return {
...actual,
isAnnotationShortcut(event: KeyboardEvent) {
return actual.isAnnotationShortcut(asTrustedKeyboardEvent(event))
},
}
})

const origin = "http://127.0.0.1:1234"
const installed = Symbol.for("replica.canvas.bridge.v1")

function pointerDown(
target: EventTarget,
{ button = 0, isPrimary = true }: { button?: number; isPrimary?: boolean } = {},
) {
const event = new MouseEvent("pointerdown", { bubbles: true, cancelable: true, button })
Object.defineProperty(event, "isPrimary", { value: isPrimary })
target.dispatchEvent(event)
return event
}

function request(
data: Record<string, unknown>,
requestOrigin = origin,
source: Window | null = window,
) {
window.dispatchEvent(new MessageEvent("message", { origin: requestOrigin, source, data }))
}

function isSelectionMessage(message: unknown): message is Record<string, unknown> {
return (
typeof message === "object" &&
Expand Down Expand Up @@ -68,6 +98,186 @@ function withSelection(run: (selection: ReturnType<typeof setup>) => void) {
}

describe("installBridge() improvement selection", () => {
it("requests conversation dismissal for an ordinary primary preview pointer without blocking it", () => {
const bridgeWindow = window as Window & { [installed]?: () => void }
const target = document.createElement("button")
const handled = vi.fn()
target.addEventListener("pointerdown", handled)
document.body.append(target)
const post = vi.spyOn(window, "postMessage")
installBridge(origin)
request({ type: "replica:canvas:resolve", nonce: "current", anchors: [] })
post.mockClear()
try {
const event = pointerDown(target)
expect(event.defaultPrevented).toBe(false)
expect(handled).toHaveBeenCalledOnce()
expect(post).toHaveBeenCalledExactlyOnceWith(
{
type: "replica:canvas:conversation-dismiss-requested",
nonce: "current",
restoreFocus: false,
},
origin,
)
} finally {
bridgeWindow[installed]?.()
target.remove()
}
})

it.each([
["before a resolve", "none"],
["while selection is active", "selection"],
["for a secondary pointer", "secondary"],
["for a non-primary pointer", "non-primary"],
])("does not request conversation dismissal %s", (_label, action) => {
const bridgeWindow = window as Window & { [installed]?: () => void }
const target = document.createElement("button")
document.body.append(target)
const post = vi.spyOn(window, "postMessage")
installBridge(origin)
if (action !== "none") {
request({ type: "replica:canvas:resolve", nonce: "current", anchors: [] })
if (action === "selection")
request({ type: "replica:canvas:select", nonce: "selection", mode: "improvement" })
}
post.mockClear()
try {
pointerDown(target, {
button: action === "secondary" ? 1 : 0,
isPrimary: action !== "non-primary",
})
expect(post).not.toHaveBeenCalledWith(
expect.objectContaining({ type: "replica:canvas:conversation-dismiss-requested" }),
expect.anything(),
)
} finally {
bridgeWindow[installed]?.()
target.remove()
}
})

it("clears dismissal and shortcut state when reinstalled", () => {
const bridgeWindow = window as Window & { [installed]?: () => void }
const target = document.createElement("button")
document.body.append(target)
const post = vi.spyOn(window, "postMessage")
installBridge(origin)
request({ type: "replica:canvas:resolve", nonce: "first", anchors: [] })
request({
type: "replica:canvas:request-ready",
nonce: "shortcut",
selectionShortcut: true,
})
installBridge(origin)
post.mockClear()
pointerDown(target)
const shortcut = new KeyboardEvent("keydown", {
key: "s",
bubbles: true,
cancelable: true,
})
target.dispatchEvent(shortcut)
expect(shortcut.defaultPrevented).toBe(false)
expect(post).not.toHaveBeenCalledWith(
expect.objectContaining({
type: "replica:canvas:conversation-dismiss-requested",
}),
origin,
)
expect(post).not.toHaveBeenCalledWith(
expect.objectContaining({
type: "replica:canvas:selection-toggle-requested",
}),
origin,
)

bridgeWindow[installed]?.()
target.remove()
})

it("forwards S only while opted in and uses the current shortcut request nonce", () => {
const post = vi.spyOn(window, "postMessage")
const bridgeWindow = window as Window & { [installed]?: () => void }
installBridge(origin)
function press(key = "s") {
const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true })
document.dispatchEvent(event)
return event
}
const optIn = {
type: "replica:canvas:request-ready",
nonce: "first-shortcut",
selectionShortcut: true,
}
try {
expect(press().defaultPrevented).toBe(false)
request(optIn)
post.mockClear()
expect(press().defaultPrevented).toBe(true)
expect(post).toHaveBeenCalledExactlyOnceWith(
{ type: "replica:canvas:selection-toggle-requested", nonce: "first-shortcut" },
origin,
)

request({ ...optIn, nonce: "current-shortcut" })
request({ ...optIn, selectionShortcut: false })
post.mockClear()
expect(press("S").defaultPrevented).toBe(true)
expect(post).toHaveBeenCalledExactlyOnceWith(
{ type: "replica:canvas:selection-toggle-requested", nonce: "current-shortcut" },
origin,
)
request({ ...optIn, nonce: "current-shortcut", selectionShortcut: false })
post.mockClear()
expect(press().defaultPrevented).toBe(false)
expect(post).not.toHaveBeenCalled()
} finally {
bridgeWindow[installed]?.()
}
})

it("respects a shortcut handled by the prototype", () => {
const post = vi.spyOn(window, "postMessage")
const bridgeWindow = window as Window & { [installed]?: () => void }
const target = document.createElement("button")
document.body.append(target)
installBridge(origin)
window.dispatchEvent(
new MessageEvent("message", {
origin,
source: window,
data: {
type: "replica:canvas:request-ready",
nonce: "shortcut",
selectionShortcut: true,
},
}),
)
function handledByPrototype(event: KeyboardEvent) {
event.preventDefault()
}
target.addEventListener("keydown", handledByPrototype)
post.mockClear()
try {
const shortcut = new KeyboardEvent("keydown", {
key: "s",
bubbles: true,
cancelable: true,
})
target.dispatchEvent(shortcut)
expect(shortcut.defaultPrevented).toBe(true)
expect(post).not.toHaveBeenCalledWith(
expect.objectContaining({ type: "replica:canvas:selection-toggle-requested" }),
expect.anything(),
)
} finally {
bridgeWindow[installed]?.()
target.remove()
}
})

it("picks an element without activating its underlying control", () => {
withSelection(({ target, pointer, anchor }) => {
const activated = vi.fn()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { installControlsBridge } from "./controls"
import { targetIssue } from "@utils/prototype-controls/dom-target.mjs"
import { isAnnotationShortcut } from "@utils/is-annotation-shortcut"

type ReviewRoute = { pathname: string; search: string; hash: string }
type AnchorRect = { x: number; y: number; width: number; height: number }
Expand Down Expand Up @@ -40,6 +41,7 @@ type ElementMatch = Pick<ElementAnchor, "selector" | "accessibleName" | "tagName
const BRIDGE_ATTRIBUTE = "data-replica-canvas-bridge"
const TEXT_ENCODER = new TextEncoder()
let parentOrigin: string | null = null
let selectionShortcutNonce: string | null = null
let cleanupActiveSelection: (() => void) | null = null
let selectedOverlay: HTMLElement | null = null
let selectedOverlayTarget: Element | null = null
Expand Down Expand Up @@ -740,6 +742,9 @@ function receiveCanvasMessage(event: MessageEvent<unknown>) {
}

if (data.type === "replica:canvas:request-ready") {
if (data.selectionShortcut === true) selectionShortcutNonce = data.nonce
else if (data.selectionShortcut === false && selectionShortcutNonce === data.nonce)
selectionShortcutNonce = null
post(event.origin, {
type: "replica:canvas:ready",
protocolVersion: 1,
Expand Down Expand Up @@ -870,6 +875,7 @@ export function installBridge(origin: string) {
if (typeof previous === "function") previous()
selectionAccent = "light-dark(#0969da, #58a6ff)"
parentOrigin = origin
selectionShortcutNonce = null
resolvedRequest = null
resolvedRoute = ""
resolvedGeometry.clear()
Expand Down Expand Up @@ -926,10 +932,35 @@ export function installBridge(origin: string) {
})
}, 0)
}
function handlePointerDown(event: PointerEvent) {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.isPrimary === false ||
cleanupActiveSelection ||
!resolvedRequest
)
return
post(origin, {
type: "replica:canvas:conversation-dismiss-requested",
nonce: resolvedRequest.nonce,
restoreFocus: false,
})
Comment thread
Owen-Richard marked this conversation as resolved.
}
function handleSelectionShortcut(event: KeyboardEvent) {
if (!selectionShortcutNonce || !isAnnotationShortcut(event)) return
event.preventDefault()
post(origin, {
type: "replica:canvas:selection-toggle-requested",
nonce: selectionShortcutNonce,
})
}
window.addEventListener("message", receiveCanvasMessage)
window.addEventListener("scroll", handleScroll, { capture: true, passive: true })
window.addEventListener("resize", handleResize)
window.addEventListener("keydown", handleEscape)
window.addEventListener("keydown", handleSelectionShortcut)
window.addEventListener("pointerdown", handlePointerDown, true)
const mutations = new MutationObserver((entries) => {
if (
entries.some(
Expand All @@ -955,6 +986,9 @@ export function installBridge(origin: string) {
window.removeEventListener("scroll", handleScroll, true)
window.removeEventListener("resize", handleResize)
window.removeEventListener("keydown", handleEscape)
window.removeEventListener("keydown", handleSelectionShortcut)
window.removeEventListener("pointerdown", handlePointerDown, true)
selectionShortcutNonce = null
mutations.disconnect()
resize?.disconnect()
if (geometryFrame !== null) window.cancelAnimationFrame(geometryFrame)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,10 @@ describe("PrototypeCanvasHubPanel()", () => {
expect(toggle).toHaveAttribute("aria-expanded", String(commentsOpen))
receive({ type: "replica:canvas:conversation-dismiss-requested", nonce: "wrong-frame" })
expect(dialog).toBeInTheDocument()
act(() => frameRef.current!.focus())
receive({ type: "replica:canvas:conversation-dismiss-requested", restoreFocus: false })
expect(dialog).toBeInTheDocument()
expect(frameRef.current).toHaveFocus()
receive({ type: "replica:canvas:conversation-dismiss-requested" })
expect(dialog).not.toBeInTheDocument()
expect(toggle).toHaveAttribute("aria-expanded", String(commentsOpen))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -690,7 +690,11 @@ export function PrototypeCanvasHubPanel({
}
if (incompatible) return
if (data.type === "replica:canvas:conversation-dismiss-requested") {
window.dispatchEvent(new Event("replica:review-dismiss"))
window.dispatchEvent(
new CustomEvent("replica:review-dismiss", {
detail: { restoreFocus: data.restoreFocus !== false },
}),
)
return
}
if (data.type === "replica:canvas:resolved") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,20 @@ export const copy = {
sourceUnavailable:
"You can still save this improvement locally, but it cannot sync without a captured source.",
annotate: "Pick & polish",
openNotes: (count: number) => `Open improvement notes (${count})`,
notes: "Pick & polish",
annotateShortcut: "Add note",
openNotes: (count: number) => `Open Notes (${count})`,
closeNotes: (count: number) => `Close Notes (${count})`,
notes: "Notes",
selectHint:
"Click an element or drag a rectangle. For a keyboard region, focus the preview, press Shift+Enter, then use arrows and Enter to choose two corners. Escape exits annotation mode.",
"Hover to highlight, then click an element or drag a rectangle. S toggles selection. For a keyboard region, focus the preview, press Shift+Enter, then use arrows and Enter to choose two corners. Escape exits annotation mode.",
regionSmall: "Choose a larger region, at least 8 preview pixels wide and high.",
add: "Add improvement note",
add: "Add note",
label: "What should change?",
placeholder: "Describe the improvement you want Copilot to make...",
save: "Save note",
placeholder: "Describe what you want Copilot to change...",
save: "Save",
saveShortcutMac: "⌘",
saveShortcutOther: "Ctrl",
saveShortcutEnter: "↵",
cancel: "Cancel",
discard: "Discard draft",
keep: "Keep writing",
Expand Down
Loading
Loading