From a0993221acc1caf1f5d1fb77db8f2bd0e7df7d4f Mon Sep 17 00:00:00 2001 From: SamsonCJ Date: Fri, 7 Aug 2026 01:17:26 -0700 Subject: [PATCH 1/3] Fix repeated Canvas link activation --- docs/components/canvas.md | 2 + .../Models/CanvasInteractionPolicy.swift | 23 ++++++ .../Features/Canvas/Views/CanvasView.swift | 23 ++++-- .../CanvasInteractionPolicyTests.swift | 77 +++++++++++++++++++ 4 files changed, 119 insertions(+), 6 deletions(-) create mode 100644 supacode/Features/Canvas/Models/CanvasInteractionPolicy.swift create mode 100644 supacodeTests/CanvasInteractionPolicyTests.swift diff --git a/docs/components/canvas.md b/docs/components/canvas.md index e36da24f..6b472623 100644 --- a/docs/components/canvas.md +++ b/docs/components/canvas.md @@ -32,6 +32,8 @@ Canvas lets you **type a command once and send it to many agents at once**: Selection controls: - `⌘`-click a card body → toggle it in/out of the selection. +- `⌘`-click a terminal link → open it. Recognized links take priority over card + multi-selection, including when `⌘` is held across repeated clicks. - **Click (without `⌘`)** an already-selected card → make it the **primary** (the one you type into) without deselecting the rest. (`⌘`-clicking a selected card instead *removes* it from the selection.) diff --git a/supacode/Features/Canvas/Models/CanvasInteractionPolicy.swift b/supacode/Features/Canvas/Models/CanvasInteractionPolicy.swift new file mode 100644 index 00000000..21eb6575 --- /dev/null +++ b/supacode/Features/Canvas/Models/CanvasInteractionPolicy.swift @@ -0,0 +1,23 @@ +enum CanvasInteractionPolicy { + static func linkActivationRequested( + hasHoveredLink: Bool, + isCommandModifierActive: Bool + ) -> Bool { + hasHoveredLink && isCommandModifierActive + } + + static func showsSelectionShield( + commandSelectionActive: Bool, + selectionModeActive: Bool, + broadcastFollower: Bool, + linkActivationRequested: Bool + ) -> Bool { + if linkActivationRequested { + return false + } + if commandSelectionActive || selectionModeActive { + return true + } + return broadcastFollower + } +} diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index a11c31cb..815396f0 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -256,11 +256,22 @@ struct CanvasView: View { .onDisappear { deactivateCanvas() } } - func showsSelectionShield(for tabID: TerminalTabID) -> Bool { - if commandKeyObserver.isPressed { return true } - if selectionState.isSelecting { return true } - if selectionState.isBroadcasting, selectionState.primaryTabID != tabID { return true } - return false + func showsSelectionShield( + for tabID: TerminalTabID, + in tree: SplitTree + ) -> Bool { + let hasHoveredLink = tree.leaves().contains { + $0.bridge.state.mouseOverLink?.isEmpty == false + } + return CanvasInteractionPolicy.showsSelectionShield( + commandSelectionActive: commandKeyObserver.isPressed, + selectionModeActive: selectionState.isSelecting, + broadcastFollower: selectionState.isBroadcasting && selectionState.primaryTabID != tabID, + linkActivationRequested: CanvasInteractionPolicy.linkActivationRequested( + hasHoveredLink: hasHoveredLink, + isCommandModifierActive: NSEvent.modifierFlags.contains(.command) + ) + ) } // MARK: - Cards Layer @@ -358,7 +369,7 @@ struct CanvasView: View { isExpanded: isCardExpanded, expandHelp: expandHelp, canvasScale: isCardExpanded ? 1 : canvasScale, - showsSelectionShield: showsSelectionShield(for: tab.id), + showsSelectionShield: showsSelectionShield(for: tab.id, in: tree), onTap: { let cmdHeld = NSEvent.modifierFlags.contains(.command) if cmdHeld { diff --git a/supacodeTests/CanvasInteractionPolicyTests.swift b/supacodeTests/CanvasInteractionPolicyTests.swift new file mode 100644 index 00000000..ae6c3051 --- /dev/null +++ b/supacodeTests/CanvasInteractionPolicyTests.swift @@ -0,0 +1,77 @@ +import Testing + +@testable import supacode + +struct CanvasInteractionPolicyTests { + @Test func hoveredLinkTakesPriorityOverCommandSelection() { + let linkActivationRequested = CanvasInteractionPolicy.linkActivationRequested( + hasHoveredLink: true, + isCommandModifierActive: true + ) + let showsShield = CanvasInteractionPolicy.showsSelectionShield( + commandSelectionActive: true, + selectionModeActive: true, + broadcastFollower: true, + linkActivationRequested: linkActivationRequested + ) + + #expect(linkActivationRequested) + #expect(showsShield == false) + } + + @Test func commandSelectionStillShieldsNonLinkContent() { + let showsShield = CanvasInteractionPolicy.showsSelectionShield( + commandSelectionActive: true, + selectionModeActive: false, + broadcastFollower: false, + linkActivationRequested: false + ) + + #expect(showsShield) + } + + @Test func selectionModeStillShieldsNonLinkContent() { + let showsShield = CanvasInteractionPolicy.showsSelectionShield( + commandSelectionActive: false, + selectionModeActive: true, + broadcastFollower: false, + linkActivationRequested: false + ) + + #expect(showsShield) + } + + @Test func broadcastingOnlyShieldsFollowerCards() { + let primaryShowsShield = CanvasInteractionPolicy.showsSelectionShield( + commandSelectionActive: false, + selectionModeActive: false, + broadcastFollower: false, + linkActivationRequested: false + ) + let followerShowsShield = CanvasInteractionPolicy.showsSelectionShield( + commandSelectionActive: false, + selectionModeActive: false, + broadcastFollower: true, + linkActivationRequested: false + ) + + #expect(primaryShowsShield == false) + #expect(followerShowsShield) + } + + @Test func hoveredLinkWithoutCommandStillUsesSelectionPolicy() { + let linkActivationRequested = CanvasInteractionPolicy.linkActivationRequested( + hasHoveredLink: true, + isCommandModifierActive: false + ) + let showsShield = CanvasInteractionPolicy.showsSelectionShield( + commandSelectionActive: false, + selectionModeActive: true, + broadcastFollower: false, + linkActivationRequested: linkActivationRequested + ) + + #expect(linkActivationRequested == false) + #expect(showsShield) + } +} -- 2.51.2 From 6ad3d2fbe3d80cc8198aee1847211572d16b3701 Mon Sep 17 00:00:00 2001 From: SamsonCJ Date: Sun, 9 Aug 2026 19:27:23 -0700 Subject: [PATCH 2/3] Fix Canvas follower link event routing --- .../Models/CanvasInteractionPolicy.swift | 22 +++ .../Canvas/Views/CanvasCardView.swift | 12 +- .../Features/Canvas/Views/CanvasView.swift | 23 +-- .../CanvasCardEventRoutingTests.swift | 179 ++++++++++++++++++ .../CanvasInteractionPolicyTests.swift | 38 ++++ 5 files changed, 262 insertions(+), 12 deletions(-) create mode 100644 supacodeTests/CanvasCardEventRoutingTests.swift diff --git a/supacode/Features/Canvas/Models/CanvasInteractionPolicy.swift b/supacode/Features/Canvas/Models/CanvasInteractionPolicy.swift index 21eb6575..1cc63fb8 100644 --- a/supacode/Features/Canvas/Models/CanvasInteractionPolicy.swift +++ b/supacode/Features/Canvas/Models/CanvasInteractionPolicy.swift @@ -6,6 +6,16 @@ enum CanvasInteractionPolicy { hasHoveredLink && isCommandModifierActive } + /// Whether any terminal pane of a card that is actually on screen currently + /// has a hovered link. Only `visibleLeaves()` are scanned: a pane hidden by + /// split zoom can otherwise retain a stale `mouseOverLink` and incorrectly + /// disable the selection shield for the visible pane. + static func hasHoveredLink(in tree: SplitTree) -> Bool { + tree.visibleLeaves().contains { + $0.bridge.state.mouseOverLink?.isEmpty == false + } + } + static func showsSelectionShield( commandSelectionActive: Bool, selectionModeActive: Bool, @@ -20,4 +30,16 @@ enum CanvasInteractionPolicy { } return broadcastFollower } + + /// Whether a card's terminal surface should receive mouse events. Focused + /// cards are always interactive; for every other card (e.g. broadcast + /// followers) a link activation is the only interaction allowed through — + /// anything else must keep routing to the selection callbacks. + static func terminalHitTestingEnabled( + isFocused: Bool, + linkActivationRequested: Bool, + showsSelectionShield: Bool + ) -> Bool { + (isFocused || linkActivationRequested) && !showsSelectionShield + } } diff --git a/supacode/Features/Canvas/Views/CanvasCardView.swift b/supacode/Features/Canvas/Views/CanvasCardView.swift index 44d2fa12..e17f94d9 100644 --- a/supacode/Features/Canvas/Views/CanvasCardView.swift +++ b/supacode/Features/Canvas/Views/CanvasCardView.swift @@ -35,6 +35,10 @@ struct CanvasCardView: View { /// parent resolves it since CanvasCardView has no keybindings context). let expandHelp: String let canvasScale: CGFloat + /// Whether a terminal link is currently hovered while Command is held, so + /// the click must reach Ghostty even on non-focused cards (broadcast + /// followers) instead of falling through to the selection callbacks. + let linkActivationRequested: Bool let showsSelectionShield: Bool let onTap: () -> Void let onSelectionTap: () -> Void @@ -269,7 +273,13 @@ struct CanvasCardView: View { // withAnimation (expand/restore, resize commit, arrange), so the terminal // refit stays in lock-step with the card's offset/scale. Without a wrapping // animation (live resize drag) the size tracks the gesture 1:1. - .allowsHitTesting(isFocused && !showsSelectionShield) + .allowsHitTesting( + CanvasInteractionPolicy.terminalHitTestingEnabled( + isFocused: isFocused, + linkActivationRequested: linkActivationRequested, + showsSelectionShield: showsSelectionShield + ) + ) } private var selectionShield: some View { diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index 815396f0..f9b66f1b 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -256,22 +256,21 @@ struct CanvasView: View { .onDisappear { deactivateCanvas() } } - func showsSelectionShield( + func interactionState( for tabID: TerminalTabID, in tree: SplitTree - ) -> Bool { - let hasHoveredLink = tree.leaves().contains { - $0.bridge.state.mouseOverLink?.isEmpty == false - } - return CanvasInteractionPolicy.showsSelectionShield( + ) -> (linkActivationRequested: Bool, showsSelectionShield: Bool) { + let linkActivationRequested = CanvasInteractionPolicy.linkActivationRequested( + hasHoveredLink: CanvasInteractionPolicy.hasHoveredLink(in: tree), + isCommandModifierActive: NSEvent.modifierFlags.contains(.command) + ) + let showsSelectionShield = CanvasInteractionPolicy.showsSelectionShield( commandSelectionActive: commandKeyObserver.isPressed, selectionModeActive: selectionState.isSelecting, broadcastFollower: selectionState.isBroadcasting && selectionState.primaryTabID != tabID, - linkActivationRequested: CanvasInteractionPolicy.linkActivationRequested( - hasHoveredLink: hasHoveredLink, - isCommandModifierActive: NSEvent.modifierFlags.contains(.command) - ) + linkActivationRequested: linkActivationRequested ) + return (linkActivationRequested, showsSelectionShield) } // MARK: - Cards Layer @@ -341,6 +340,7 @@ struct CanvasView: View { let splitDivider = terminalManager.splitDividerAppearance() let repositoryAppearance = appearance(for: state.repositoryRootURL) let resolvedRepositoryName = repositoryDisplayName(for: state.repositoryRootURL) + let interaction = interactionState(for: tab.id, in: tree) AnimatedExpandableCard( progress: isCardExpanded ? 1 : 0, @@ -369,7 +369,8 @@ struct CanvasView: View { isExpanded: isCardExpanded, expandHelp: expandHelp, canvasScale: isCardExpanded ? 1 : canvasScale, - showsSelectionShield: showsSelectionShield(for: tab.id, in: tree), + linkActivationRequested: interaction.linkActivationRequested, + showsSelectionShield: interaction.showsSelectionShield, onTap: { let cmdHeld = NSEvent.modifierFlags.contains(.command) if cmdHeld { diff --git a/supacodeTests/CanvasCardEventRoutingTests.swift b/supacodeTests/CanvasCardEventRoutingTests.swift new file mode 100644 index 00000000..e0245ce0 --- /dev/null +++ b/supacodeTests/CanvasCardEventRoutingTests.swift @@ -0,0 +1,179 @@ +import AppKit +import GhosttyKit +import SwiftUI +import Testing + +@testable import supacode + +@MainActor +struct CanvasCardEventRoutingTests { + @Test func commandClickOnFollowerLinkRoutesToGhosttyWithoutChangingSelection() async throws { + let fixture = CanvasCardEventRoutingFixture(hasHoveredLink: true) + defer { fixture.close() } + await fixture.prepareForHitTesting() + + let target = try #require(fixture.sendCommandClick()) + await fixture.drainMainQueue() + + #expect(target === fixture.surfaceView) + #expect(fixture.tapRecorder.selectionTapCount == 0) + #expect(fixture.tapRecorder.outerTapCount == 0) + } + + @Test func commandClickOnFollowerNonLinkContentStillRoutesToSelection() async throws { + let fixture = CanvasCardEventRoutingFixture(hasHoveredLink: false) + defer { fixture.close() } + await fixture.prepareForHitTesting() + + let target = try #require(fixture.sendCommandClick()) + await fixture.drainMainQueue() + + #expect(target !== fixture.surfaceView) + #expect(fixture.tapRecorder.selectionTapCount == 1) + #expect(fixture.tapRecorder.outerTapCount == 0) + } +} + +@MainActor +private final class CanvasCardEventRoutingFixture { + let surfaceView: GhosttySurfaceView + let tapRecorder = CanvasCardTapRecorder() + + private let cardSize = CGSize(width: 320, height: 200) + private let window: NSWindow + private let hostingView: NSHostingView + + init(hasHoveredLink: Bool) { + let runtime = GhosttyRuntime() + let surfaceView = GhosttySurfaceView( + runtime: runtime, + workingDirectory: nil, + context: GHOSTTY_SURFACE_CONTEXT_TAB, + skipsSurfaceCreationForTesting: true + ) + self.surfaceView = surfaceView + surfaceView.bridge.state.mouseOverLink = hasHoveredLink ? "https://example.com" : nil + + let tab = TerminalTabItem(title: "Follower", icon: nil) + let linkActivationRequested = CanvasInteractionPolicy.linkActivationRequested( + hasHoveredLink: CanvasInteractionPolicy.hasHoveredLink(in: SplitTree(view: surfaceView)), + isCommandModifierActive: true + ) + let showsSelectionShield = CanvasInteractionPolicy.showsSelectionShield( + commandSelectionActive: true, + selectionModeActive: true, + broadcastFollower: true, + linkActivationRequested: linkActivationRequested + ) + let card = CanvasCardView( + repositoryName: "Prowl", + worktreeName: tab.displayTitle, + tree: SplitTree(view: surfaceView), + activeSurfaceID: surfaceView.id, + unfocusedSplitOverlay: (nil, 0), + isFocused: false, + isSelected: true, + hasUnseenNotification: false, + tabIcon: nil, + tabId: tab.id, + tabs: [tab], + tabContextMenuActions: TerminalTabContextMenuActions( + renameTab: { _ in }, + changeIcon: { _ in }, + closeTab: { _ in }, + closeOthers: { _ in }, + closeToRight: { _ in }, + closeAll: {} + ), + cardSize: cardSize, + isExpanded: false, + expandHelp: "Expand card", + canvasScale: 1, + linkActivationRequested: linkActivationRequested, + showsSelectionShield: showsSelectionShield, + onTap: { [tapRecorder] in tapRecorder.outerTapCount += 1 }, + onSelectionTap: { [tapRecorder] in tapRecorder.selectionTapCount += 1 }, + onDragCommit: { _ in }, + onResize: { _, _ in }, + onResizeEnd: {}, + onSplitOperation: { _ in }, + onTitleBarTap: {}, + onExpand: {}, + onClose: {} + ) + + let contentSize = CGSize(width: cardSize.width, height: cardSize.height + 28) + window = NSWindow( + contentRect: NSRect(origin: .zero, size: contentSize), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + hostingView = NSHostingView(rootView: card) + hostingView.frame = NSRect(origin: .zero, size: contentSize) + window.contentView = hostingView + } + + func prepareForHitTesting() async { + window.orderFront(nil) + hostingView.layoutSubtreeIfNeeded() + await drainMainQueue() + hostingView.layoutSubtreeIfNeeded() + } + + func sendCommandClick() -> NSView? { + let point = NSPoint(x: cardSize.width / 2, y: cardSize.height / 2) + let target = hostingView.hitTest(point) + let locationInWindow = hostingView.convert(point, to: nil) + let timestamp = ProcessInfo.processInfo.systemUptime + + guard + let mouseDown = NSEvent.mouseEvent( + with: .leftMouseDown, + location: locationInWindow, + modifierFlags: .command, + timestamp: timestamp, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 1, + clickCount: 1, + pressure: 1 + ), + let mouseUp = NSEvent.mouseEvent( + with: .leftMouseUp, + location: locationInWindow, + modifierFlags: .command, + timestamp: timestamp, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 2, + clickCount: 1, + pressure: 0 + ) + else { + return nil + } + + window.sendEvent(mouseDown) + window.sendEvent(mouseUp) + return target + } + + func close() { + window.orderOut(nil) + } + + func drainMainQueue() async { + await withCheckedContinuation { continuation in + DispatchQueue.main.async { + continuation.resume() + } + } + } +} + +@MainActor +private final class CanvasCardTapRecorder { + var outerTapCount = 0 + var selectionTapCount = 0 +} diff --git a/supacodeTests/CanvasInteractionPolicyTests.swift b/supacodeTests/CanvasInteractionPolicyTests.swift index ae6c3051..8b001231 100644 --- a/supacodeTests/CanvasInteractionPolicyTests.swift +++ b/supacodeTests/CanvasInteractionPolicyTests.swift @@ -1,3 +1,4 @@ +import GhosttyKit import Testing @testable import supacode @@ -74,4 +75,41 @@ struct CanvasInteractionPolicyTests { #expect(linkActivationRequested == false) #expect(showsShield) } + + @Test @MainActor func hiddenZoomedPaneDoesNotContributeStaleHoveredLink() throws { + let runtime = GhosttyRuntime() + let hiddenPane = makeSurface(runtime: runtime) + let visiblePane = makeSurface(runtime: runtime) + hiddenPane.bridge.state.mouseOverLink = "https://example.com/stale" + + let tree = try SplitTree(view: hiddenPane) + .inserting(view: visiblePane, at: hiddenPane, direction: .right) + let zoomedTree = tree.settingZoomed(try #require(tree.find(id: visiblePane.id))) + let linkActivationRequested = CanvasInteractionPolicy.linkActivationRequested( + hasHoveredLink: CanvasInteractionPolicy.hasHoveredLink(in: zoomedTree), + isCommandModifierActive: true + ) + let showsShield = CanvasInteractionPolicy.showsSelectionShield( + commandSelectionActive: true, + selectionModeActive: false, + broadcastFollower: false, + linkActivationRequested: linkActivationRequested + ) + + #expect(linkActivationRequested == false) + #expect(showsShield) + + visiblePane.bridge.state.mouseOverLink = "https://example.com/visible" + #expect(CanvasInteractionPolicy.hasHoveredLink(in: zoomedTree)) + } + + @MainActor + private func makeSurface(runtime: GhosttyRuntime) -> GhosttySurfaceView { + GhosttySurfaceView( + runtime: runtime, + workingDirectory: nil, + context: GHOSTTY_SURFACE_CONTEXT_TAB, + skipsSurfaceCreationForTesting: true + ) + } } -- 2.51.2 From 918d2bef962c9baf331a456dc164e90ded8c455a Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 11 Aug 2026 23:00:41 +0900 Subject: [PATCH 3/3] Fix Canvas interaction test warning --- supacodeTests/CanvasInteractionPolicyTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supacodeTests/CanvasInteractionPolicyTests.swift b/supacodeTests/CanvasInteractionPolicyTests.swift index 8b001231..d9a91bf3 100644 --- a/supacodeTests/CanvasInteractionPolicyTests.swift +++ b/supacodeTests/CanvasInteractionPolicyTests.swift @@ -84,7 +84,7 @@ struct CanvasInteractionPolicyTests { let tree = try SplitTree(view: hiddenPane) .inserting(view: visiblePane, at: hiddenPane, direction: .right) - let zoomedTree = tree.settingZoomed(try #require(tree.find(id: visiblePane.id))) + let zoomedTree = tree.settingZoomed(tree.find(id: visiblePane.id)) let linkActivationRequested = CanvasInteractionPolicy.linkActivationRequested( hasHoveredLink: CanvasInteractionPolicy.hasHoveredLink(in: zoomedTree), isCommandModifierActive: true -- 2.51.2