Skip to content

Commit e3b2ddd

Browse files
angusbezzinaclaude
andcommitted
Overlay UX round 2 (cli-dwegp): selection through expanded overlay, persistent pill + copy/export, launch activation
Fixes the three issues from the second live demo test, each root-caused empirically with a new full-window overlay harness (AnnotKitOverlayProbe): - Element selection (issue 2): when the overlay panel expands to the host's full frame it is a live AXWindow that AXUIElementCopyElementAtPosition resolves to (its own hosting view), so every hover/click resolved to the whole app. Fix: OverlayController tags its panel with an AX identifier (com.annotkit.overlay-window); AXIntrospection filters overlay windows out of snapshot() and, when the native point query lands on the overlay, discards it and hit-tests BENEATH by descending the frontmost non-overlay window geometrically. Verified through the expanded overlay for button / text field / toggle / list row - each resolves to its own control, none to a window. - Persistent pill + copy/export (issue 1): notes PERSIST until Clear (addNote appends; export()/copy never clear; only clear() empties); NotesFileSink overwrites the full set so re-export is idempotent; the pill's entrance animation gate is removed so it renders unconditionally across modes. - Launch activation (issue 3): the demo activated before app.run() (before any window existed) so the window server dropped it; now activates in applicationDidFinishLaunching with a bounded retry. New AnnotKitOverlayProbe mounts install()+start() to EXPAND the panel and asserts per-control resolution through it - the expanded path the idle probe never covered, which is what let issue 2 ship. Build clean (Swift 6); 44 tests; AnnotKitProbe + AnnotKitOverlayProbe pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 82f9dcf commit e3b2ddd

14 files changed

Lines changed: 953 additions & 75 deletions

File tree

Package.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,20 @@ let package = Package(
7272
swiftSettings: [
7373
.swiftLanguageMode(.v6)
7474
]
75+
),
76+
// Off-screen overlay diagnostic harness. Unlike AnnotKitProbe (which only
77+
// exercises the IDLE corner panel), this one calls `Annotation.install()`
78+
// then `Annotation.start()` so the child panel EXPANDS to the full host
79+
// window, then inspects the AX snapshot and the raw
80+
// `AXUIElementCopyElementAtPosition` result THROUGH the expanded overlay.
81+
// It exists to confirm/refute the "expanded panel shadows the host in the
82+
// AX point query" hypothesis (issue 2). Reusable regression asset.
83+
.executableTarget(
84+
name: "AnnotKitOverlayProbe",
85+
dependencies: ["AnnotKit"],
86+
swiftSettings: [
87+
.swiftLanguageMode(.v6)
88+
]
7589
)
7690
]
7791
)

Sources/AnnotKit/Overlay/AnnotationSession.swift

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import CoreGraphics
33
import Foundation
44

