From 78e83e2610211280b285e160d0ecd41475743ebe Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 28 Sep 2026 14:35:07 -0700 Subject: [PATCH] Fix Windows UIA gate owned modal lookup Resolve visible update dialogs by their owning process and HWND when desktop UIA enumeration omits them. Assert visible shell root access, report native activation outcomes and actionable fixture path limits, and correct Partial parity evidence without claiming full validation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../windows/Tests/ValidationRunner.Tests.ps1 | 22 +++ Tools/windows/uia-live-gate.ps1 | 139 ++++++++++++++---- investigation/ui-parity-matrix.md | 10 +- 3 files changed, 139 insertions(+), 32 deletions(-) diff --git a/Tools/windows/Tests/ValidationRunner.Tests.ps1 b/Tools/windows/Tests/ValidationRunner.Tests.ps1 index 2004d590..1b0f0d3f 100644 --- a/Tools/windows/Tests/ValidationRunner.Tests.ps1 +++ b/Tools/windows/Tests/ValidationRunner.Tests.ps1 @@ -461,6 +461,28 @@ try { throw "RED: Windows shell validation does not execute the UI Automation live gate" } $uiaLiveGateSource = Get-Content (Join-Path $repoRoot "Tools\windows\uia-live-gate.ps1") -Raw + if ($uiaLiveGateSource -notmatch 'UIA_ROOT_ACCESS' -or + $uiaLiveGateSource -notmatch 'UIA_UPDATE_DIALOG_DIAGNOSTICS' -or + $uiaLiveGateSource -notmatch 'maxSandboxRootUtf16' -or + $uiaLiveGateSource -notmatch 'Require \(\[GraphCodeUiaGateState\]::WindowIsVisible\(\$shellWindow\)\)') { + throw "RED: UIA gate does not identify visible shell HWND, background root access, missing modal and short TEMP remedy" + } + if ($uiaLiveGateSource -notmatch 'FindTopLevel\("GraphCodeUpdateOffer", \[uint32\]\$process\.Id\)' -or + $uiaLiveGateSource -notmatch 'UIA_UPDATE_DIALOG_DIRECT' -or + $uiaLiveGateSource -notmatch 'FromHandle\(\$nativeUpdateWindow\)' -or + $uiaLiveGateSource -notmatch '\$updateDialog = \$directUpdate') { + throw "RED: UIA gate does not use the owned modal HWND when desktop-tree lookup omits it" + } + if ($uiaLiveGateSource -notmatch '(?s)\$updateDialog = \$desktop\.FindFirst\(\s*\[System\.Windows\.Automation\.TreeScope\]::Children' -or + $uiaLiveGateSource -notmatch 'UIA_UPDATE_DIALOG_CHILDREN found=' -or + $uiaLiveGateSource -notmatch 'Current\.ProcessId -ne \$process\.Id') { + throw "RED: UIA gate does not search top-level dialogs as desktop children scoped to the shell PID" + } + if ($uiaLiveGateSource -notmatch 'LastActivationDiagnostic' -or + $uiaLiveGateSource -notmatch 'SetForegroundWindow\(window\).*?Marshal.GetLastWin32Error\(\)' -or + $uiaLiveGateSource -notmatch 'AttachThreadInput\(currentThread, targetThread, true\).*?Marshal.GetLastWin32Error\(\)') { + throw "RED: UIA foreground failure hides native return values and last-error diagnostics" + } if ($uiaLiveGateSource -notmatch 'AttachThreadInput' -or $uiaLiveGateSource -notmatch 'keybd_event\(0x12, 0, 0, UIntPtr\.Zero\)' -or $uiaLiveGateSource -notmatch 'SetActiveWindow\(window\)' -or diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 4ca7c6cf..b3fd4e82 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -104,15 +104,15 @@ public static class GraphCodeUiaGateState { private static extern IntPtr SetFocus(IntPtr window); [DllImport("user32.dll")] private static extern IntPtr GetFocus(); - [DllImport("user32.dll")] + [DllImport("user32.dll", SetLastError = true)] private static extern bool SetForegroundWindow(IntPtr window); [DllImport("user32.dll")] private static extern IntPtr GetForegroundWindow(); - [DllImport("user32.dll")] + [DllImport("user32.dll", SetLastError = true)] private static extern bool BringWindowToTop(IntPtr window); [DllImport("kernel32.dll")] private static extern uint GetCurrentThreadId(); - [DllImport("user32.dll")] + [DllImport("user32.dll", SetLastError = true)] private static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool attach); [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern int GetWindowText(IntPtr window, StringBuilder text, int count); @@ -278,8 +278,12 @@ public static class GraphCodeUiaGateState { if (attached) AttachThreadInput(currentThread, parentThread, false); } } + public static string LastActivationDiagnostic = "not attempted"; public static bool ActivateWindow(IntPtr window) { - if (window == IntPtr.Zero) return false; + if (window == IntPtr.Zero) { + LastActivationDiagnostic = "invalid target HWND"; + return false; + } IntPtr foreground = GetForegroundWindow(); uint ignoredForegroundProcessId; uint foregroundThread = foreground == IntPtr.Zero ? 0 : @@ -287,22 +291,32 @@ public static class GraphCodeUiaGateState { uint ignoredTargetProcessId; uint targetThread = GetWindowThreadProcessId(window, out ignoredTargetProcessId); uint currentThread = GetCurrentThreadId(); - bool attachForeground = foregroundThread != 0 && - currentThread != foregroundThread && - AttachThreadInput(currentThread, foregroundThread, true); - bool attachTarget = currentThread != targetThread && - AttachThreadInput(currentThread, targetThread, true); + bool needsForegroundAttach = foregroundThread != 0 && currentThread != foregroundThread; + bool attachForeground = !needsForegroundAttach || + AttachThreadInput(currentThread, foregroundThread, true); int foregroundAttachError = Marshal.GetLastWin32Error(); + bool needsTargetAttach = currentThread != targetThread; + bool attachTarget = !needsTargetAttach || + AttachThreadInput(currentThread, targetThread, true); int targetAttachError = Marshal.GetLastWin32Error(); try { ShowWindow(window, 9); - BringWindowToTop(window); + bool broughtToTop = BringWindowToTop(window); + int bringError = Marshal.GetLastWin32Error(); keybd_event(0x12, 0, 0, UIntPtr.Zero); keybd_event(0x12, 0, 0x0002, UIntPtr.Zero); SetActiveWindow(window); - SetForegroundWindow(window); - return IsForegroundWindow(window); + bool foregroundSet = SetForegroundWindow(window); int foregroundError = Marshal.GetLastWin32Error(); + bool observed = IsForegroundWindow(window); + LastActivationDiagnostic = String.Format( + "AttachThreadInput(foreground)={0} GetLastError={1} AttachThreadInput(target)={2} GetLastError={3} BringWindowToTop={4} GetLastError={5} SetForegroundWindow={6} GetLastError={7} (FALSE can leave no meaningful last error) observedForeground={8}", + attachForeground, needsForegroundAttach && !attachForeground ? foregroundAttachError : 0, + attachTarget, needsTargetAttach && !attachTarget ? targetAttachError : 0, + broughtToTop, broughtToTop ? 0 : bringError, + foregroundSet, foregroundSet ? 0 : foregroundError, observed + ); + return observed; } finally { - if (attachTarget) AttachThreadInput(currentThread, targetThread, false); - if (attachForeground) AttachThreadInput(currentThread, foregroundThread, false); + if (needsTargetAttach && attachTarget) AttachThreadInput(currentThread, targetThread, false); + if (needsForegroundAttach && attachForeground) AttachThreadInput(currentThread, foregroundThread, false); } } public static bool IsForegroundWindow(IntPtr window) { @@ -377,6 +391,9 @@ public static class GraphCodeUiaGateState { public static bool WindowIsEnabled(IntPtr window) { return IsWindowEnabled(window); } + public static bool WindowIsVisible(IntPtr window) { + return window != IntPtr.Zero && IsWindowVisible(window); + } // Real client-coordinate mouse messages posted directly to the target window, // matching the same WM_MOUSEMOVE/WM_LBUTTONDOWN/WM_LBUTTONUP messages the OS // delivers for genuine mouse input, without moving the shared desktop's real @@ -505,7 +522,7 @@ function Get-FocusDiagnostics([IntPtr] $expectedWindow) { } catch { $focusedDescription = "error='$($_.Exception.Message)'" } - 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}" + 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} activation={$([GraphCodeUiaGateState]::LastActivationDiagnostic)}" } function Wait-ForPopupMenu( @@ -774,6 +791,8 @@ function Ensure-ShellForeground( } while (-not $acquired -and [DateTime]::UtcNow -lt $deadline) if (-not $acquired) { Write-Host "UIA_FOREGROUND_DIAGNOSTICS phase=$label $(Get-FocusDiagnostics $window)" + } else { + Write-Host "UIA_FOREGROUND_ACQUIRED phase=$label window=$(Format-WindowHandle $window) $([GraphCodeUiaGateState]::LastActivationDiagnostic)" } return $acquired } @@ -1305,7 +1324,15 @@ function Assert-UiaProviderPathBudget( $endpoint = '\\.\pipe\zmx-' + $sidComponent + '\' + $qualified + '-' + ("0" * 32) # 232 is the reviewed conservative gate budget, not a universal Win32 limit. if ($lease.Length -gt 232 -or $endpoint.Length -ge 256) { - throw "UIA fixture provider path budget exceeded: lease=$($lease.Length)/232, pipe=$($endpoint.Length)/255, assumed ordinary-account SID length=$sidLengthAssumption" + $maxSandboxRootUtf16 = $sandbox.Length + 232 - $lease.Length + $remedies = @() + if ($lease.Length -gt 232) { + $remedies += "set TEMP/TMP to a shorter per-session directory before launching the gate" + } + if ($endpoint.Length -ge 256) { + $remedies += "shorten inherited session prefixes; the pipe length does not depend on TEMP/TMP" + } + throw "UIA fixture provider path budget exceeded: lease=$($lease.Length)/232, pipe=$($endpoint.Length)/255, assumed ordinary-account SID length=$sidLengthAssumption; maxSandboxRootUtf16=$maxSandboxRootUtf16 ($($remedies -join '; '))" } return [pscustomobject]@{ leaseUtf16 = $lease.Length @@ -1443,7 +1470,11 @@ try { } } } - if ($null -eq $root) { throw "shell did not expose graphcode-root through WM_GETOBJECT" } + if ($null -eq $root) { + $process.Refresh() + Write-Host "UIA_ROOT_DIAGNOSTICS processId=$($process.Id) mainWindow=$(Format-WindowHandle $process.MainWindowHandle) topLevels=$([GraphCodeUiaGateState]::DescribeTopLevelWindows([uint32]$process.Id) -join ';')" + throw "shell did not expose graphcode-root through WM_GETOBJECT" + } $expectedRootIds = @("projects", "loops", "worktrees", "graph", "actions", "status", "workspaces") $rawWalker = [System.Windows.Automation.TreeWalker]::RawViewWalker @@ -1465,9 +1496,10 @@ try { # rather than an assumption about which exception type will show up. $providerSettled = $false $lastSettleException = $null + $firstRootChild = $null for ($settleAttempt = 0; $settleAttempt -lt 60; $settleAttempt++) { try { - $null = @($rawWalker.GetFirstChild($root)) + $firstRootChild = $rawWalker.GetFirstChild($root) $providerSettled = $true break } catch { @@ -1481,24 +1513,63 @@ try { " (last: $($lastSettleException.Exception.GetType().FullName): $($lastSettleException.Exception.Message))" } else { "" } Require $providerSettled "shell UI Automation provider did not settle after graphcode-root appeared$settleFailureDetail" + Require ([GraphCodeUiaGateState]::WindowIsVisible($shellWindow)) ` + "shell exposed graphcode-root but its top-level window is not visible" + $foregroundAtRoot = [GraphCodeUiaGateState]::CurrentForegroundWindow() + Write-Host "UIA_ROOT_ACCESS processId=$($process.Id) window=$(Format-WindowHandle $shellWindow) visible=True foreground=$(Format-WindowHandle $foregroundAtRoot) background=$($foregroundAtRoot -ne $shellWindow) automationId=$($root.Current.AutomationId) rawFirstChildPresent=$($null -ne $firstRootChild) topLevels=$([GraphCodeUiaGateState]::DescribeTopLevelWindows([uint32]$process.Id) -join ';')" $desktop = [System.Windows.Automation.AutomationElement]::RootElement $updateDialog = $desktop.FindFirst( - [System.Windows.Automation.TreeScope]::Descendants, + [System.Windows.Automation.TreeScope]::Children, (New-Object System.Windows.Automation.PropertyCondition( [System.Windows.Automation.AutomationElement]::NameProperty, "GraphCode Update Available" )) ) + if ($null -ne $updateDialog -and $updateDialog.Current.ProcessId -ne $process.Id) { + $updateDialog = $null + } + Write-Host "UIA_UPDATE_DIALOG_CHILDREN found=$($null -ne $updateDialog)" + $nativeUpdateWindow = [IntPtr]::Zero + if ($null -eq $updateDialog) { + Write-Host "UIA_UPDATE_DIALOG_DIAGNOSTICS processId=$($process.Id) topLevels=$([GraphCodeUiaGateState]::DescribeTopLevelWindows([uint32]$process.Id) -join ';') $(Get-FocusDiagnostics $shellWindow)" + $nativeUpdateWindow = [GraphCodeUiaGateState]::FindTopLevel("GraphCodeUpdateOffer", [uint32]$process.Id) + if ([GraphCodeUiaGateState]::WindowIsVisible($nativeUpdateWindow)) { + try { + $directUpdate = [System.Windows.Automation.AutomationElement]::FromHandle($nativeUpdateWindow) + $directName = $directUpdate.Current.Name + $directButton = $directUpdate.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::NameProperty, "Later" + )) + ) + Write-Host "UIA_UPDATE_DIALOG_DIRECT handle=$(Format-WindowHandle $nativeUpdateWindow) name='$directName' laterFound=$($null -ne $directButton)" + if ($directName -eq "GraphCode Update Available") { + $updateDialog = $directUpdate + } + } catch { + $errorCode = $_.Exception.GetBaseException().HResult + throw "UIA_UPDATE_DIALOG_DIRECT handle=$(Format-WindowHandle $nativeUpdateWindow) errorType=$($_.Exception.GetBaseException().GetType().FullName) hresult=0x$($errorCode.ToString('X8')) message='$($_.Exception.GetBaseException().Message)'" + } + } + } Require ($null -ne $updateDialog) "update offer dialog did not appear" Start-Sleep -Milliseconds 250 - $updateDialog = $desktop.FindFirst( - [System.Windows.Automation.TreeScope]::Descendants, - (New-Object System.Windows.Automation.PropertyCondition( - [System.Windows.Automation.AutomationElement]::NameProperty, - "GraphCode Update Available" - )) - ) + if ($nativeUpdateWindow -ne [IntPtr]::Zero) { + $updateDialog = [System.Windows.Automation.AutomationElement]::FromHandle($nativeUpdateWindow) + } else { + $updateDialog = $desktop.FindFirst( + [System.Windows.Automation.TreeScope]::Children, + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::NameProperty, + "GraphCode Update Available" + )) + ) + if ($null -ne $updateDialog -and $updateDialog.Current.ProcessId -ne $process.Id) { + $updateDialog = $null + } + } $installButton = $updateDialog.FindFirst( [System.Windows.Automation.TreeScope]::Descendants, (New-Object System.Windows.Automation.PropertyCondition( @@ -1602,12 +1673,26 @@ try { for ($index = 0; $index -lt 20 -and $null -eq $clickedUpdateDialog; $index++) { Start-Sleep -Milliseconds 100 $clickedUpdateDialog = $desktop.FindFirst( - [System.Windows.Automation.TreeScope]::Descendants, + [System.Windows.Automation.TreeScope]::Children, (New-Object System.Windows.Automation.PropertyCondition( [System.Windows.Automation.AutomationElement]::NameProperty, "GraphCode Update Available" )) ) + if ($null -ne $clickedUpdateDialog -and $clickedUpdateDialog.Current.ProcessId -ne $process.Id) { + $clickedUpdateDialog = $null + } + if ($null -eq $clickedUpdateDialog) { + $clickedUpdateWindow = [GraphCodeUiaGateState]::FindTopLevel( + "GraphCodeUpdateOffer", [uint32]$process.Id + ) + if ([GraphCodeUiaGateState]::WindowIsVisible($clickedUpdateWindow)) { + $candidate = [System.Windows.Automation.AutomationElement]::FromHandle($clickedUpdateWindow) + if ($candidate.Current.Name -eq "GraphCode Update Available") { + $clickedUpdateDialog = $candidate + } + } + } } Require ($null -ne $clickedUpdateDialog) ` "a real click on the sidebar update banner's live geometry did not open the update offer dialog" diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index 615656c9..85af0747 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -20,10 +20,10 @@ Statuses: | macOS surface | Required visible behavior | Windows evidence | Status | |---|---|---|---| -| Main split view | Persistent sidebar; detail switches among welcome, global graph, project canvas, Quick Chats canvas, and loop workspace | Explicit destinations exist; the earlier stub walkthrough covered project → overview → workspace → Show in Graph with sidebar retention. The shared header now follows destination identity, distinguishes a Quick Chat workspace from a previously selected project, and keeps detail-panel toggling inside the loop workspace. Production-state and layout tests cover the destinations, narrow widths, and sidebar/panel independence. The current live attempt stopped at foreground acquisition before UIA root access: no new live UIA SetFocus, F6, Jump, pixels, or sidebar-effect proof was obtained, and provider-backed workspace/panel behavior remains unverified | Partial | -| Window toolbar | Needs-you chip, worktree notice, jump field, contextual loop-panel toggle | Render, hit-test, UIA exposure, and focus order share header visibility/layout. Needs-you opens the selected attention target; cached local worktree notices honor the owning project and count/size threshold. Jump opens the existing palette even with no projects. The detail toggle is limited to loop workspaces with supported edge/metric content; workspace/project identity is no longer overpainted. F6 entry/exit, scoped traversal/activation, focus rendering, and marshaled UIA button focus are implemented, while ordinary Tab/Shift+Tab/Ctrl+Tab contracts remain. The header paints inside the buffered logical pass; literal 96/144/192-DPI bounds and hidden native client tests cover logical layout/input and exactly-once physical UIA conversion without rescaling terminal tabs. Production-helper tests, a hidden native Tab-to-command test, and a hidden empty-palette create/cancel test pass. Live focus/event-deadlock, keyboard activation, rendered pixels, sidebar effects, and provider-backed panel proof are still absent because foreground acquisition failed. Cross-project worktree-notice discovery/aggregation, summary/board/mailroom panel content, and macOS visual parity remain separate residuals; private live tooling is not CI coverage | Partial | +| Main split view | Persistent sidebar; detail switches among welcome, global graph, project canvas, Quick Chats canvas, and loop workspace | Explicit destinations exist; the earlier stub walkthrough covered project → overview → workspace → Show in Graph with sidebar retention. The shared header now follows destination identity, distinguishes a Quick Chat workspace from a previously selected project, and keeps detail-panel toggling inside the loop workspace. Production-state and layout tests cover the destinations, narrow widths, and sidebar/panel independence. A local Console/WinSta0\\Default live gate found a visible shell and read `graphcode-root` with the shell in the background; foreground is not a prerequisite for UIA root access. Its desktop-wide update-dialog search returned null despite a visible native dialog, while PID-scoped `FromHandle` found the dialog and its Later button. One subsequent run reached the full gate result, but its output pipeline did not exit, and other runs hit unrelated intermittent assertions. No new F6/Jump/pixel/sidebar-effect or provider-backed workspace/panel parity proof was obtained | Partial | +| Window toolbar | Needs-you chip, worktree notice, jump field, contextual loop-panel toggle | Render, hit-test, UIA exposure, and focus order share header visibility/layout. Needs-you opens the selected attention target; cached local worktree notices honor the owning project and count/size threshold. Jump opens the existing palette even with no projects. The detail toggle is limited to loop workspaces with supported edge/metric content; workspace/project identity is no longer overpainted. F6 entry/exit, scoped traversal/activation, focus rendering, and marshaled UIA button focus are implemented, while ordinary Tab/Shift+Tab/Ctrl+Tab contracts remain. The header paints inside the buffered logical pass; literal 96/144/192-DPI bounds and hidden native client tests cover logical layout/input and exactly-once physical UIA conversion without rescaling terminal tabs. Production-helper tests, a hidden native Tab-to-command test, and a hidden empty-palette create/cancel test pass. A local live gate resolved the background shell UIA root and later observed true foreground ownership and worktree-row focus; this does not establish F6/keyboard activation, rendered toolbar pixels, sidebar effects, or provider-backed panel behavior. Desktop modal lookup required a PID-scoped HWND fallback; the gate remains intermittently unstable and this local result is not CI or full macOS parity evidence. Cross-project worktree-notice discovery/aggregation and summary/board/mailroom panel content remain separate residuals | Partial | | Jump palette | Search field, ranked cross-project results, type/state/project context, mouse and keyboard selection | Ctrl+P and Ctrl+J open a native modal palette with live exact-ID, exact-title, title-prefix, and substring ranking across projects. Results visibly include project, loop type, and state; Up/Down, Return, Escape, and mouse double-click are supported. The deterministic UIA gate verifies a visible search field, contextual cross-project results, and keyboard navigation changing the selected loop. | Validated | -| File/Loop/Terminal menus | Discoverable project, worktree, navigation, workspace, update, settings, and help commands with state-aware enablement | Startup menu replacement and UTF-16 corruption are fixed and the five readable runtime groups were probed. Reclaim/Reveal require a selected row (`worktreeRowSelected()` retains dedicated unit coverage); Save requires the Worktrees dialog open. Loop commands now gray out when they have no actionable target: Jump requires a loaded loop anywhere, Next/Previous require at least two loops or one unselected loop in the active graph, Create Edge requires two active-graph loops, and Stop requires a selected loop. These distinct thresholds follow their different production handlers and are intentional. Terminal Next/Previous Tab and Focus Next/Previous Pane require multiple tabs or panes in the selected tab. A hidden-window test calls `MainWindow.updateMenu` and verifies the real native HMENU `MF_GRAYED` bits in both unavailable and available states; this is unit/runtime Win32-menu evidence, not a live application walkthrough. The Worktrees-dialog UIA gate evidence remains outstanding: obtaining it requires launching the shell in a foreground desktop session, and no application executable is built in this worktree; provider builds/provisioning and foreground-gated UIA automation were not performed. Broader project-management parity remains incomplete | Partial | +| File/Loop/Terminal menus | Discoverable project, worktree, navigation, workspace, update, settings, and help commands with state-aware enablement | Startup menu replacement and UTF-16 corruption are fixed and the five readable runtime groups were probed. Reclaim/Reveal require a selected row (`worktreeRowSelected()` retains dedicated unit coverage); Save requires the Worktrees dialog open. Loop commands now gray out when they have no actionable target: Jump requires a loaded loop anywhere, Next/Previous require at least two loops or one unselected loop in the active graph, Create Edge requires two active-graph loops, and Stop requires a selected loop. These distinct thresholds follow their different production handlers and are intentional. Terminal Next/Previous Tab and Focus Next/Previous Pane require multiple tabs or panes in the selected tab. A hidden-window test calls `MainWindow.updateMenu` and verifies the real native HMENU `MF_GRAYED` bits in both unavailable and available states; this is unit/runtime Win32-menu evidence, not a live application walkthrough. A local built shell exposed its UIA root while background, and the live gate traversed worktree rows and menu actions after PID-scoped update-dialog resolution; root inspection does not require foreground. A complete, reliably exiting live Worktrees-menu walkthrough and broader project-management parity remain unverified | Partial | | Workspace lifecycle | List/switch, create, rename, delete with fail-closed confirmation, and keyboard/UIA reachability | Windows discovers `Default` plus `.graphcode-*` siblings and exposes New/Rename/Delete and Ctrl+Alt paging. Manage now has an owned row model/native list, Default/creation-time/name-tie order, identity-deduplicated current-outside-home inclusion, full-path detail, current/default/open/uncertain-window refusals, and captured-identity Open/New/Rename handoff after modal teardown. An App-owned single joinable reader supplies bounded **saved top-level** graph-header counts, not live/descendant totals; current live content, missing/unreadable/invalid/duplicate/limited/unsupported-location data remain explicitly unavailable, not partial zero. Fixed local/non-reparse reads use existing project JSON and the documented mailroom-array exception, no daemon/backend or window activation. Done/Escape cancels; pending actions wait for reader cancellation/join while the modal pumps, and shutdown drains before allocator teardown (a stalled local disk can delay shutdown). Manager Delete is enabled and shares rename's refusals, identity revalidation after confirmation, and reservation; deletion itself is recoverable: the folder is staged aside, the target workspace's daemon is stopped by its own derived shutdown event, the folder goes to the Recycle Bin with `FOF_ALLOWUNDO`, and only then are its saved sessions ended with `zmx kill --force`. A failed stage refuses without effect, a daemon that will not stop or a failed recycle renames the folder back and ends no sessions, and a failed rename-back reports the exact staged path instead of claiming a rollback. Ordinary menu ordering/Open/New semantics are unchanged. Next/Previous now reread the manager-ordered list, include validated current outside home, filter identified running windows, wrap both ways, recheck the target, and use a restore-only API with no launcher. Closed/disappeared targets never cold-open; any unidentified flag or lookup/restore failure refuses with status. Existing SID/session/class/published-metadata checks are preserved, not executable-path attestation. Menu capability counts known rows plus implicit current, without periodic window polling. Injected production-helper RED/GREEN, allocation-failure/owned-refresh, ordering/dedup/offset/race/refusal tests and pure menu-capability tests execute in the existing App/MainWindow roots; full native roots compile/link without execution and the ReleaseSafe app builds without launch. Manager coverage remains pure owned-data/explicit disposable fixtures, controlled memory-only joined threads, and filtered form/App data seams; no new shown manager/native-control/window-lookup/UIA/runtime proof is claimed. Existing lifecycle helper and never-shown native-control regressions cover accepted text lifetime, allocation cleanup, normalized naming/collisions, lexical identity, exact-window and one-launch routing, Default/current/open refusals, child-only support/daemon-pipe isolation, canonical plus legacy reservations, unidentified-window refusal, fail-closed Settings rebinding, and disposable confirmed mutation preservation. Lexical identity is not junction equivalence. Shown-dialog accessibility/keyboard/layout, full multi-instance/save-reload behavior, reliable live totals, and real running-cycle keyboard/window proof remain residuals. Deletion's evidence is unit-level only: every teardown effect is injected through a comptime seam, so no test recycles a folder, signals a real daemon, or kills a real session, and no live walkthrough has confirmed a recovered folder in the Recycle Bin, a daemon actually exiting, or sessions actually ending. Sessions saved outside the workspace's own `projects` directory (extra terminal tabs/splits, whose layout files are written relative to the process working directory) are not discoverable for a non-current workspace and are reported as unended rather than assumed absent. Shared-host menu failures still have an unproven cause; no full lifecycle parity or host-only diagnosis is claimed | Partial | | Help menu | GraphCode Basics and normal About entry | The live Help menu exposes GraphCode Basics, which reopens onboarding, and About GraphCode, which opens a native versioned product dialog. The populated UIA gate verifies the dialog identity, version text, and close behavior | Validated | | Update command | Check for Updates, disabled while checking/installing | Reachable from the Help menu's native menu bar, immediately reports "Checking for updates..." status and disables the command the instant a check is invoked, and — after fixing a real bug where the background check's completion never refreshed the menu bit because the only refresh path was gated on unrelated daemon-connectivity state — now reliably re-enables the command once the check settles. A dedicated live UIA gate assertion (reading the real native `HMENU` bit via `GetMenuState`, not the fragile UIA tree) and a Win32-window-backed unit test both confirm the disable/re-enable cycle. When the settled result is an available update, the gate additionally waits for the real offer, dismisses it via Later, verifies the modal disappears, and requires the shell owner to be enabled again before continuing; failure/no-update outcomes are required not to present an offer. In-app installation is now implemented as a separate scope (see Install progress/Relaunch prompt, Partial) | Validated | @@ -94,9 +94,9 @@ native keyboard/accelerator/window proof; the workspace row remains Partial. | Custody child creation | New Child Node on unresolved parents, inherited editable backend, daemon-owned custody | Project-canvas/sidebar node menus consume the tested unresolved-only item plan (5119). Owned popup target/settings/child-only exact-project worktree snapshots feed the existing guarded node form and template/attachment continuation. Original popup context is checked before normal initial selection; final guards never reselect, reject project/composite/address drift or deleted/resolved/type/backend-changed parents, and allow rename/reorder/unresolved progress. Tests exercise the production initializer/transfer boundary and real data-only client queue, including cached B while observing A, root-versus-composite same IDs, UUID/null wire fields, cancellation and allocation failures. `createdBy` produces one create command: GraphStore owns the fired handoff/report-back memo and normal startup. No new protocol or parent/session mutation. Native display/action results, overview right-click, daemon acceptance/persistence, and inherited downstream-send failure handling remain unverified. The merged ordinary-creation path now retains the owned worktree snapshots whose evidence is recorded in Node creation sheet, while custody retains its pre-popup owned snapshot; template backend settings remain a separate residual | Partial | | Edge editing | Preserve identity/configuration and refuse stale edits | Windows uses an owned modal snapshot and one checked `updateEdge`, retaining endpoints, current fireCount, transform/spawn settings and unchanged optional legacy guards. Root/direct-composite scope and current cache are rechecked without treating observation subscription as authorization. Existing serialized daemon commands, blocking, parent roll-up and root publication remain authoritative; deeper new edit wrappers are refused. Production-helper regressions exercise actual Wire JSON/unstarted queues, foreign scopes, refresh, cancellation and allocation cleanup. Edit-only live/submit capture reads complete Unicode text and blocks acceptance on per-field read/allocation errors; injected-reader and initializer tests show no controls. In-memory Swift store tests cover CAS, kind/blocking rules, root publication, preview refusal and serialized legacy-child writeback followed by an edit that retains runtime progress. This is not native editing, daemon acceptance/persistence or macOS runtime evidence; macOS sources expose creation-time configuration and delete, not an existing edit UI | Partial | | 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, scrolling, and a live-updating recap. The native Branch picker offers inspection choices plus “This folder”; existing tests cover its empty state and selected binding, and earlier gate evidence covers the recap, Branch picker, and distinct Attach control. Staging now reserves a uniquely named file exclusively: the production append/removal regression first demonstrated A/B/C → remove B → add D overwriting C's bytes, then passed with C's original path and bytes preserved. Offline in-file coverage also checks template-restored/legacy files, preexisting destination collisions, partial-write rollback, allocation failures without orphan copies, invalid inputs, exactly eight files and the ninth rejection, and the exact 10 MiB/+1-byte boundary. Existing attachment DTO/wire tests remain passing; these are no-window helper/filesystem results, not OS input or daemon persistence evidence. Creation choices now own an exact-project snapshot of inspected path/branch strings across cache/model replacement; baseline aliasing and foreign-cache regressions fail before the fix and pass afterward. Noninteractive production-projection tests cover snapshot lifetime, projection without a loaded graph, and allocation-failure cleanup. Separate pure initializer/builder-to-Wire tests verify exact non-default repository/id/path/branch fields, initial/current/template-restored selection, and “This folder” clearing with available choices; legacy empty-choice hidden binding remains unchanged. This does not establish actual native non-default selection, a Git-inspection-to-creation flow, or daemon save/reload. **Still partial:** actual inspected nondefault binding and project-scoped ownership across pumped modals remain unproved; New branch and remote/global branch visibility differ from macOS; authentic OS picker acceptance/cancellation, clipboard paste/drop, and native-input → request → daemon save/reload evidence remain outstanding. Per-file removed-file reclamation and legacy unguarded cleanup remain separate residuals. Creation now retains the original project/composite context and validates after modal teardown, before attachment transfer, across normal, template-load-error, empty-library, and template-loop submissions without switching selection or client scope. Bounded offline production-boundary RED/GREEN and owned temporary-file tests cover stale-scope refusal, loaded-project closure, same-path refresh/promotion, path-only starts, valid empty composites, template continuation, allocation unwinding, and checked cleanup failures. Guarded staging reserves its attachment-directory leaf exclusively; cancellation/refusal and zero-reference acceptance abandon only that owned leaf, while nonzero accepted attachment bytes survive. Zero-reference drafts preserve existing empty-ID/wire behavior. These are helper/model/unstarted-client-queue proofs, not live modal, OS picker, or daemon-persistence evidence; path-only external disappearance and downstream send failure remain limitations | Partial | -| Node update/rename | Dedicated rename prompt and safe typed updates | Rename retains its dedicated safe prompt. Edit Details uses `NativeForms.update` and `sendUpdateNodeForm`; its project/node identity is now owned across the modal message loop and re-resolved afterward, and changed strings are compared against the form's owned initial snapshot rather than potentially freed graph strings. Original numeric scalars and clear-versus-unchanged semantics are preserved. Production-helper tests cover source mutation, unchanged/changed typed fields, cancellation, clearing, and allocation failures. The lifetime test rejects the original borrowed baseline. Live attempts stopped at background-menu activation before reaching editor or rename actions, so app-level dispatch, cancellation, and returned model results remain unverified in this work | Partial | +| Node update/rename | Dedicated rename prompt and safe typed updates | Rename retains its dedicated safe prompt. Edit Details uses `NativeForms.update` and `sendUpdateNodeForm`; its project/node identity is now owned across the modal message loop and re-resolved afterward, and changed strings are compared against the form's owned initial snapshot rather than potentially freed graph strings. Original numeric scalars and clear-versus-unchanged semantics are preserved. Production-helper tests cover source mutation, unchanged/changed typed fields, cancellation, clearing, and allocation failures. The lifetime test rejects the original borrowed baseline. Earlier focused attempts stopped at background-menu activation before reaching editor or rename actions; a separate local live gate now accessed the background UIA root and later read native context-menu contents after foreground acquisition, but did not submit a rename or Edit Details action. App-level dispatch, cancellation, and returned model results remain unverified | 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 | Project backgrounds now expose existing Worktrees, Project Settings, and Explorer routes with captured project context; unavailable non-filesystem actions are disabled and global scope omits them. Create Edge remains present but disabled with fewer than two loops. Resolved node menus omit Stop, matching macOS; composite/template/unwired actions and the absence of Message/Memo are preserved. Tests inspect the same native HMENU builder used by `TrackPopupMenu`, including an exact disabled-item assertion that fails when Create Edge is removed. The existing gate already inspects plain/composite/unwired popup contents; its succeeded-node Stop assertion now requires absence. Focused live attempts read the real background popup but did not achieve an action/result, and a minimal native control failed foreground acquisition before opening its menu; the cause remains unproven. Live node/edge/background action results, New Child live proof, and import/export remain deferred; custody child creation and sketch promotion retain their separate Partial evidence rows | Partial | +| Canvas context menu | Folder actions on background; complete node/edge actions | Project backgrounds now expose existing Worktrees, Project Settings, and Explorer routes with captured project context; unavailable non-filesystem actions are disabled and global scope omits them. Create Edge remains present but disabled with fewer than two loops. Resolved node menus omit Stop, matching macOS; composite/template/unwired actions and the absence of Message/Memo are preserved. Tests inspect the same native HMENU builder used by `TrackPopupMenu`, including an exact disabled-item assertion that fails when Create Edge is removed. The existing gate already inspects plain/composite/unwired popup contents; its succeeded-node Stop assertion now requires absence. Earlier focused attempts read a background popup without completing an action, and a separate minimal native control failed foreground acquisition for an unproven reason; neither blocks background UIA root inspection. A new local live gate run acquired real foreground, read a 12-item native project context menu and dismissed it, but did not prove node/edge/background action results, New Child, import/export, custody child creation, or sketch promotion. The gate remains intermittently unstable | Partial | | Sketch promotion | Promote a sketch to Goal, Turn, or Timed without replacing its identity or session | Canvas and sidebar context menus use one production submenu plan and focused native forms. Owned popup/form snapshots reject stale scope, selection, type, and deletion; initial selection of a different cached project remains supported independently of the observation subscription. Filtered pure tests exercise the actual App selection adapter, typed native-form builder, and unstarted DaemonClient outbound queue, including composite addressing and allocation failures. Three emitted fixtures decode as existing Swift `promoteNode` variants, and the full Windows application builds ReleaseSafe. A native Win32 test attaches the production node popup to a hidden HWND and checks the Goal/Turn/Timed submenu's presence, IDs, labels, and enabled states through the actual HMENU; it does not show or select the popup. Local command construction/queueing and this native menu state are proven, but native keyboard interaction, UIA, app launch, and real-daemon acceptance/persistence remain unexercised | Partial | ## Quick Chats