From 38a484ab6a12b1ae56af2d7fa99f581127831142 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 22 Sep 2026 17:41:00 -0700 Subject: [PATCH 1/2] Windows: inspect native context menus in the UIA live gate The live gate had no way to open, read, or dismiss a native Win32 popup menu, so every context-menu behavior in the Windows shell -- including the grayed Move Project item that GraphContextMenu.show() appends with MF_GRAYED -- was unverifiable live. Add a gate-only fixture message (MainWindow.wm_uia_context_menu) that asks the shell to open a real context menu through the same GraphContextMenu.show() the mouse path calls, a watchdog timer that calls EndMenu() so an abandoned popup can never block the message loop, and gate-side popup discovery, introspection, and dismissal. A popup surfaces in the UIA tree only as an empty Pane with no MenuItem children, so the menu is read through MN_GETHMENU and the Win32 menu API against the HMENU the shell handed to TrackPopupMenu. That is live evidence of what the shell renders, not UIA-tree evidence, and is labelled as such in the gate, the tests, and the parity ledger. The gate now asserts the live project menu's ordered items, that Move is command 5149 with its exact unavailable text and a disabled state, that a remote project's menu omits Move/Recycle Bin/Explorer, and that the popup dismisses without wedging the shell. GraphContextMenu.zig and MainWindow.zig unit tests were never executed by any harness; they now run in the Windows shell validation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../windows/Tests/ValidationRunner.Tests.ps1 | 26 ++ Tools/windows/Tests/WindowsShell.Tests.ps1 | 14 ++ Tools/windows/uia-live-gate.ps1 | 233 +++++++++++++++++- graphcode-windows/src/App.zig | 65 ++++- graphcode-windows/src/MainWindow.zig | 18 +- investigation/ui-parity-matrix.md | 6 +- 6 files changed, 344 insertions(+), 18 deletions(-) diff --git a/Tools/windows/Tests/ValidationRunner.Tests.ps1 b/Tools/windows/Tests/ValidationRunner.Tests.ps1 index aab84c5f..6bd58e5f 100644 --- a/Tools/windows/Tests/ValidationRunner.Tests.ps1 +++ b/Tools/windows/Tests/ValidationRunner.Tests.ps1 @@ -247,6 +247,32 @@ try { throw "RED: UIA live gate New Loop invocation is not preceded by verified foreground recovery at every site" } $shellTests = Get-Content (Join-Path $PSScriptRoot "WindowsShell.Tests.ps1") -Raw + if ($uiaLiveGateSource -notmatch 'function Wait-ForPopupMenu' -or + $uiaLiveGateSource -notmatch 'function Get-PopupMenuItems' -or + $uiaLiveGateSource -notmatch 'function Close-PopupMenu' -or + $uiaLiveGateSource -notmatch 'FindPopupMenuWindow' -or + $uiaLiveGateSource -notmatch 'SendMessage\(popup, 0x01E1, UIntPtr\.Zero, IntPtr\.Zero\)' -or + $uiaLiveGateSource -notmatch 'PostMessage\(window, 0x802C, \(UIntPtr\)target, IntPtr\.Zero\)') { + throw "RED: UIA live gate cannot open, read, or dismiss a native TrackPopupMenu popup" + } + if ($uiaLiveGateSource -notmatch '\$moveProjectMenuText = "Move Project\.\.\. \(unavailable: daemon support required\)"' -or + $uiaLiveGateSource -notmatch '(?s)PostContextMenu\(\$shellWindow, 1\).*?Wait-ForPopupMenu \$process \$shellWindow "project"' -or + $uiaLiveGateSource -notmatch 'Require \(-not \$moveProjectItem\.Enabled\)' -or + $uiaLiveGateSource -notmatch '\$moveProjectItem\.Text -eq \$moveProjectMenuText' -or + $uiaLiveGateSource -notmatch '(?s)PostContextMenu\(\$shellWindow, 2\).*?\$_\.Id -in @\(5149, 5151, 5144\)' -or + $uiaLiveGateSource -notmatch 'project context menu did not dismiss, leaving the shell blocked in its modal loop') { + throw "RED: UIA live gate does not assert the live project context menu's disabled Move item and deterministic dismissal" + } + if ($shellTests -notmatch '(?s)Context menu and gate fixture message executable tests.*?zig test src\\GraphContextMenu\.zig' -or + $shellTests -notmatch '(?s)Context menu and gate fixture message executable tests.*?zig test src\\MainWindow\.zig') { + throw "RED: Windows shell validation does not run the context menu and gate fixture message tests" + } + $appSource = Get-Content (Join-Path $repoRoot "graphcode-windows\src\App.zig") -Raw + if ($appSource -notmatch 'fn showUiaContextMenu' -or + $appSource -notmatch 'MainWindow\.wm_uia_context_menu => \{' -or + $appSource -notmatch '(?s)wparam == MainWindow\.menu_watchdog_timer_id.*?c\.EndMenu\(\)') { + throw "RED: the shell cannot open a gate-requested context menu, or an abandoned popup can block its message loop forever" + } if ($shellTests -notmatch '(?s)Windows update feed executable tests.*?zig test src\\WindowsUpdates\.zig.*?-lwinhttp') { throw "RED: Windows shell validation does not run the native updater tests" } diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 index bf92d852..63f6c5d0 100644 --- a/Tools/windows/Tests/WindowsShell.Tests.ps1 +++ b/Tools/windows/Tests/WindowsShell.Tests.ps1 @@ -379,6 +379,20 @@ Invoke-Native "Update offer modal deferral executable tests" { Push-Location $shellRoot try { & $zig test src\UpdateOfferPresentation.zig } finally { Pop-Location } } +Invoke-Native "Context menu and gate fixture message executable tests" { + $depotRoot = Split-Path (Split-Path $repoRoot -Parent) -Parent + $winghosttyRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_WINGHOSTTY_ROOT") + if (-not $winghosttyRoot) { + $winghosttyRoot = Join-Path $depotRoot "Winghostty-worktrees\host-integration" + } + $include = Join-Path $winghosttyRoot "include" + Push-Location $shellRoot + try { + & $zig test src\GraphContextMenu.zig -target x86_64-windows-msvc -lc -luser32 "-I$include" + if ($LASTEXITCODE -ne 0) { return } + & $zig test src\MainWindow.zig -target x86_64-windows-msvc -lc -luser32 -lgdi32 "-I$include" + } finally { Pop-Location } +} Invoke-Native "Jump palette executable tests" { $depotRoot = Split-Path (Split-Path $repoRoot -Parent) -Parent $winghosttyRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_WINGHOSTTY_ROOT") diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 627923a4..a4e0c006 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -123,6 +123,71 @@ public static class GraphCodeUiaGateState { private static extern IntPtr SetActiveWindow(IntPtr window); [DllImport("user32.dll")] private static extern void keybd_event(byte virtualKey, byte scanCode, uint flags, UIntPtr extraInfo); + [DllImport("user32.dll")] + private static extern bool IsWindowVisible(IntPtr window); + [DllImport("user32.dll")] + private static extern int GetMenuItemCount(IntPtr menu); + [DllImport("user32.dll")] + private static extern uint GetMenuItemID(IntPtr menu, int position); + [DllImport("user32.dll")] + private static extern uint GetMenuState(IntPtr menu, uint item, uint flags); + [DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "GetMenuStringW")] + private static extern int GetMenuString(IntPtr menu, uint item, StringBuilder text, int max, uint flags); + public static IntPtr FindPopupMenuWindow(uint processId) { + IntPtr result = IntPtr.Zero; + EnumWindows(delegate(IntPtr window, IntPtr parameter) { + uint owner; + GetWindowThreadProcessId(window, out owner); + if (owner != processId) return true; + var actualClass = new StringBuilder(256); + GetClassName(window, actualClass, actualClass.Capacity); + if (!String.Equals(actualClass.ToString(), "#32768", StringComparison.Ordinal)) return true; + if (!IsWindowVisible(window)) return true; + result = window; + return false; + }, IntPtr.Zero); + return result; + } + // MN_GETHMENU. The live UIA tree exposes a popup menu only as an empty Pane + // with no MenuItem children, so the menu itself is read through the Win32 + // menu API against the real HMENU the shell handed to TrackPopupMenu. + public static IntPtr PopupMenuHandle(IntPtr popup) { + if (popup == IntPtr.Zero) return IntPtr.Zero; + return SendMessage(popup, 0x01E1, UIntPtr.Zero, IntPtr.Zero); + } + public static int PopupMenuItemCount(IntPtr menu) { + if (menu == IntPtr.Zero) return -1; + return GetMenuItemCount(menu); + } + public static uint PopupMenuItemId(IntPtr menu, int position) { + if (menu == IntPtr.Zero) return 0; + return GetMenuItemID(menu, position); + } + public static string PopupMenuItemText(IntPtr menu, int position) { + if (menu == IntPtr.Zero) return ""; + var text = new StringBuilder(512); + GetMenuString(menu, (uint)position, text, text.Capacity, 0x0400); + return text.ToString(); + } + public static uint PopupMenuItemState(IntPtr menu, int position) { + if (menu == IntPtr.Zero) return 0xFFFFFFFF; + return GetMenuState(menu, (uint)position, 0x0400); + } + // MainWindow.wm_uia_context_menu (WM_APP + 44). + public static bool PostContextMenu(IntPtr window, uint target) { + return PostMessage(window, 0x802C, (UIntPtr)target, IntPtr.Zero); + } + public static bool DismissPopupMenu(IntPtr popup, IntPtr owner) { + bool posted = popup != IntPtr.Zero && + PostMessage(popup, 0x0100, (UIntPtr)0x1B, IntPtr.Zero); + if (!posted && owner != IntPtr.Zero) { + SendMessage(owner, 0x001F, UIntPtr.Zero, IntPtr.Zero); + } + return posted; + } + public static void CancelPopupMenu(IntPtr owner) { + if (owner != IntPtr.Zero) PostMessage(owner, 0x001F, UIntPtr.Zero, IntPtr.Zero); + } public static IntPtr FindChild(IntPtr parent, string className) { return FindWindowEx(parent, IntPtr.Zero, className, null); } @@ -318,6 +383,83 @@ function Get-FocusDiagnostics([IntPtr] $expectedWindow) { return "foreground=$(Format-WindowHandle $foreground) expected=$(Format-WindowHandle $expectedWindow) expectedIsForeground=$([GraphCodeUiaGateState]::IsForegroundWindow($expectedWindow)) foregroundPid=$foregroundProcessId foregroundProcess='$($foregroundProcess.ProcessName)' foregroundClass='$([GraphCodeUiaGateState]::WindowClass($foreground))' foregroundTitle='$([GraphCodeUiaGateState]::WindowTitle($foreground))' focused={$focusedDescription}" } +function Wait-ForPopupMenu( + [System.Diagnostics.Process] $process, + [IntPtr] $ownerWindow, + [string] $label, + [int] $TimeoutMilliseconds = 5000, + [int] $PollMilliseconds = 50 +) { + $deadline = [DateTime]::UtcNow.AddMilliseconds($TimeoutMilliseconds) + $popup = [IntPtr]::Zero + while ([DateTime]::UtcNow -lt $deadline -and $popup -eq [IntPtr]::Zero) { + $process.Refresh() + if ($process.HasExited) { + throw "shell exited with code $($process.ExitCode) while opening the $label context menu" + } + $popup = [GraphCodeUiaGateState]::FindPopupMenuWindow([uint32]$process.Id) + if ($popup -eq [IntPtr]::Zero) { Start-Sleep -Milliseconds $PollMilliseconds } + } + if ($popup -eq [IntPtr]::Zero) { + Write-Host "UIA_POPUP_DIAGNOSTICS label=$label $(Get-FocusDiagnostics $ownerWindow)" + } + return $popup +} + +function Get-PopupMenuItems([IntPtr] $popup) { + $menu = [GraphCodeUiaGateState]::PopupMenuHandle($popup) + if ($menu -eq [IntPtr]::Zero) { return @() } + $count = [GraphCodeUiaGateState]::PopupMenuItemCount($menu) + if ($count -lt 0) { return @() } + $items = @() + for ($position = 0; $position -lt $count; $position++) { + $state = [GraphCodeUiaGateState]::PopupMenuItemState($menu, $position) + $items += [PSCustomObject]@{ + Position = $position + Id = [GraphCodeUiaGateState]::PopupMenuItemId($menu, $position) + Text = [GraphCodeUiaGateState]::PopupMenuItemText($menu, $position) + State = $state + # MF_GRAYED (0x1) and MF_DISABLED (0x2) both render an unavailable item. + Enabled = (($state -band 0x3) -eq 0) + Separator = (($state -band 0x800) -ne 0) + } + } + return $items +} + +function Format-PopupMenuItems($items) { + if ($null -eq $items -or @($items).Count -eq 0) { return "" } + return (@($items) | ForEach-Object { + "[$($_.Position)] id=$($_.Id) enabled=$($_.Enabled) separator=$($_.Separator) '$($_.Text)'" + }) -join '; ' +} + +function Close-PopupMenu( + [System.Diagnostics.Process] $process, + [IntPtr] $popup, + [IntPtr] $ownerWindow, + [string] $label, + [int] $TimeoutMilliseconds = 3000, + [int] $PollMilliseconds = 50 +) { + $null = [GraphCodeUiaGateState]::DismissPopupMenu($popup, $ownerWindow) + $deadline = [DateTime]::UtcNow.AddMilliseconds($TimeoutMilliseconds) + $cancelled = $false + while ([DateTime]::UtcNow -lt $deadline) { + if ([GraphCodeUiaGateState]::FindPopupMenuWindow([uint32]$process.Id) -eq [IntPtr]::Zero) { + return $true + } + if (-not $cancelled -and [DateTime]::UtcNow -gt $deadline.AddMilliseconds(-1500)) { + # Escape did not take: fall back to cancelling the owner's modal loop. + [GraphCodeUiaGateState]::CancelPopupMenu($ownerWindow) + $cancelled = $true + } + Start-Sleep -Milliseconds $PollMilliseconds + } + Write-Host "UIA_POPUP_DISMISS_DIAGNOSTICS label=$label cancelSent=$cancelled $(Get-FocusDiagnostics $ownerWindow)" + return $false +} + function Wait-ForDesktopElement( [System.Windows.Automation.AutomationElement] $desktop, [System.Windows.Automation.Condition] $condition, @@ -2088,18 +2230,85 @@ try { "sidebar root reorder did not use the sidebar-order daemon command: $reorderCommand" $moveProjectUnavailableReason = "Project relocation is unavailable: the daemon wire contract has no authoritative moveProject command." - # The live native project right-click context menu (GraphContextMenu.zig, - # a real Win32 TrackPopupMenu) always renders "Move Project... (unavailable: - # daemon support required)" grayed via MF_GRAYED -- see the direct, - # deterministic proof of that exact item's id/text/enabled state in - # GraphContextMenu.zig's "the real Move Project menu item is disabled with - # its explicit reason inline" test, which exercises the very function - # show() uses to build the popup. This harness has no existing capability - # to open/inspect a transient native Win32 popup menu live (no action in - # this gate does; TrackPopupMenu blocks the message loop while displayed), - # so instead we assert the two behaviors this gate CAN observe live: that - # invoking the stale/legacy command path never opens Explorer, and that it - # surfaces the exact unavailable-status reason. + $moveProjectMenuText = "Move Project... (unavailable: daemon support required)" + # Live proof of what the native project right-click menu actually renders. + # GraphContextMenu.zig builds a real Win32 TrackPopupMenu; the gate asks the + # shell to open that exact menu (MainWindow.wm_uia_context_menu -> the same + # GraphContextMenu.show() the mouse path calls) and then reads the live HMENU. + # Note the observation channel: a popup menu surfaces in the UIA tree only as + # an empty Pane with no MenuItem children, so item identity, text, and the + # MF_GRAYED state are read through MN_GETHMENU and the Win32 menu API against + # the menu the shell itself handed to TrackPopupMenu. The shell thread stays + # blocked in the menu's own modal loop while we inspect, which is why the menu + # is requested asynchronously and dismissed deterministically afterwards. + $projectPopup = [IntPtr]::Zero + for ($attempt = 1; $attempt -le 3 -and $projectPopup -eq [IntPtr]::Zero; $attempt++) { + Require (Ensure-ShellForeground $shellWindow "project context menu") ` + "GraphCode shell did not reacquire foreground before the project context menu" + Require ([GraphCodeUiaGateState]::PostContextMenu($shellWindow, 1)) ` + "project context menu request was rejected" + $projectPopup = Wait-ForPopupMenu $process $shellWindow "project" + } + Require ($projectPopup -ne [IntPtr]::Zero) ` + "project context menu never opened a native popup window" + $projectMenuItems = @(Get-PopupMenuItems $projectPopup) + $projectMenuDescription = Format-PopupMenuItems $projectMenuItems + $projectMenuClosed = Close-PopupMenu $process $projectPopup $shellWindow "project" + Require $projectMenuClosed ` + "project context menu did not dismiss, leaving the shell blocked in its modal loop" + $process.Refresh() + Require (-not $process.HasExited) ` + "shell exited with code $($process.ExitCode) while its context menu was inspected" + Require ($projectMenuItems.Count -gt 0) ` + "project context menu exposed no live items: $projectMenuDescription" + $projectMenuLabels = @($projectMenuItems | Where-Object { -not $_.Separator } | + ForEach-Object { $_.Text }) + foreach ($expectedLabel in @( + "Open Project", "New Loop...`tCtrl+N", "Worktrees...", "Project Settings...", + "Show in Explorer", "Close Project", "Move to Recycle Bin...", + "Remove from GraphCode...", "Delete All Loops..." + )) { + Require ($projectMenuLabels -contains $expectedLabel) ` + "project context menu omitted '$expectedLabel': $projectMenuDescription" + } + $moveProjectItem = @($projectMenuItems | Where-Object { $_.Id -eq 5149 }) | Select-Object -First 1 + Require ($null -ne $moveProjectItem) ` + "project context menu omitted the Move Project item (command 5149): $projectMenuDescription" + Require ($moveProjectItem.Text -eq $moveProjectMenuText) ` + "project context menu Move item text drifted: '$($moveProjectItem.Text)'" + Require (-not $moveProjectItem.Enabled) ` + "project context menu rendered Move Project as available: $projectMenuDescription" + $liveStatusAfterMenu = Find-FragmentById $root "status" $rawWalker + Require ($null -ne $liveStatusAfterMenu) ` + "shell UIA tree stopped answering after its context menu was dismissed" + + # Remote projects must not offer local-filesystem relocation at all. This is a + # negative assertion that can fail: the same show() switch appends Move and + # Move to Recycle Bin only when the project is local. + $remotePopup = [IntPtr]::Zero + for ($attempt = 1; $attempt -le 3 -and $remotePopup -eq [IntPtr]::Zero; $attempt++) { + Require (Ensure-ShellForeground $shellWindow "remote project context menu") ` + "GraphCode shell did not reacquire foreground before the remote project context menu" + Require ([GraphCodeUiaGateState]::PostContextMenu($shellWindow, 2)) ` + "remote project context menu request was rejected" + $remotePopup = Wait-ForPopupMenu $process $shellWindow "remote project" + } + Require ($remotePopup -ne [IntPtr]::Zero) ` + "remote project context menu never opened a native popup window" + $remoteMenuItems = @(Get-PopupMenuItems $remotePopup) + $remoteMenuDescription = Format-PopupMenuItems $remoteMenuItems + Require (Close-PopupMenu $process $remotePopup $shellWindow "remote project") ` + "remote project context menu did not dismiss, leaving the shell blocked in its modal loop" + Require (@($remoteMenuItems | Where-Object { $_.Id -eq 5145 }).Count -eq 1) ` + "remote project context menu omitted Remote Connection Info: $remoteMenuDescription" + Require (@($remoteMenuItems | Where-Object { $_.Id -in @(5149, 5151, 5144) }).Count -eq 0) ` + "remote project context menu offered local-only relocation or Explorer actions: $remoteMenuDescription" + $process.Refresh() + Require (-not $process.HasExited) ` + "shell exited with code $($process.ExitCode) after the remote project context menu" + + # The stale/legacy Move command path must still refuse to alias Explorer and + # must surface the explicit unavailable reason. Remove-Item -LiteralPath $shellExecuteLogPath -Force -ErrorAction SilentlyContinue Require ([GraphCodeUiaGateState]::PostFixtureMutation($shellWindow, 17)) ` "project Move fixture mutation was rejected" diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 4fdfcea0..13b65ebe 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -47,6 +47,13 @@ const daemon_supervisor_test_property = std.unicode.utf8ToUtf16LeStringLiteral("GraphCode.Windows.DaemonSupervisorState"); extern fn graphcode_pick_folder(owner: c.HWND, buffer: [*]u16, capacity: c.DWORD) callconv(.c) c_int; +/// Deterministic targets and screen position used only by the live UIA gate's +/// context-menu hook (`MainWindow.wm_uia_context_menu`). +const uia_context_menu_project_path = "C:\\GraphCode\\fixture"; +const uia_context_menu_remote_project_path = "ssh://builder/GraphCode"; +const uia_context_menu_x: i32 = 160; +const uia_context_menu_y: i32 = 160; + const InputBounds = struct { rail_left: i32, workspace_top: i32, @@ -2053,6 +2060,52 @@ pub const App = struct { ); } + /// Gate-only hook that opens a real native context menu so the live UIA + /// gate can inspect what `TrackPopupMenu` actually renders. It calls the + /// same `GraphContextMenu.show()` the mouse path calls with the same + /// target data; only hit-test routing is bypassed. A watchdog timer ends + /// the menu if the harness never dismisses it, so a wedged popup can never + /// block the shell thread for the life of the process. + fn showUiaContextMenu(self: *App, target_kind: c.WPARAM) void { + if (!envFlag("GRAPHCODE_UIA_GATE")) return; + const hwnd = self.window.hwnd; + const target: GraphContextMenu.Target = switch (target_kind) { + 1 => .{ .project = .{ .path = uia_context_menu_project_path, .remote = false } }, + 2 => .{ .project = .{ .path = uia_context_menu_remote_project_path, .remote = true } }, + 3 => blk: { + const graph = self.model.graph orelse return; + if (graph.nodes.items.len == 0) return; + break :blk .{ .node = .{ + .project_path = graph.project.path, + .id = graph.nodes.items[0].id, + .composite = std.mem.eql(u8, graph.nodes.items[0].loop_type, "composite") or + std.mem.eql(u8, graph.nodes.items[0].loop_type, "proactive"), + .can_arm = std.mem.eql(u8, graph.nodes.items[0].pilot_state, "piloted"), + .unwired = self.nodeIsUnwired(graph.nodes.items[0].id), + .follows_template = graph.nodes.items[0].follows_template, + } }; + }, + 4 => .background, + 5 => .quick_chats, + else => return, + }; + _ = c.SetTimer( + hwnd, + MainWindow.menu_watchdog_timer_id, + MainWindow.menu_watchdog_interval_ms, + null, + ); + GraphContextMenu.show( + hwnd, + target, + uia_context_menu_x, + uia_context_menu_y, + self, + &onContextAction, + ); + _ = c.KillTimer(hwnd, MainWindow.menu_watchdog_timer_id); + } + fn handleContextAction(self: *App, action: GraphContextMenu.Action, target: GraphContextMenu.Target) void { switch (target) { .project => |stable| { @@ -4866,7 +4919,12 @@ fn onWindowMessage( result.* = 0; return true; }, - c.WM_TIMER => if (wparam == MainWindow.timer_id) { + c.WM_TIMER => if (wparam == MainWindow.menu_watchdog_timer_id) { + _ = c.KillTimer(hwnd, MainWindow.menu_watchdog_timer_id); + _ = c.EndMenu(); + result.* = 0; + return true; + } else if (wparam == MainWindow.timer_id) { app.smoke_tick += 1; if (!app.tray.added and app.smoke_tick % 10 == 0) { app.tray.add(hwnd) catch app.setStatus("System tray unavailable; retrying"); @@ -5004,6 +5062,11 @@ fn onWindowMessage( result.* = 0; return true; }, + MainWindow.wm_uia_context_menu => { + app.showUiaContextMenu(wparam); + result.* = 0; + return true; + }, c.WM_KEYDOWN => { if (wparam == c.VK_ESCAPE) { app.cancelCanvasInteraction(); diff --git a/graphcode-windows/src/MainWindow.zig b/graphcode-windows/src/MainWindow.zig index 7f56a1e0..3c13fdc2 100644 --- a/graphcode-windows/src/MainWindow.zig +++ b/graphcode-windows/src/MainWindow.zig @@ -169,6 +169,13 @@ pub const timer_id: usize = 41; pub const wm_app_tick: c.UINT = c.WM_APP + 41; pub var restore_message: c.UINT = 0; pub const wm_uia_fixture_mutate: c.UINT = c.WM_APP + 42; +pub const wm_uia_context_menu: c.UINT = c.WM_APP + 44; + +/// Watchdog that ends a gate-opened popup menu if the harness never dismisses +/// it. `TrackPopupMenu` runs its own modal loop, so without this a wedged +/// popup would block the shell thread for the lifetime of the process. +pub const menu_watchdog_timer_id: usize = 43; +pub const menu_watchdog_interval_ms: c.UINT = 10000; const class_name = std.unicode.utf8ToUtf16LeStringLiteral("GraphCodeWindowsShell"); @@ -458,8 +465,15 @@ test "workspace commands use a dedicated command range" { try std.testing.expectEqual(Command.workspace_new, commandFromId(4800).?); } -test "native menu labels are NUL terminated UTF-16" { - const wide = try toWideZ(std.testing.allocator, "Clone Repository…"); +test "gate fixture messages and timers never collide with shell traffic" { + try std.testing.expect(wm_uia_context_menu != wm_app_tick); + try std.testing.expect(wm_uia_context_menu != wm_uia_fixture_mutate); + try std.testing.expect(wm_uia_context_menu > c.WM_APP); + try std.testing.expect(menu_watchdog_timer_id != timer_id); + try std.testing.expect(menu_watchdog_interval_ms > 0); +} + +test "native menu labels are NUL terminated UTF-16" { const wide = try toWideZ(std.testing.allocator, "Clone Repository…"); defer std.testing.allocator.free(wide); try std.testing.expectEqual(@as(u16, 0), wide[wide.len]); try std.testing.expect(wide.len > "Clone Repository".len); diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index 04c0d141..614e5c97 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -50,8 +50,8 @@ Statuses: | Project rows | Selection, folder type, hover New Loop, disclosure | Open project rows retain selection and local/remote glyphs, reveal hover-only New Loop and disclosure controls, and collapse/restore their own loop tree without changing row identity. The live UIA gate invokes the project-row New Loop action into the real native node form and exercises project collapse/expand through stable UIA actions. | Validated | | Nested loop tree | Edge-derived hierarchy, persisted expansion, drag reorder of roots | Handoff edges derive a cycle-safe root/descendant tree; nested rows disclose and collapse by stable node ID, expanded IDs persist atomically in the GraphCode support directory, and root rows now reorder through live pointer drag backed by the existing transactional `root` records. Focused Sidebar/Wire coverage and the deterministic UIA gate verify observable reorder plus emission of the new `sidebarNodesReordered` daemon command for server-side persistence parity. | Validated | | Loop row presentation | Type stripe, title, elapsed time, state indicator | Rows now show a loop-type stripe, title, compact state indicator, and a compact elapsed value derived from `createdAt`. Focused/live executable evidence for the elapsed clock is not yet complete | Partial | -| Project context menu | Move, worktrees, settings, Explorer, remote info, close, remove, delete loops/project | Project rows retain the existing lifecycle actions plus Move and the confirmed Windows Recycle Bin path for local folders. Windows keeps Move as the native Explorer `/select` handoff rather than an in-app relocation flow; the deterministic UIA gate now verifies the live shell-execute target and selected folder path. | Validated | -| Loop context menu | Open, composite actions, rename, stop, delete | Sidebar and canvas loop rows share stable-ID Open, Rename, Stop, and Delete actions. Composite cards expose Open Group, Pilot Once, and Arm Schedule; the drilled-in canvas addresses mutations through the parent composite. Final live menu and accessibility evidence remains incomplete | Partial | +| Project context menu | Move, worktrees, settings, Explorer, remote info, close, remove, delete loops/project | Project rows expose the lifecycle actions plus the Windows Recycle Bin path for local folders. Move is deliberately **not** an Explorer `/select` alias: `GraphContextMenu.moveProjectMenuItem()` appends "Move Project... (unavailable: daemon support required)" with `MF_GRAYED` while `Wire.supportsProjectRelocation()` is false, and the stale command path surfaces that explicit reason instead of opening Explorer. The live UIA gate now drives the real `TrackPopupMenu` popup (`MainWindow.wm_uia_context_menu` -> the same `GraphContextMenu.show()` the mouse path calls) and asserts the live menu's ordered items, that Move is command 5149 with that exact text and a disabled state, that a remote project's menu omits Move/Recycle Bin/Explorer entirely, and that the popup dismisses without wedging the shell. Note the observation channel: a popup menu appears in the UIA tree only as an empty Pane with no `MenuItem` children, so item identity/text/enabled state are read through `MN_GETHMENU` and the Win32 menu API against the HMENU the shell handed to `TrackPopupMenu` — live evidence of what the shell actually renders, but not UIA-tree evidence. | Validated | +| Loop context menu | Open, composite actions, rename, stop, delete | Sidebar and canvas loop rows share stable-ID Open, Rename, Stop, and Delete actions. Composite cards expose Open Group, Pilot Once, and Arm Schedule; the drilled-in canvas addresses mutations through the parent composite. The gate can now open and read native popups (target 3 of `wm_uia_context_menu` requests the loop menu), but no live assertion over the loop menu's composite/unwired variants exists yet; adding those item assertions is what would close this row | Partial | | Recent projects | Reachable from Add Folder menu | Recent and currently-open projects are now exposed as distinct sidebar rows, with unopened recents remaining under the LOCAL/REMOTE sections while open workspaces use separate `open-project` identities. The deterministic UIA gate verifies the split presentation and section behavior; recent-menu reachability is covered by the native menu tests, but a live submenu walkthrough remains outstanding. | Partial | | Add Folder menu | Open Folder, Clone, Add Remote, recents | File now groups Open Folder, Clone Repository, Add Remote Repository, and Add Codespace under Add Folder and adds a dedicated Recent Folders submenu with its own command range; that submenu is now located rather than positionally indexed, so a new ingress entry can no longer silently retarget the rebuild. Focused MainWindow coverage validates the native menu structure and recent-folder command wiring; a live submenu walkthrough remains outstanding. | Partial | | Sidebar update banner | Available version and click-to-install action | A persistent footer banner now shows the retained offered version and reopens the native update offer when clicked. A deterministic live fixture captured the banner and verified the click raises `GraphCode Update Available`; the offer still hands installation off to the verified release page | Partial | @@ -84,7 +84,7 @@ Statuses: | Node creation sheet | Loop-type teaching tiles, conditional fields, backend/model/branch pickers, recap, validation reason | A guided native form provides loop-type/backend/model choices, type-specific fields, explanatory copy, accessible checkboxes, inline validation, keyboard traversal, and scrolling while preserving hidden wire values. The loop-type field is now rendered as four owner-drawn "teaching tiles" (`NativeForms.zig`: `createTileButtons`/`drawTile`/`WM_DRAWITEM`) — rounded 9px cards with an accent color chip, bold title, and one-line description, mirroring `graphcode/Sources/Features/Project/LoopTypeChooser.swift`'s grid; tile accents are the exact macOS RGB values from `LoopTypeAppearance.swift` (turnBased #D55181, timeBased #C98500, goalBased #199E70, composite #9085E9) packed as correct COLORREFs via a new `tileColor` helper, with selection shown as an accent-tinted fill/border via `blendColor` and idle tiles a faint dark card, all on the shared dark panel background. The form now also exposes a native saved-template picker that filters through keyboard-standard ComboBox behavior and deterministically pre-fills the typed draft; loop context menus can save reusable templates and detach followed templates. Focused Zig and WindowsShell coverage pass, and the live gate reached and accepted the template picker before an unrelated workspace-toolbar fixture assertion; branch picker, recap, and complete live recapture remain incomplete. Prompt attachments are now supported in the draft: a native `IFileOpenDialog` multi-select picker (`FilePicker.c`) attaches supported files, `DraftAttachments.zig` validates extension/size and ingests them into a bounded per-draft staged directory, the dialog lists name/type/size with removal and `[image #N]` token renumbering, and `Wire.zig` encodes them as an always-present `[{"id":...,"path":...}]` array matching the exact Codable shape of macOS `GraphcodeKit/Sources/Domain/PromptAttachment.swift`, decoded end-to-end by a Swift interop test. Staged assets survive the saved-template round trip and are discarded on cancel or validation failure. Clipboard paste and drag-and-drop attachment are not implemented, and the live file-picker interaction is not exercised by the UI Automation gate (the gate opens the node form and closes it without invoking Attach), so picker behavior rests on unit coverage rather than live evidence | Partial | | Node update/rename | Dedicated rename prompt and safe typed updates | Rename retains its dedicated safe prompt. The canvas context menu now also exposes Edit Details..., backed by the typed `NativeForms.update` editor and authoritative `sendUpdateNodeForm` path for goal, predicate, polling/stall, metric, trigger/check, and model fields. Focused form/wire coverage passes; live editor evidence remains blocked | Partial | | Delete confirmations | Named object, consequences, safe default | Loop deletion names the loop and explains graph-connection removal. Edge deletion now names both endpoint loops and the connection kind, explains that the loops remain, re-resolves the stable edge after confirmation, and defaults to cancellation | Validated | -| Canvas context menu | Folder actions on background; complete node/edge actions | Background retains Create Edge; node menus expose composite Open Group/Pilot/Arm actions plus Edit Details and no longer show the non-macOS Message/Memo actions. Focused menu coverage remains the available evidence; live context-menu/UIA evidence is still pending because the current live gate does not synthesize a right-click menu walkthrough | Partial | +| Canvas context menu | Folder actions on background; complete node/edge actions | Background retains Create Edge; node menus expose composite Open Group/Pilot/Arm actions plus Edit Details and no longer show the non-macOS Message/Memo actions. The live gate can now open and read native popup menus, and the background menu is reachable through `wm_uia_context_menu` target 4, but the shipped live assertion covers the project menu only; node and edge popup item assertions are what would close this row | Partial | ## Quick Chats From 846b663f8c43e22adaf9f272f79df10ea4876793 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 22 Sep 2026 18:10:56 -0700 Subject: [PATCH 2/2] Windows: publish live context-menu facts in the gate summary The context-menu assertions were silent on success, so a passing CI run left no positive record of what the native popup actually contained. Emit the observed item count, the Move Project item's exact text, enabled flag and raw MF_ state bits, and the dismissal result into the gate's summary JSON so the live evidence is auditable from the job log. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/uia-live-gate.ps1 | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index a4e0c006..8f410724 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -2665,6 +2665,12 @@ try { focusFallbackSource = [GraphCodeUiaGateState]::FocusSourceAutomationId providerTeardownSafe = $retainedProviderSafe connectionFailureBannerPassed = $true + contextMenuItemCount = $projectMenuItems.Count + contextMenuMoveProjectText = $moveProjectItem.Text + contextMenuMoveProjectEnabled = $moveProjectItem.Enabled + contextMenuMoveProjectState = ("0x{0:x}" -f $moveProjectItem.State) + contextMenuDismissed = $projectMenuClosed + remoteContextMenuItemCount = $remoteMenuItems.Count } | ConvertTo-Json -Compress } finally { if ($stressJob) {