55
/// The platform-independent heart of the overlay: tracks annotate mode, the
6-
/// hovered and selected elements, and the list of pending notes, and turns a
7-
/// selected element plus a comment into an ``AnnotationNote`` that it flushes to
8-
/// the sink. The macOS/iOS overlay hosts drive this; it owns no UI, so it is
6+
/// hovered and selected elements, and the retained list of captured notes, and
7+
/// turns a selected element plus a comment into an ``AnnotationNote`` that it
8+
/// appends to that list. Notes PERSIST until ``clear()`` — capturing, copying,
9+
/// and exporting never drop them — so the same set can be copied and exported
10+
/// repeatedly. The macOS/iOS overlay hosts drive this; it owns no UI, so it is
911
/// unit-testable without a window.
1012
@MainActor
1113
public final class AnnotationSession: ObservableObject {
@@ -15,6 +17,9 @@ public final class AnnotationSession: ObservableObject {
1517
}
1618

1719
@Published public private(set) var mode: Mode = .idle
20+
/// The retained set of captured notes. Grows via ``addNote(comment:selectedText:screenshot:)``
21+
/// and is emptied ONLY by ``clear()`` — ``export()`` and copy read it without
22+
/// mutating it, so the same set survives repeated copy/export.
1823
@Published public private(set) var pending: [AnnotationNote] = []
1924
@Published public private(set) var hovered: Element?
2025
@Published public private(set) var selected: Element?
@@ -102,10 +107,13 @@ public final class AnnotationSession: ObservableObject {
102107
return note
103108
}
104109

105-
/// Flush pending notes to the sink and clear them.
106-
public func flush() throws {
110+
/// Write the full retained set to the sink, WITHOUT clearing it. Notes
111+
/// persist until ``clear()``, so the same set can be exported repeatedly (and
112+
/// also copied). The file sink overwrites its file with the current set, so
113+
/// re-exporting after more notes were captured replaces it with the full set —
114+
/// idempotent, no duplicates.
115+
public func export() throws {
107116
try sink.flush(pending)
108-
pending.removeAll()
109117
}
110118

111119
public func clear() {

Sources/AnnotKit/Overlay/OverlayView.swift

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,12 @@ struct OverlayView: View {
2525
/// Window-local size of this surface, used to clamp the composer on-screen.
2626
let surfaceSize: CGSize
2727
let onToggle: () -> Void
28-
/// Copy pending notes to the pasteboard (host wires ``ClipboardSink``).
28+
/// Copy the retained notes to the pasteboard as markdown (host wires
29+
/// ``ClipboardSink``). Non-clearing.
2930
let onCopy: () -> Void
30-
let onFlush: () -> Void
31+
/// Export the retained notes to `AGENTATION_NOTES.md` (host wires
32+
/// ``NotesFileSink``). Non-clearing and idempotent.
33+
let onExport: () -> Void
3134
/// Dismiss the whole overlay (macOS -> `unmount()`, iOS -> hide the window).
3235
let onClose: () -> Void
3336

@@ -157,7 +160,7 @@ struct OverlayView: View {
157160
session: session,
158161
onToggle: onToggle,
159162
onCopy: onCopy,
160-
onFlush: onFlush,
163+
onExport: onExport,
161164
onClose: onClose
162165
)
163166
.padding(20)
@@ -171,20 +174,25 @@ struct OverlayView: View {
171174
/// capability — no dead buttons (CLAUDE.md) — so Agentation's `settings`/`eye`
172175
/// (no settings model, no preview capability here) are deliberately omitted.
173176
///
174-
/// Left to right: annotate toggle (always), then — only while notes are pending —
175-
/// a count badge, copy, save, and a destructive clear, then a divider and a
176-
/// close/exit (always). Copy and save flow through host callbacks (they need a
177-
/// sink); toggle needs the controller (activation); clear reads/writes the
178-
/// session directly.
177+
/// Left to right: annotate toggle (always), then — only while notes exist — a
178+
/// count badge, then two DISTINCT persist actions (Copy to the clipboard as
179+
/// markdown, and Export to `AGENTATION_NOTES.md`), and a destructive clear, then
180+
/// a divider and a close/exit (always). Copy and Export never clear the retained
181+
/// set (only Clear does), so the same notes can be both copied and exported.
182+
/// Copy/Export flow through host callbacks (they need a sink); toggle needs the
183+
/// controller (activation); clear reads/writes the session directly.
184+
///
185+
/// The pill itself is rendered unconditionally and is NEVER gated on an entrance
186+
/// flag, so it stays visible across idle<->annotate and before/during/after
187+
/// capturing a note; only the note-action cluster animates in and out.
179188
private struct ToolbarView: View {
180189
@ObservedObject var session: AnnotationSession
181190
let onToggle: () -> Void
182191
let onCopy: () -> Void
183-
let onFlush: () -> Void
192+
let onExport: () -> Void
184193
let onClose: () -> Void
185194

186195
@Environment(\.accessibilityReduceMotion) private var reduceMotion
187-
@State private var appeared = false
188196

189197
private var annotating: Bool { session.mode == .annotating }
190198
private var hasNotes: Bool { !session.pending.isEmpty }
@@ -200,8 +208,8 @@ private struct ToolbarView: View {
200208

201209
if hasNotes {
202210
countBadge
203-
PillButton(icon: .copy, tooltip: "Copy notes", action: onCopy)
204-
PillButton(icon: .check, tooltip: "Save notes", action: onFlush)
211+
PillButton(icon: .copy, tooltip: "Copy notes (Markdown)", action: onCopy)
212+
PillButton(icon: .download, tooltip: "Export to AGENTATION_NOTES.md", action: onExport)
205213
PillButton(icon: .trash, isDestructive: true, tooltip: "Clear notes") {
206214
session.clear()
207215
}
@@ -218,13 +226,8 @@ private struct ToolbarView: View {
218226
.overlay(Capsule(style: .continuous).strokeBorder(PillStyle.border, lineWidth: 1))
219227
)
220228
.shadow(color: .black.opacity(0.4), radius: 12, y: 8)
221-
.opacity(appeared ? 1 : 0)
222-
.scaleEffect(appeared ? 1 : 0.96)
223-
.onAppear {
224-
guard !reduceMotion else { appeared = true; return }
225-
withAnimation(.easeOut(duration: 0.18)) { appeared = true }
226-
}
227-
// Reveal/hide the note-action cluster smoothly as pending changes.
229+
// Reveal/hide the note-action cluster smoothly as pending changes. The
230+
// pill has no entrance opacity/scale gate: it must ALWAYS be visible.
228231
.animation(reduceMotion ? nil : .easeOut(duration: 0.15), value: hasNotes)
229232
}
230233

Sources/AnnotKit/Overlay/PillStyle.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,18 @@ struct LucideIcon {
7777

7878
static let check = LucideIcon(parts: [.path("M20 6 9 17l-5-5")])
7979

80+
/// Lucide `download` — export to a file. Drawn with straight strokes only
81+
/// (the real glyph's rounded tray uses SVG arc commands the primitive parser
82+
/// does not implement): an open-top tray plus a down arrow into it.
83+
static let download = LucideIcon(parts: [
84+
.line(CGPoint(x: 4, y: 15), CGPoint(x: 4, y: 20)),
85+
.line(CGPoint(x: 4, y: 20), CGPoint(x: 20, y: 20)),
86+
.line(CGPoint(x: 20, y: 20), CGPoint(x: 20, y: 15)),
87+
.line(CGPoint(x: 12, y: 3), CGPoint(x: 12, y: 15)),
88+
.line(CGPoint(x: 7, y: 10), CGPoint(x: 12, y: 15)),
89+
.line(CGPoint(x: 17, y: 10), CGPoint(x: 12, y: 15)),
90+
])
91+
8092
static let copy = LucideIcon(parts: [
8193
.rrect(CGRect(x: 8, y: 8, width: 14, height: 14), 2),
8294
.path("M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"),
Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import Foundation
22

3-
/// Appends notes to an `AGENTATION_NOTES.md` file (created with a header if
4-
/// absent), in the format the `process-agentation-notes` skill reads. This is
5-
/// the primary sink: it hands the agent loop a file it already knows how to
6-
/// process. The native source-location adaptation (resolving the AX selector to
7-
/// Swift `accessibilityIdentifier` call sites) is tracked in F4.2.
3+
/// Writes the full set of notes to an `AGENTATION_NOTES.md` file, OVERWRITING
4+
/// any previous contents, in the format the `process-agentation-notes` skill
5+
/// reads. This is the primary sink: it hands the agent loop a file it already
6+
/// knows how to process. Overwriting (rather than appending) is what makes
7+
/// export idempotent — the caller (``AnnotationSession``) retains the whole set
8+
/// and re-exports it, so re-writing after more notes were captured replaces the
9+
/// file with the current full set: no duplicates, no stale notes. The native
10+
/// source-location adaptation (resolving the AX selector to Swift
11+
/// `accessibilityIdentifier` call sites) is tracked in F4.2.
812
public struct NotesFileSink: AnnotationSink {
913
/// Destination path. Defaults to `AGENTATION_NOTES.md` in the working
1014
/// directory, matching the skill's expectation.
@@ -16,10 +20,6 @@ public struct NotesFileSink: AnnotationSink {
1620

1721
public func flush(_ notes: [AnnotationNote]) throws {
1822
guard !notes.isEmpty else { return }
19-
let existing = (try? String(contentsOfFile: path, encoding: .utf8)) ?? "# Agentation Notes\n"
20-
let blocks = notes
21-
.map { "\n---\n\n" + AnnotationFormatter.markdownBlock($0) + "\n" }
22-
.joined()
23-
try (existing + blocks).write(toFile: path, atomically: true, encoding: .utf8)
23+
try AnnotationFormatter.markdown(notes).write(toFile: path, atomically: true, encoding: .utf8)
2424
}
2525
}

Sources/AnnotKit/iOS/IOSOverlayController.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ public final class IOSOverlayController: NSObject {
5151
surfaceSize: proxy.size,
5252
onToggle: { [weak self] in self?.toggle() },
5353
onCopy: { [weak self] in self?.copy() },
54-
onFlush: { [weak self] in self?.flush() },
54+
onExport: { [weak self] in self?.export() },
5555
onClose: { [weak self] in self?.unmount() }
5656
)
5757
}
@@ -85,8 +85,8 @@ public final class IOSOverlayController: NSObject {
8585
window?.captureTouches = false
8686
}
8787

88-
private func flush() {
89-
try? session.flush()
88+
private func export() {
89+
try? session.export()
9090
}
9191

9292
private func copy() {

Sources/AnnotKit/macOS/AXIntrospection.swift

Lines changed: 106 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,16 @@ final class AXElementNode: SelectorMatchable {
8080
enum AXIntrospection {
8181
static let maxDepth = 200
8282

83+
/// AX identifier that ``OverlayController`` stamps on its overlay panel
84+
/// window (`panel.setAccessibilityIdentifier(_:)`). The point query and the
85+
/// snapshot both skip any window carrying it, so our own overlay never
86+
/// shadows the host. This is required in addition to the SwiftUI content
87+
/// being `accessibilityHidden`: when the panel expands to the host's full
88+
/// frame it is a live `AXWindow` that `AXUIElementCopyElementAtPosition`
89+
/// hit-tests first, so without excluding the panel WINDOW every hover/click
90+
/// resolves to the overlay's own hosting view instead of the control beneath.
91+
static let overlayWindowIdentifier = "com.annotkit.overlay-window"
92+
8393
private static let actionableRoles: Set<String> = [
8494
"AXButton", "AXLink", "AXCheckBox", "AXRadioButton",
8595
"AXPopUpButton", "AXMenuButton", "AXMenuItem", "AXSlider"
@@ -98,12 +108,36 @@ enum AXIntrospection {
98108

99109
// MARK: - Snapshot
100110

101-
/// Snapshot every window of the host app as an ``AXElementNode`` tree.
111+
/// Snapshot every window of the host app as an ``AXElementNode`` tree,
112+
/// excluding our own overlay panel window(s) so the overlay never appears as
113+
/// a phantom window shadowing the host.
102114
static func snapshotNodes() -> [AXElementNode] {
103115
let app = appElement()
104-
return elementArray(app, kAXWindowsAttribute).map { window in
105-
buildNode(window, selfComponent: component(for: window, indexAmongRole: 0), parentPath: [], depth: 0)
116+
return elementArray(app, kAXWindowsAttribute)
117+
.filter { !isOverlayWindow($0) }
118+
.map { window in
119+
buildNode(window, selfComponent: component(for: window, indexAmongRole: 0), parentPath: [], depth: 0)
120+
}
121+
}
122+
123+
/// True when `window` is one of our own overlay panels, tagged by
124+
/// ``OverlayController`` with ``overlayWindowIdentifier``.
125+
private static func isOverlayWindow(_ window: AXUIElement) -> Bool {
126+
string(window, kAXIdentifierAttribute) == overlayWindowIdentifier
127+
}
128+
129+
/// True when `element` lives inside one of our overlay panel windows. Used to
130+
/// reject a point-query hit that resolved into the overlay so we never return
131+
/// the overlay's own hosting view instead of the host control.
132+
private static func belongsToOverlayWindow(_ element: AXUIElement) -> Bool {
133+
var current: AXUIElement? = element
134+
var depth = 0
135+
while let node = current, depth < maxDepth {
136+
if string(node, kAXRoleAttribute) == "AXWindow" { return isOverlayWindow(node) }
137+
current = copyValue(node, kAXParentAttribute).map { unsafeDowncast($0, to: AXUIElement.self) }
138+
depth += 1
106139
}
140+
return false
107141
}
108142

109143
/// Public snapshot in terms of ``WindowSnapshot``.
@@ -188,22 +222,70 @@ enum AXIntrospection {
188222
/// Resolve a screen point (AX top-left coordinates) to an annotation target.
189223
/// Uses the native `AXUIElementCopyElementAtPosition` for the deepest
190224
/// element, then walks up to the nearest ancestor carrying a stable identity
191-
/// (identifier or label), per docs/spike-ax-pointquery.md. The overlay window
192-
/// must be excluded by the caller (marked non-accessibility) so it never
193-
/// resolves to itself.
225+
/// (identifier or label), per docs/spike-ax-pointquery.md.
226+
///
227+
/// The native app-level query is the fast path: when it lands on a real host
228+
/// element (idle corner overlay, or any point the overlay does not cover) it
229+
/// is the most accurate hit-test, so it is kept. But the expanded overlay is
230+
/// a full-window `AXWindow` sitting above the host, so while annotating the
231+
/// native query hits the overlay's own hosting-view group instead of the
232+
/// control beneath — that is the "everything resolves to the whole app" bug.
233+
/// When the native hit belongs to our overlay we discard it and resolve the
234+
/// point by descending the frontmost non-overlay window's AX subtree directly
235+
/// (``hitBeneathOverlay(_:)``), which is what "queries beneath" the overlay.
236+
/// (`AXUIElementCopyElementAtPosition` only hit-tests when given the
237+
/// application element, so we cannot simply re-target it at the host window.)
194238
static func hitTest(_ point: CGPoint) -> Element? {
195239
let app = appElement()
196240
var hit: AXUIElement?
197-
guard AXUIElementCopyElementAtPosition(app, Float(point.x), Float(point.y), &hit) == .success,
198-
let deepest = hit
199-
else { return nil }
241+
let deepest: AXUIElement
242+
if AXUIElementCopyElementAtPosition(app, Float(point.x), Float(point.y), &hit) == .success,
243+
let native = hit, !belongsToOverlayWindow(native) {
244+
deepest = native
245+
} else if let beneath = hitBeneathOverlay(point) {
246+
deepest = beneath
247+
} else {
248+
return nil
249+
}
200250

201251
let chain = ancestorChain(from: deepest)
202252
guard !chain.isEmpty else { return nil }
203-
let target = nearestIdentified(in: chain) ?? deepest
253+
// Never fall back to the window or application container: escalating to
254+
// AXWindow is what made a background click resolve to the whole app.
255+
guard let target = nearestIdentified(in: chain) ?? deepestNonContainer(in: chain) else {
256+
return nil
257+
}
204258
return element(for: target, ancestorChain: chain)
205259
}
206260

261+
/// Geometric hit-test beneath the overlay. The native point query cannot see
262+
/// past our own full-window overlay panel, so descend the frontmost
263+
/// non-overlay window whose frame contains `point` to the deepest descendant
264+
/// that still contains it, walking `kAXChildren` by frame. `kAXWindows` is
265+
/// front-to-back, so the first matching window is the frontmost real target.
266+
private static func hitBeneathOverlay(_ point: CGPoint) -> AXUIElement? {
267+
let app = appElement()
268+
let windows = elementArray(app, kAXWindowsAttribute).filter { !isOverlayWindow($0) }
269+
guard let window = windows.first(where: { frameScreen(of: $0).contains(point) }) else {
270+
return nil
271+
}
272+
return deepestChild(of: window, containing: point, depth: 0)
273+
}
274+
275+
/// Deepest descendant of `element` whose (non-empty) frame contains `point`.
276+
/// Later children are drawn on top, so a topmost match wins; returns
277+
/// `element` itself when no child contains the point.
278+
private static func deepestChild(of element: AXUIElement, containing point: CGPoint, depth: Int) -> AXUIElement {
279+
guard depth < maxDepth else { return element }
280+
for child in elementArray(element, kAXChildrenAttribute).reversed() {
281+
let frame = frameScreen(of: child)
282+
if frame.width > 0, frame.height > 0, frame.contains(point) {
283+
return deepestChild(of: child, containing: point, depth: depth + 1)
284+
}
285+
}
286+
return element
287+
}
288+
207289
/// Climb `kAXParentAttribute` from `element` up to the window, returning the
208290
/// chain ordered root-first.
209291
private static func ancestorChain(from element: AXUIElement) -> [AXUIElement] {
@@ -240,6 +322,20 @@ enum AXIntrospection {
240322
return nil
241323
}
242324

325+
/// Deepest element in the chain that is still a plausible target — anything
326+
/// that is not the window or application container. Used only when no
327+
/// identified/actionable ancestor exists, so a click on a plain leaf resolves
328+
/// to that leaf rather than escalating to the whole window (which would then
329+
/// show the window title in the composer header).
330+
private static func deepestNonContainer(in rootFirstChain: [AXUIElement]) -> AXUIElement? {
331+
for element in rootFirstChain.reversed() {
332+
let role = string(element, kAXRoleAttribute) ?? ""
333+
if role == "AXWindow" || role == "AXApplication" { continue }
334+
return element
335+
}
336+
return nil
337+
}
338+
243339
/// Build a public ``Element`` for `target`, computing its path from the
244340
/// supplied root-first ancestor chain (with same-role sibling indices).
245341
private static func element(for target: AXUIElement, ancestorChain rootFirst: [AXUIElement]) -> Element {

0 commit comments

Comments
 (0)