From b8140f1833185cf8a8b744e445e8a08b3f4eae07 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Thu, 24 Sep 2026 15:19:14 -0700 Subject: [PATCH] Prove Show in Graph live navigation Add exact action/result and same-loop return assertions to the Windows UIA gate, and record the focused RED/GREEN evidence while retaining Partial parity status. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/uia-live-gate.ps1 | 145 ++++++++++++++++++++++++++++-- investigation/ui-parity-matrix.md | 2 +- 2 files changed, 139 insertions(+), 8 deletions(-) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 473c5c64..d83c8eb4 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -797,6 +797,15 @@ function Ensure-ShellForeground( # silently-passing check. That is exactly the transient class the original comment # described. Unlike the ENA case, this can't make an assertion vacuous: it always # throws (never returns a masking @()) unless it genuinely recovers within budget. +function Test-RetryableUiaError([System.Management.Automation.ErrorRecord] $errorRecord) { + $exception = $errorRecord.Exception + $inner = $exception.InnerException + return ($exception -is [System.Runtime.InteropServices.COMException]) -or + ($inner -is [System.Runtime.InteropServices.COMException]) -or + ($exception -is [System.Windows.Automation.ElementNotAvailableException]) -or + ($inner -is [System.Windows.Automation.ElementNotAvailableException]) +} + function Get-DirectChildren( [System.Windows.Automation.AutomationElement] $element, [System.Windows.Automation.TreeWalker] $walker @@ -841,10 +850,7 @@ function Get-DirectChildren( # exception types get a bounded, wall-clock-limited chance to resolve # before that unconditional throw. $inner = $_.Exception.InnerException - $isRetryable = ($_.Exception -is [System.Runtime.InteropServices.COMException]) -or - ($inner -is [System.Runtime.InteropServices.COMException]) -or - ($_.Exception -is [System.Windows.Automation.ElementNotAvailableException]) -or - ($inner -is [System.Windows.Automation.ElementNotAvailableException]) + $isRetryable = Test-RetryableUiaError $_ $hresult = if ($inner) { $inner.HResult } else { $_.Exception.HResult } $innerType = if ($inner) { $inner.GetType().FullName } else { "" } $attempt++ @@ -981,16 +987,21 @@ function Wait-ForGraphChildren( [int] $delayMs = 100 ) { $observed = @() + $children = @() $liveGraph = $null for ($attempt = 0; $attempt -lt $maxAttempts; $attempt++) { $liveGraph = Find-FragmentById $root "graph" $walker if ($null -ne $liveGraph) { - $observed = @(Get-DirectChildren $liveGraph $walker | Where-Object $filter) + $children = @(Get-DirectChildren $liveGraph $walker) + $observed = @($children | Where-Object $filter) if (& $until $observed) { break } + } else { + $children = @() + $observed = @() } Start-Sleep -Milliseconds $delayMs } - return [pscustomobject]@{ Graph = $liveGraph; Items = $observed } + return [pscustomobject]@{ Graph = $liveGraph; Items = $observed; Children = $children } } # A modal teardown (e.g. dismissing the update-offer dialog via SendCommand) @@ -1055,6 +1066,7 @@ function Wait-ForRootReconnect( break } } catch { + if (-not (Test-RetryableUiaError $_)) { throw } $lastException = $_ Write-Host "UIA_ROOT_RECONNECT_RETRY attempt=$attempt type=$($_.Exception.GetType().FullName) message=$($_.Exception.Message)" } @@ -2292,6 +2304,7 @@ try { $workspaceCards[0].GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern).Current.IsSelected) ` "loop invocation did not transition to the selected workspace loop" $workspaceToolbar = $null + $workspaceLoopBar = $null $workspaceShowGraph = $null $workspaceTabs = @() $workspaceControls = @() @@ -2314,6 +2327,10 @@ try { $workspaceToolbar = @($workspaceChildren | Where-Object { $_.Current.AutomationId -match '^workspace-toolbar-' -and $_.Current.Name -eq "UIA project" }) | Select-Object -First 1 + $workspaceLoopBar = @($workspaceChildren | Where-Object { + $_.Current.AutomationId -match '^workspace-loop-bar-' -and + $_.Current.Name -eq "Selected loop workspace" + }) | Select-Object -First 1 $workspaceShowGraph = @($workspaceChildren | Where-Object { $_.Current.AutomationId -match '^workspace-show-graph-' -and $_.Current.Name -eq "Show in Graph" }) | Select-Object -First 1 @@ -2336,7 +2353,8 @@ try { $workspaceUsage = @($workspaceChildren | Where-Object { $_.Current.AutomationId -match '^workspace-detail-usage-' -and $_.Current.Name -match 'tokens$' }) | Select-Object -First 1 - if (($null -ne $workspaceToolbar) -and ($null -ne $workspaceShowGraph) -and + if (($null -ne $workspaceToolbar) -and ($null -ne $workspaceLoopBar) -and + ($null -ne $workspaceShowGraph) -and ($workspaceTabs.Count -ge 1) -and ($workspaceControls.Count -eq 3) -and ($null -ne $workspacePanelToggle) -and ($null -ne $workspaceSparkline) -and ($null -ne $workspaceStart) -and ($null -ne $workspaceUsage)) { @@ -2346,6 +2364,8 @@ try { } Require ($null -ne $workspaceToolbar) ` "workspace chrome omitted the toolbar identity child" + Require ($null -ne $workspaceLoopBar) ` + "workspace chrome omitted the selected-loop identity child" Require ($null -ne $workspaceShowGraph) ` "workspace chrome omitted the Show in Graph child" Require ($workspaceControls.Count -eq 3) ` @@ -2356,6 +2376,12 @@ try { Require ($null -ne $workspaceSparkline) "workspace right panel omitted metric sparkline child" Require ($null -ne $workspaceStart) "workspace right panel omitted start-time child" Require ($null -ne $workspaceUsage) "workspace right panel omitted token-usage child" + $expectedShowGraphCardId = $workspaceCards[0].Current.AutomationId + $expectedShowGraphLoopRowId = $activeLoopRow.Current.AutomationId + $expectedWorkspaceToolbarId = $workspaceToolbar.Current.AutomationId + $expectedWorkspaceLoopBarId = $workspaceLoopBar.Current.AutomationId + $expectedWorkspaceShowGraphId = $workspaceShowGraph.Current.AutomationId + # The four detail children can be present in the UIA tree before the loop panel has # laid them out, so existence (which the mount loop above waits for) does not imply # non-empty bounds. They are also served by a custom fragment provider that destroys @@ -2480,6 +2506,111 @@ try { Require (($workspaceTabs[0].Current.AutomationId -eq $initialWorkspaceTabId) -and ($workspaceTabs[1].Current.AutomationId -eq $newWorkspaceTabId)) ` "switching back from the mounted background tab changed terminal tab identity" + + # Resolve the current workspace generation after the tab round trip, then invoke + # Show in Graph once. Poll only for the positive destination precondition: the + # exact selected card must exist. Workspace-chrome absence is asserted separately + # from the same successful generation, so an unavailable tree cannot pass as empty. + $showGraphActionProbe = Wait-ForGraphChildren $root $rawWalker ` + { $_.Current.AutomationId -in @($expectedWorkspaceLoopBarId, $expectedWorkspaceShowGraphId) } ` + { param($items) + (@($items | Where-Object { $_.Current.AutomationId -eq $expectedWorkspaceLoopBarId }).Count -eq 1) -and + (@($items | Where-Object { $_.Current.AutomationId -eq $expectedWorkspaceShowGraphId }).Count -eq 1) } + $graph = $showGraphActionProbe.Graph + $workspaceShowGraph = @($showGraphActionProbe.Items | Where-Object { + $_.Current.AutomationId -eq $expectedWorkspaceShowGraphId -and + $_.Current.Name -eq "Show in Graph" + }) + Require ($workspaceShowGraph.Count -eq 1) ` + "workspace round trip did not retain the exact Show in Graph action identity" + $workspaceShowGraph[0].GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + + $root = Wait-ForRootReconnect $process @($rawWalker) + $showGraphProbe = Wait-ForGraphChildren $root $rawWalker ` + { $_.Current.AutomationId -eq $expectedShowGraphCardId } ` + { param($items) $items.Count -eq 1 } ` + -maxAttempts 100 + $graph = $showGraphProbe.Graph + Require ($null -ne $graph) "Show in Graph did not expose the project graph" + $showGraphChildren = @($showGraphProbe.Children) + $showGraphCards = @($showGraphChildren | Where-Object { + $_.Current.AutomationId -eq $expectedShowGraphCardId -and + $_.Current.Name -eq "UIA loop A" + }) + Require ($showGraphCards.Count -eq 1) ` + "Show in Graph did not expose exactly one card with the selected loop's stable identity" + $remainingWorkspaceChrome = @($showGraphChildren | Where-Object { + $_.Current.AutomationId -match '^workspace-(toolbar|show-graph|loop-bar|tab|new-tab|split-right|split-down)-' + }) + Require ($remainingWorkspaceChrome.Count -eq 0) ` + "Show in Graph retained workspace chrome: $(@($remainingWorkspaceChrome | ForEach-Object { $_.Current.AutomationId }) -join '|')" + Require ($showGraphCards[0].GetCurrentPattern( + [System.Windows.Automation.SelectionItemPattern]::Pattern + ).Current.IsSelected) ` + "Show in Graph did not retain selection of the exact UIA loop A card" + $process.Refresh() + Require (-not $process.HasExited) "Show in Graph terminated the shell" + + # Cards are intentionally non-invokable. Return through the supported sidebar + # row, asserting its exact stable identity before invoking it, then require the + # reopened workspace to expose the same project and selected-loop identities. + $showGraphSidebar = Find-FragmentById $root "loops" $rawWalker + Require ($null -ne $showGraphSidebar) "Show in Graph result omitted the sidebar loop list" + $showGraphLoopRows = @(Get-DirectChildren $showGraphSidebar $rawWalker | Where-Object { + $_.Current.AutomationId -eq $expectedShowGraphLoopRowId -and + $_.Current.Name -eq "UIA loop A" + }) + Require ($showGraphLoopRows.Count -eq 1) ` + "Show in Graph result omitted the selected loop's exact sidebar identity" + $showGraphLoopRows[0].GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + + $root = Wait-ForRootReconnect $process @($rawWalker) + $roundTripProbe = Wait-ForGraphChildren $root $rawWalker ` + { $_.Current.AutomationId -in @( + $expectedShowGraphCardId, + $expectedWorkspaceToolbarId, + $expectedWorkspaceLoopBarId, + $expectedWorkspaceShowGraphId + ) } ` + { param($items) + (@($items | Where-Object { $_.Current.AutomationId -eq $expectedShowGraphCardId }).Count -eq 1) -and + (@($items | Where-Object { $_.Current.AutomationId -eq $expectedWorkspaceToolbarId }).Count -eq 1) -and + (@($items | Where-Object { $_.Current.AutomationId -eq $expectedWorkspaceLoopBarId }).Count -eq 1) -and + (@($items | Where-Object { $_.Current.AutomationId -eq $expectedWorkspaceShowGraphId }).Count -eq 1) } + $graph = $roundTripProbe.Graph + $roundTripSelectedCard = @($roundTripProbe.Items | Where-Object { + $_.Current.AutomationId -eq $expectedShowGraphCardId -and $_.Current.Name -eq "UIA loop A" + }) + $roundTripToolbar = @($roundTripProbe.Items | Where-Object { + $_.Current.AutomationId -eq $expectedWorkspaceToolbarId -and $_.Current.Name -eq "UIA project" + }) + $roundTripLoopBar = @($roundTripProbe.Items | Where-Object { + $_.Current.AutomationId -eq $expectedWorkspaceLoopBarId -and + $_.Current.Name -eq "Selected loop workspace" + }) + $roundTripShowGraph = @($roundTripProbe.Items | Where-Object { + $_.Current.AutomationId -eq $expectedWorkspaceShowGraphId -and + $_.Current.Name -eq "Show in Graph" + }) + Require (($roundTripSelectedCard.Count -eq 1) -and + $roundTripSelectedCard[0].GetCurrentPattern( + [System.Windows.Automation.SelectionItemPattern]::Pattern + ).Current.IsSelected -and + ($roundTripToolbar.Count -eq 1) -and + ($roundTripLoopBar.Count -eq 1) -and + ($roundTripShowGraph.Count -eq 1)) ` + "Show in Graph sidebar return did not reopen the same project and selected-loop workspace" + + # Rebind all provider references used below from the returned generation. + $projects = Find-FragmentById $root "projects" $rawWalker + $loops = Find-FragmentById $root "loops" $rawWalker + $overviewDestination = Find-FragmentById $root "overview-destination" $rawWalker + Require (($null -ne $projects) -and ($null -ne $loops) -and + ($null -ne $overviewDestination)) ` + "Show in Graph round trip did not restore downstream navigation providers" + $surfaceActionPatterns["overview-destination"] = $overviewDestination.GetCurrentPattern( + [System.Windows.Automation.InvokePattern]::Pattern + ) $surfaceActionPatterns["overview-destination"].Invoke() Start-Sleep -Milliseconds 150 Require ([GraphCodeUiaGateState]::PostTaggedExitCollision($process.MainWindowHandle)) ` diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index 5fbe8f56..c6f16e2b 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -109,7 +109,7 @@ Statuses: | Pane headers | agent/shell identity, backend/shell detail, focused state | Product-owned pane headers distinguish agent and shell panes, label the zmx session detail, add truthful backend: agent/backend: shell detail, and retain the explicit focused-pane accent. Focused rendering unit coverage plus the live `windows-shell` CI run (real workspace/terminal panes, run https://github.com/scgopi/GraphCode/actions/runs/35415967793) provide the side-by-side live evidence that was previously blocked | Validated | | Mounted background tabs | Switching preserves live terminal surfaces | Workspace implementation tests still cover the topology, and the live UIA gate now creates a second mounted tab, switches between the original and background tab, and asserts both tab automation identities survive the round trip without shell exit/reconnection | Partial | | Right loop panel | Minimap, upstream/downstream, fired conditions, metric sparkline, branch/start/usage footer | The full workspace right rail includes the selected-loop map, upstream/downstream cards, fired-edge coloring, edge conditions, branch/worktree identity, metric/goal detail, model tier, a metric-history sparkline from decoded samples, start-time/usage/backend footer text, a collapse/expand control that no longer reserves rail width while hidden, and dedicated UIA children for sparkline/start/usage/toggle (`workspace-detail-sparkline-*`, start, usage, and toggle automation IDs). The live UIA gate asserts those children and toggles collapse/expand, and that gate passed on `windows-shell` (run https://github.com/scgopi/GraphCode/actions/runs/35638849754, merged as PR #399) | Validated | -| Show in Graph | Visible loop-bar and menu action | The restored Loop menu and native loop bar expose Show in Graph, and focused hit testing covers the visible action. The live gate asserts that a `workspace-show-graph-*` child named `Show in Graph` is present in the real shell build's workspace chrome (`uia-live-gate.ps1:1156` discovery, `:1188` `Require`), and that assertion passes on `windows-shell`. **Presence is all that is proven.** The gate never retrieves an `InvokePattern` for this element and never invokes it, so "invokable" is not established; there is also no return-to-graph-card walkthrough in the gate (no `graph-card` assertion exists anywhere in `uia-live-gate.ps1`), and the earlier claim of one was inaccurate. Invoking Show in Graph and observing the resulting navigation back to the graph still needs live evidence | Partial | +| Show in Graph | Visible loop-bar and menu action | The live gate invokes the real `workspace-show-graph-*` loop-bar action through `InvokePattern`, requires the exact selected `UIA loop A` `canvas-card-*` identity and selected state with strict workspace-chrome absence, then returns through the exact supported `loop-row-*` identity and requires the same project toolbar, selected-loop bar, and Show in Graph identities. A focused isolated pair at `.graphcode-evidence/show-in-graph-20260924-151455` used source gate SHA-256 `AE4A2C71...`, focused harness `78E6821E...`, and shell `8E0AA9E7...`: GREEN invoked the action and completed the exact round trip (stdout `E94DCA9D...`); RED disabled only that invocation and failed the unchanged chrome-absence assertion (stderr `3198914D...`). This confirms the route without a product fix; graph cards remain intentionally non-invokable. The focused early return did not exercise later downstream-provider rebinding. A full integrated gate pass and live native Loop-menu invocation remain unverified. | Partial | **Live-gate infrastructure fix (this session):** the `windows-shell` CI job's `uia-live-gate.ps1` step was, until now, never actually exercising any of the workspace chrome above: `App.init()` unconditionally skipped `Workspace.init()` under `GRAPHCODE_UIA_GATE=1` regardless of whether a real `zmx` executable was supplied (a pre-existing guard predating this workstream), so every "Partial" row above had never been run against a real workspace at all. Fixed in `App.zig` to build the real workspace under the gate whenever `GRAPHCODE_ZMX` is present. That surfaced a second, genuine regression: the newly-real terminal surface competed for native Win32 keyboard focus with the rest of the UI after navigating away from the workspace (`App.openGlobalOverview()` and friends). Root-caused to `Workspace.poll()` (driven by the main window's 100ms `WM_TIMER`) unconditionally draining terminal output and calling `winghostty_surface_notify_accessibility_text()` regardless of workspace visibility, which kept re-asserting UI Automation focus on the terminal no matter what Win32-level focus fixes were made. Fixed by adding `Workspace.collapse()`/`Workspace.collapsed`, skipping `resize()`/`syncTopology()`'s pane refocus and terminal-output polling entirely while the workspace is hidden, plus a `WM_ACTIVATE` handler that reasserts the app's own focus policy after `DefWindowProc`'s default child-focus restoration on window reactivation. All of this is now covered by the passing `windows-shell` CI job (commits `a31813b`..`cba010f`, run https://github.com/scgopi/GraphCode/actions/runs/35415967793). Note: the separate `windows-spikes`/`windows-hardening` jobs (`validate.ps1 -Task all`) run the identical gate script but under much heavier CI load and still intermittently hit this same assertion's 15-second retry window; this has been confirmed as pre-existing, cross-branch flakiness unrelated to this workstream (an unrelated sibling branch, `coneilen-microsoft-repository-settings-parity`, shows both a pass and an unrelated failure on the same job across consecutive runs), not a regression introduced here.