diff --git a/Tools/windows/Invoke-UiCaptureWorker.ps1 b/Tools/windows/Invoke-UiCaptureWorker.ps1 index 90d1569e..042797f4 100644 --- a/Tools/windows/Invoke-UiCaptureWorker.ps1 +++ b/Tools/windows/Invoke-UiCaptureWorker.ps1 @@ -684,6 +684,11 @@ function Invoke-CapSceneAction { Assert-CapWorkerDeadline "after action $Action" } +function Send-CapMatchedEscape { + param([Parameter(Mandatory)]$AppRun) + return [GraphCodeUiaGateState]::PostKeyboard($AppRun.Window,0x1B) +} + function Measure-CapSceneZoom { param( [Parameter(Mandatory)]$AppRun, @@ -2075,11 +2080,75 @@ function Invoke-CapMatchedScenes { $zoom=Measure-CapSceneZoom -AppRun $app -Scene $probe -NaturalWidth 250 $app.SObservation=[pscustomobject]@{readSucceeded=$true;evidenceMode='OWNED_UIA_READ_NOT_VISUAL_PARITY' fixtureState=$state[0];card=$card[0];wireState='HASH_BOUND_STATIC_PEER_NOT_LIVE_BACKEND'} + $recovery=$null if ($scene -cmatch '^s-goal-unwired-') { + $wireIdentity="wire-node:$($fixture.graphs[0].project.path):$($state[0].nodeId)" + $markIdentity="mark-entry:$($fixture.graphs[0].project.path):$($state[0].nodeId)" + $wireId=Get-CapMatchedAutomationId $wireIdentity + $markId=Get-CapMatchedAutomationId $markIdentity + $graph=Find-CapAutomationElement -RootHandle $app.Window -ProcessIdentity $app.Run.Identity ` + -ExpectedProcessId $app.Run.Identity.Pid -Name 'Graph' -AutomationId 'graph' + $children=@(Get-CapDirectChildren -Parent $graph) + $wire=@($children | Where-Object {$_.Current.AutomationId -ceq $wireId -and + $_.Current.Name -ceq 'Wire it up'}) + $mark=@($children | Where-Object {$_.Current.AutomationId -ceq $markId -and + $_.Current.Name -ceq 'Mark as entry'}) + if ($wire.Count -ne 1 -or $mark.Count -ne 1) { + throw "Matched unwired Goal omitted exact inline recovery controls for $scene." + } + foreach ($control in @($wire[0],$mark[0])) { + if ($control.Current.ProcessId -ne $app.Run.Identity.Pid -or + $control.Current.BoundingRectangle.Width -le 0 -or + $control.Current.BoundingRectangle.Height -le 0) { + throw "Matched unwired Goal recovery control was not owned and visible for $scene." + } + $null=$control.GetCurrentPattern([Windows.Automation.InvokePattern]::Pattern) + } + $recovery=[pscustomobject]@{wireIdentity=$wireIdentity;wireAutomationId=$wireId + markIdentity=$markIdentity;markAutomationId=$markId} $app.SObservation | Add-Member -NotePropertyName recoveryControls -NotePropertyValue ` - 'REFERENCE_ONLY: Wire it up / Mark as entry; Windows control equivalence requires C8 review' + ([pscustomobject]@{wire=[pscustomobject]@{name='Wire it up';automationId=$wireId + bounds=@($wire[0].Current.BoundingRectangle.Left,$wire[0].Current.BoundingRectangle.Top, + $wire[0].Current.BoundingRectangle.Right,$wire[0].Current.BoundingRectangle.Bottom)} + markEntry=[pscustomobject]@{name='Mark as entry';automationId=$markId + bounds=@($mark[0].Current.BoundingRectangle.Left,$mark[0].Current.BoundingRectangle.Top, + $mark[0].Current.BoundingRectangle.Right,$mark[0].Current.BoundingRectangle.Bottom)}}) } Save-CapSceneImage -AppRun $app -SceneName $scene -ZoomEvidence $zoom -Phase $Phase + if ($null -ne $recovery) { + $null=Invoke-CapSceneAction -AppRun $app -Action 'wire-node' -SubjectId $recovery.wireIdentity + $wireStatus=Find-CapAutomationElement -RootHandle $app.Window -ProcessIdentity $app.Run.Identity ` + -ExpectedProcessId $app.Run.Identity.Pid -Name 'Choose a target loop to wire this entry' ` + -AutomationId 'status' + if ($null -eq $wireStatus) {throw "Wire it up did not expose its armed result for $scene."} + if (-not (Send-CapMatchedEscape -AppRun $app)) { + throw "Wire it up could not be cancelled after matched evidence for $scene." + } + Start-Sleep -Milliseconds 100 + $null=Invoke-CapSceneAction -AppRun $app -Action 'mark-entry' -SubjectId $recovery.markIdentity + $entryStatus=Find-CapAutomationElement -RootHandle $app.Window -ProcessIdentity $app.Run.Identity ` + -ExpectedProcessId $app.Run.Identity.Pid -Name 'Marked as an entry for this session' ` + -AutomationId 'status' + if ($null -eq $entryStatus) {throw "Mark as entry did not expose its result for $scene."} + $graph=Find-CapAutomationElement -RootHandle $app.Window -ProcessIdentity $app.Run.Identity ` + -ExpectedProcessId $app.Run.Identity.Pid -Name 'Graph' -AutomationId 'graph' + $after=@(Get-CapDirectChildren -Parent $graph) + if (@($after | Where-Object {$_.Current.AutomationId -cin @( + $recovery.wireAutomationId,$recovery.markAutomationId)}).Count -ne 0) { + throw "Mark as entry left stale unwired controls for $scene." + } + $cardAfter=@($after | Where-Object {$_.Current.AutomationId -ceq $cardId -and + $_.Current.Name -ceq $card[0].Name}) + if ($cardAfter.Count -ne 1 -or $cardAfter[0].Current.ProcessId -ne $app.Run.Identity.Pid) { + throw "Mark as entry did not preserve the owned Goal card for $scene." + } + $app.SObservation | Add-Member -NotePropertyName recoveryRoutes -NotePropertyValue ` + ([pscustomobject]@{wire=[pscustomobject]@{automationId=$recovery.wireAutomationId + result='Choose a target loop to wire this entry';cancelledWith='Escape'} + markEntry=[pscustomobject]@{automationId=$recovery.markAutomationId + result='Marked as an entry for this session';controlsRemoved=$true + cardPreserved=$cardAfter[0].Current.AutomationId}}) + } } else { $null=Invoke-CapSceneAction -AppRun $app -Action 'open-loop' -Name $node.title $total=1;$selected=0;$title=switch ($nodeIndex) {0 {'Goal'};6 {'Loop'};7 {'Agent'};default {'Copilot CLI'}} diff --git a/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp b/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp index 40f32e7a..c72515c8 100644 --- a/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp +++ b/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp @@ -459,6 +459,57 @@ static void nativeBannerInvokeChecks(IRawElementProviderSimple *provider, HWND h if (projects) projects->Release(); } +static void nativeUnwiredRecoveryChecks(IRawElementProviderSimple *provider, HWND hwnd, + IRawElementProviderFragment *root) { + const char *identities[] = { + "wire-node:C:\\fixture:goal", + "mark-entry:C:\\fixture:goal", + }; + const char *names[] = {"Wire it up", "Mark as entry"}; + const int parents[] = {4, 4}; + const int none[] = {0, 0}; + const int actions[] = {1, 1}; + const int bounds[] = {300, 120, 373, 140, 379, 120, 465, 140}; + auto update = reinterpret_cast(&gc_uia_update); + check(update(provider, "Unwired recovery", identities, names, parents, none, none, actions, + bounds, 2, 0, 1, 0, 0, 0, 0) == S_OK, + "unwired recovery publication succeeds"); + + auto *projects = child(root, NavigateDirection_FirstChild); + auto *loops = child(projects, NavigateDirection_NextSibling); + auto *worktrees = child(loops, NavigateDirection_NextSibling); + auto *graph = child(worktrees, NavigateDirection_NextSibling); + auto *wire = child(graph, NavigateDirection_FirstChild); + auto *mark_entry = child(wire, NavigateDirection_NextSibling); + check(stringProperty(wire, UIA_AutomationIdPropertyId).rfind(L"wire-node-", 0) == 0, + "Wire it up uses its stable AutomationId prefix"); + check(stringProperty(mark_entry, UIA_AutomationIdPropertyId).rfind(L"mark-entry-", 0) == 0, + "Mark as entry uses its stable AutomationId prefix"); + checkName(wire, L"Wire it up"); + checkName(mark_entry, L"Mark as entry"); + check(invokable(wire) && invokable(mark_entry), + "both unwired recovery controls expose Invoke"); + + auto *invoke = static_cast(pattern(wire, UIA_InvokePatternId)); + posted_commands.clear(); + if (invoke) { + check(invoke->Invoke() == S_OK, "Wire it up Invoke succeeds"); + invoke->Release(); + } + drainMessages(hwnd); + const uint64_t expected = 0x8000000000000000ULL | + identityPayload("wire-node:C:\\fixture:goal"); + check(posted_commands.size() == 1 && posted_commands[0] == expected, + "Wire it up posts its exact stable identity once"); + + if (mark_entry) mark_entry->Release(); + if (wire) wire->Release(); + if (graph) graph->Release(); + if (worktrees) worktrees->Release(); + if (loops) loops->Release(); + if (projects) projects->Release(); +} + static void nativeQuickChatRestartChecks(IRawElementProviderSimple *provider, HWND hwnd, IRawElementProviderFragment *root) { check(!IsWindowVisible(hwnd), "Quick Chat restart is tested against a hidden provider window"); @@ -692,6 +743,7 @@ static void nativeCommandAndScrollChecks() { if (root) { nativeHeaderOverflowChecks(provider, capture, root); nativeBannerInvokeChecks(provider, capture, root); + nativeUnwiredRecoveryChecks(provider, capture, root); nativeQuickChatRestartChecks(provider, capture, root); nativeQuickChatWorkspaceBarChecks(provider, root); nativeScrollChecks(provider, capture, root); diff --git a/Tools/windows/Tests/AccessibilityProvider.Native.Tests.ps1 b/Tools/windows/Tests/AccessibilityProvider.Native.Tests.ps1 index 7179b87c..289c293f 100644 --- a/Tools/windows/Tests/AccessibilityProvider.Native.Tests.ps1 +++ b/Tools/windows/Tests/AccessibilityProvider.Native.Tests.ps1 @@ -90,8 +90,8 @@ exit /b %test_exit% $setupPathLength = $setupPath[0].Substring(5).Length Write-Host "[native-uia] post-vcvars PATH length=$setupPathLength (cmd limit=8191)" if ($setupPathLength -ge 8191) { throw "Native accessibility provider post-vcvars PATH exceeds cmd limit: $setupPathLength" } - if (@($nativeOutput | Where-Object { $_ -eq "Native accessibility provider: 224 checks, 0 failures" }).Count -ne 1) { - throw "Native accessibility provider test-exe did not report exactly one 224-check, zero-failure summary" + if (@($nativeOutput | Where-Object { $_ -eq "Native accessibility provider: 240 checks, 0 failures" }).Count -ne 1) { + throw "Native accessibility provider test-exe did not report exactly one 240-check, zero-failure summary" } } finally { $env:TEMP = $savedTemp diff --git a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 index cc815ed8..903b878a 100644 --- a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 +++ b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 @@ -4011,6 +4011,7 @@ function Invoke-CapMatchedSceneRoutingContract { $captured=[Collections.Generic.List[object]]::new() $seeded=[Collections.Generic.List[string]]::new() $model=[pscustomobject]@{total=1;selected=0;node=$null;removed=$false;restart=0;hover=0;freezeVisibility=$false + wireArmed=$false;marked=$false selections=[Collections.Generic.List[string]]::new()} function Get-MockVisibleIndexes { if ($model.freezeVisibility) {return @(4..11)} @@ -4024,7 +4025,7 @@ function Invoke-CapMatchedSceneRoutingContract { function Start-CapOfflineSeed {param($Roots,$Phase,$SessionName) $seeded.Add($SessionName);[pscustomobject]@{mode='SIMULATED_SEED'}} function Start-CapGraphCodeApp { param($Roots,$Phase,$PipeName,$SessionPrefix) - $model.total=1;$model.selected=0;$model.node=$null;$model.removed=$false + $model.total=1;$model.selected=0;$model.node=$null;$model.removed=$false;$model.wireArmed=$false;$model.marked=$false [pscustomobject]@{Run=[pscustomobject]@{Identity=[pscustomobject]@{Pid=4242}};Window=[IntPtr]1;Phase=$Phase} } function Invoke-CapSceneAction { @@ -4040,8 +4041,41 @@ function Invoke-CapMatchedSceneRoutingContract { $model.selections.Add("${scene}:$SubjectId") $model.selected=[int]$SubjectId } + 'wire-node' {$model.wireArmed=$true} + 'mark-entry' {$model.marked=$true} } } + function New-CapMockElement([string]$Id,[string]$Name,[int]$ProcessId,[double[]]$Bounds) { + $current=[pscustomobject]@{AutomationId=$Id;Name=$Name;ProcessId=$ProcessId + BoundingRectangle=[pscustomobject]@{Left=$Bounds[0];Top=$Bounds[1];Right=$Bounds[2];Bottom=$Bounds[3] + Width=($Bounds[2]-$Bounds[0]);Height=($Bounds[3]-$Bounds[1])}} + $element=[pscustomobject]@{Current=$current} + $element | Add-Member -MemberType ScriptMethod -Name GetCurrentPattern -Value {param($Pattern) [pscustomobject]@{}} + return $element + } + function Find-CapAutomationElement { + param($RootHandle,$ProcessIdentity,$ExpectedProcessId,$Name,$AutomationId) + if ($AutomationId -ceq 'graph') {return (New-CapMockElement 'graph' 'Graph' 4242 @(0,0,1200,900))} + if ($AutomationId -ceq 'status') {return (New-CapMockElement 'status' $Name 4242 @(0,880,1200,900))} + throw "Unexpected matched routing automation lookup: $AutomationId / $Name" + } + function Get-CapDirectChildren { + param($Parent) + $rows=[Collections.Generic.List[object]]::new() + foreach ($node in $fixture.graphs[0].nodes) { + $rows.Add((New-CapMockElement (Get-CapMatchedAutomationId ("project-card:"+$fixture.graphs[0].project.path+":"+$node.id)) ` + $node.title 4242 @(250,100,500,196))) + } + if ($scene -cmatch '^s-goal-unwired-' -and -not $model.marked) { + $nodeId=@($fixture.states | Where-Object scene -CEQ $scene)[0].nodeId + $rows.Add((New-CapMockElement (Get-CapMatchedAutomationId ("wire-node:"+$fixture.graphs[0].project.path+":"+$nodeId)) ` + 'Wire it up' 4242 @(261,131,334,151))) + $rows.Add((New-CapMockElement (Get-CapMatchedAutomationId ("mark-entry:"+$fixture.graphs[0].project.path+":"+$nodeId)) ` + 'Mark as entry' 4242 @(340,131,426,151))) + } + return $rows.ToArray() + } + function Send-CapMatchedEscape {param($AppRun) $model.wireArmed=$false;return $true} function Read-CapMatchedRows { param($AppRun) if ($scene -cnotmatch '^s-tabs-') { diff --git a/Tools/windows/Tests/ValidationRunner.Tests.ps1 b/Tools/windows/Tests/ValidationRunner.Tests.ps1 index 8ad1c3b8..0c53bfcf 100644 --- a/Tools/windows/Tests/ValidationRunner.Tests.ps1 +++ b/Tools/windows/Tests/ValidationRunner.Tests.ps1 @@ -3674,6 +3674,129 @@ 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 + $uiaTokens = $null + $uiaErrors = $null + $uiaAst = [Management.Automation.Language.Parser]::ParseFile( + (Join-Path $repoRoot "Tools\windows\uia-live-gate.ps1"), + [ref]$uiaTokens, + [ref]$uiaErrors) + if ($uiaErrors.Count -ne 0) { + throw "RED: UIA live gate source does not parse" + } + $chromeClassifier = $uiaAst.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Test-MultiProjectSourceSupportedChromeId' + }, $true) + if ($null -eq $chromeClassifier) { + throw "RED: multi-project observer lacks a focused source-supported chrome classifier" + } + . ([scriptblock]::Create($chromeClassifier.Extent.Text)) + function Test-MultiProjectAutomationPrefix([string] $value, [string] $prefix) { + return $value.StartsWith($prefix, [StringComparison]::Ordinal) + } + foreach ($identity in @( + 'wire-node-1000000000000000001', + 'mark-entry-1000000000000000002', + 'overview-wire-node-1000000000000000003', + 'overview-mark-entry-1000000000000000004')) { + if (-not (Test-MultiProjectSourceSupportedChromeId $identity 'owned')) { + throw "RED: multi-project observer rejects stable recovery control $identity" + } + } + if (Test-MultiProjectSourceSupportedChromeId 'future-unknown-control-1000000000000000005' 'owned') { + throw "RED: multi-project observer weakens its census by accepting an unknown element" + } + if (Test-MultiProjectSourceSupportedChromeId 'overview-wire-node-1000000000000000003' 'foreign') { + throw "RED: multi-project observer accepts a foreign recovery control" + } + $workspaceMarkerWait = $uiaAst.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Wait-MultiProjectWorkspaceMarkerPublication' + }, $true) + if ($null -eq $workspaceMarkerWait) { + throw "RED: multi-project observer lacks a bounded workspace-marker reacquire" + } + if ($workspaceMarkerWait.Extent.Text -match + 'Invoke-MultiProjectControl|PostOwned|ClickOwned|PostMouse|InvokePattern') { + throw "RED: workspace-marker reacquire replays navigation or input" + } + . ([scriptblock]::Create($workspaceMarkerWait.Extent.Text)) + $clock = @{ Now = [DateTimeOffset]::Parse('2026-10-27T00:00:00Z') } + $reads = @{ Count = 0 } + $loopBarId = 'workspace-loop-bar-1000000000000000001' + $toolbarId = 'workspace-toolbar-1000000000000000002' + $delayed = Wait-MultiProjectWorkspaceMarkerPublication { + $reads.Count++ + if ($reads.Count -lt 3) { return @() } + return @( + [pscustomobject]@{ automationID = [pscustomobject]@{ value = $loopBarId } + ownership = 'owned'; observationReady = $true }, + [pscustomobject]@{ automationID = [pscustomobject]@{ value = $toolbarId } + ownership = 'owned'; observationReady = $true } + ) + } $loopBarId $toolbarId $clock.Now.AddSeconds(1) { $clock.Now } { + param([int] $Milliseconds) + $clock.Now = $clock.Now.AddMilliseconds($Milliseconds) + } + if ($delayed.attempts -ne 3 -or $delayed.loopBar -cne $loopBarId -or + $delayed.toolbar -cne $toolbarId) { + throw "RED: delayed exact workspace markers do not satisfy the bounded reacquire" + } + $clock.Now = [DateTimeOffset]::Parse('2026-10-27T00:00:00Z') + $permanentFailure = $null + try { + $null = Wait-MultiProjectWorkspaceMarkerPublication { + @([pscustomobject]@{ automationID = [pscustomobject]@{ value = 'workspace-loop-bar-1999999999999999999' } + ownership = 'owned'; observationReady = $true }) + } $loopBarId $toolbarId $clock.Now.AddMilliseconds(250) { $clock.Now } { + param([int] $Milliseconds) + $clock.Now = $clock.Now.AddMilliseconds($Milliseconds) + } + } catch { + $permanentFailure = $_.Exception.Message + } + if ($permanentFailure -cne 'MULTIPROJECT_WORKSPACE_MARKERS: exact owned loop bar and toolbar did not publish before the deadline') { + throw "RED: permanent or unknown workspace-marker absence no longer fails closed" + } + $cardActivation = $uiaAst.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Get-MultiProjectCardActivationRectangle' + }, $true) + if ($null -eq $cardActivation) { + throw "RED: multi-project loop activation lacks a recovery-control-safe card target" + } + . ([scriptblock]::Create($cardActivation.Extent.Text)) + [double[]]$activation = Get-MultiProjectCardActivationRectangle @(0.0, 0.0, 250.0, 96.0) + if (($activation -join ',') -cne '0,0,250,24') { + throw "RED: card activation target does not retain the source-sized title strip" + } + foreach ($recovery in @( + @(11.0, 31.0, 84.0, 51.0), + @(90.0, 31.0, 176.0, 51.0))) { + $overlaps = $activation[0] -lt $recovery[2] -and $activation[2] -gt $recovery[0] -and + $activation[1] -lt $recovery[3] -and $activation[3] -gt $recovery[1] + if ($overlaps) { + throw "RED: multi-project card activation still lands on an unwired recovery control" + } + } + $invalidActivation = $null + try { $null = Get-MultiProjectCardActivationRectangle @(0.0, 0.0, 0.0, 96.0) } + catch { $invalidActivation = $_.Exception.Message } + if ($invalidActivation -cne 'MULTIPROJECT_BOUNDS: positive card rectangle required for activation') { + throw "RED: invalid multi-project card activation bounds no longer fail closed" + } + $openLoop = $uiaAst.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Open-MultiProjectLoop' + }, $true) + if ($null -eq $openLoop -or + -not $openLoop.Extent.Text.Contains('Get-MultiProjectCardActivationRectangle $row.card.bounds')) { + throw "RED: live multi-project loop navigation still clicks the whole card rectangle" + } $designTokensSource = Get-Content (Join-Path $repoRoot "graphcode-windows\src\DesignTokens.zig") -Raw $laneLayoutSource = Get-Content (Join-Path $repoRoot "graphcode-windows\src\LaneLayout.zig") -Raw $graphCanvasSource = Get-Content (Join-Path $repoRoot "graphcode-windows\src\GraphCanvas.zig") -Raw diff --git a/Tools/windows/UI-CAPTURE-SCENARIOS.md b/Tools/windows/UI-CAPTURE-SCENARIOS.md index cf541d4a..a1d17406 100644 --- a/Tools/windows/UI-CAPTURE-SCENARIOS.md +++ b/Tools/windows/UI-CAPTURE-SCENARIOS.md @@ -358,7 +358,7 @@ observed UI state, not a `selected` property invented on the daemon's node. | Scene names | State and comparison boundary | | --- | --- | -| `s-goal-unwired-selected`, `s-goal-unwired-unselected` | Goal / `goalBased` / `awaitingInput`, no inbound or outbound edge; M6 unwired NEEDS YOU context. Both selection variants are recorded. Recovery-control equivalence is **not** inferred from the fixture or a warm face. | +| `s-goal-unwired-selected`, `s-goal-unwired-unselected` | Goal / `goalBased` / `awaitingInput`, no inbound or outbound edge; M6 unwired NEEDS YOU context. Both selection variants are recorded with owned, visible `Wire it up` and `Mark as entry` UIA controls. Each scene captures the unchanged unwired state first, then invokes the exact stable control IDs: Wire enters the existing edge-drag route and is cancelled with Escape; Mark changes the card to entry and removes both unwired controls while preserving the same card identity. These observations prove the live Windows routes, not paired macOS visual equivalence by themselves. | | `s-main-cycle-selected`, `s-main-cycle-unselected` | Main / `sketch` / `blocked`, two unfired blocking handoff edges form a closed cycle with no external entry; M1 Main-cycle context. | | `s-main-stopped-reclaim` | Actual Main / `sketch` / `stopped` ENTRY, null resolution, separate worktree binding; M4 Main STOPPED entry context. `GraphStore.swift` clears resolution outside succeeded/failed. | | `s-goal-done-reclaim` | Actual Goal / `goalBased` / `succeeded` interior, predicate resolution and separate worktree binding; controls/context only against M4, **not** an equivalent Main outcome. | diff --git a/Tools/windows/UiCaptureDriver.psm1 b/Tools/windows/UiCaptureDriver.psm1 index 80b2e82e..b6101b52 100644 --- a/Tools/windows/UiCaptureDriver.psm1 +++ b/Tools/windows/UiCaptureDriver.psm1 @@ -1023,7 +1023,8 @@ function Get-CapActionSpec { param( [Parameter(Mandatory)][ValidateSet('actual-size', 'open-project', 'overview-destination', 'open-loop', 'file-exit', 'quick-chats-destination','open-quick-chat','workspace-show-graph','workspace-toggle-panel', - 'workspace-restart-session','workspace-new-tab','workspace-split-right','workspace-select-tab','workspace-close-tab')][string]$Action, + 'workspace-restart-session','workspace-new-tab','workspace-split-right','workspace-select-tab','workspace-close-tab', + 'wire-node','mark-entry')][string]$Action, [string]$Name, [string]$SubjectId ) @@ -1066,6 +1067,20 @@ function Get-CapActionSpec { return [pscustomobject]@{AutomationId=(Get-CapMatchedAutomationId "workspace-tab-close:$SubjectId") AutomationIdPrefix=$null;Name='Close tab';CommandId=$null} } + 'wire-node' { + if ($SubjectId -cnotmatch '^wire-node:.+:[0-9A-Fa-f-]{36}$') { + throw 'Wire it up requires its exact stable control identity.' + } + return [pscustomobject]@{AutomationId=(Get-CapMatchedAutomationId $SubjectId) + AutomationIdPrefix=$null;Name='Wire it up';CommandId=$null} + } + 'mark-entry' { + if ($SubjectId -cnotmatch '^mark-entry:.+:[0-9A-Fa-f-]{36}$') { + throw 'Mark as entry requires its exact stable control identity.' + } + return [pscustomobject]@{AutomationId=(Get-CapMatchedAutomationId $SubjectId) + AutomationIdPrefix=$null;Name='Mark as entry';CommandId=$null} + } 'open-loop' { if ([string]::IsNullOrWhiteSpace($Name)) { throw 'open-loop requires a loop name.' } return [pscustomobject]@{ AutomationId = $null; AutomationIdPrefix = 'canvas-card-'; Name = $Name; CommandId = $null } @@ -1760,7 +1775,8 @@ function Invoke-CapAction { if ($Action -in @('reply', 'reclaim', 'keep')) { throw "Mutating graph action '$Action' is prohibited by the capture contract." } if ($Action -notin @('actual-size', 'open-project', 'overview-destination', 'open-loop', 'file-exit', 'quick-chats-destination','open-quick-chat','workspace-show-graph','workspace-toggle-panel', - 'workspace-restart-session','workspace-new-tab','workspace-split-right','workspace-select-tab','workspace-close-tab')) { + 'workspace-restart-session','workspace-new-tab','workspace-split-right','workspace-select-tab','workspace-close-tab', + 'wire-node','mark-entry')) { throw "Unsupported capture action '$Action'." } if ($WindowProcessResolver -and -not $Invoker) { throw 'A custom HWND resolver is allowed only with the action mock.' } @@ -1800,7 +1816,8 @@ function Invoke-CapAction { $null = Assert-CapRootProcess -RootHandle $RootHandle -ProcessIdentity $ProcessIdentity -WindowProcessResolver $WindowProcessResolver $null = Assert-CapUiaProcessIds -ExpectedProcessId $ProcessIdentity.Pid -ObservedProcessIds @([int]$current.ProcessId) if ($Action -in @('quick-chats-destination','open-quick-chat','workspace-show-graph','workspace-toggle-panel', - 'workspace-restart-session','workspace-new-tab','workspace-split-right','workspace-select-tab','workspace-close-tab')) { + 'workspace-restart-session','workspace-new-tab','workspace-split-right','workspace-select-tab','workspace-close-tab', + 'wire-node','mark-entry')) { if (-not $current.IsEnabled) { throw 'D action target is disabled.' } if ($current.ControlType -ne [System.Windows.Automation.ControlType]::Button -and $current.ControlType -ne [System.Windows.Automation.ControlType]::ListItem) { throw 'D action target has an unexpected control type.' } diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 1d879bc1..e4c1b331 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -3,7 +3,8 @@ param( [Parameter(Mandatory)] [string] $Shell, [string] $Zmx = "", [string[]] $ArgumentList = @(), - [switch] $SidebarParityOnly + [switch]$SidebarParityOnly, + [switch]$UnwiredRecoveryOnly ) $ErrorActionPreference = "Stop" @@ -4301,6 +4302,61 @@ function Read-MultiProjectMetadataProperty($element, [string] $property) { } } +function Test-MultiProjectSourceSupportedChromeId([string] $identity, [string] $ownership) { + if ($ownership -cne "owned") { return $false } + $staticIds = @("canvas-primary-action", "zoom-out", "actual-size", "zoom-in", "fit-canvas", + "overview-destination", "quick-chats-destination") + $chromePrefixes = @("sidebar-section-", "project-row-", "project-new-loop-", "project-disclosure-", + "quick-chats-header-", "quick-chat-new-", "quick-chats-disclosure-", "quick-chat-row-", "sidebar-update-banner-", + "header-attention-", "header-worktree-", "header-jump-", "header-toggle-panel-", "canvas-attention-review-", + "wire-node-", "mark-entry-", "overview-wire-node-", "overview-mark-entry-", + "workspace-show-graph-", "workspace-stop-", "workspace-toggle-panel-", "workspace-detail-sparkline-", + "workspace-detail-start-", "workspace-detail-usage-", "workspace-tab-", "workspace-tab-close-", + "workspace-new-tab-", "workspace-split-right-", "workspace-split-down-") + return $identity -cmatch '^workspace-restart-session-[0-9]+$' -or + $staticIds -ccontains $identity -or @($chromePrefixes | Where-Object { + Test-MultiProjectAutomationPrefix $identity $_ + }).Count -gt 0 +} + +function Wait-MultiProjectWorkspaceMarkerPublication( + [scriptblock] $ReadSnapshot, + [string] $ExpectedLoopBarId, + [string] $ExpectedToolbarId, + [DateTimeOffset] $DeadlineUtc, + [scriptblock] $Now = { [DateTimeOffset]::UtcNow }, + [scriptblock] $Pause = { param([int] $Milliseconds) Start-Sleep -Milliseconds $Milliseconds } +) { + $attempts = 0 + while ((& $Now) -lt $DeadlineUtc) { + $attempts++ + $rows = @(& $ReadSnapshot) + $workspaceRows = @($rows | Where-Object { + (Test-MultiProjectAutomationPrefix $_.automationID.value "workspace-loop-bar-") -or + (Test-MultiProjectAutomationPrefix $_.automationID.value "workspace-toolbar-") + }) + $loopBars = @($workspaceRows | Where-Object { + $_.automationID.value -ceq $ExpectedLoopBarId -and + $_.ownership -ceq "owned" -and $_.observationReady + }) + $toolbars = @($workspaceRows | Where-Object { + $_.automationID.value -ceq $ExpectedToolbarId -and + $_.ownership -ceq "owned" -and $_.observationReady + }) + if ($workspaceRows.Count -eq 2 -and $loopBars.Count -eq 1 -and $toolbars.Count -eq 1) { + return [ordered]@{ + attempts = $attempts + loopBar = $ExpectedLoopBarId + toolbar = $ExpectedToolbarId + } + } + $remaining = [int][Math]::Floor(($DeadlineUtc - (& $Now)).TotalMilliseconds) + if ($remaining -le 0) { break } + & $Pause ([Math]::Min(100, $remaining)) + } + throw "MULTIPROJECT_WORKSPACE_MARKERS: exact owned loop bar and toolbar did not publish before the deadline" +} + function Get-MultiProjectFragmentMetadata($element, [int] $expectedProcessId) { $pidFirst = Read-MultiProjectMetadataProperty $element "ProcessId" $identity = Read-MultiProjectMetadataProperty $element "AutomationId" @@ -4314,19 +4370,8 @@ function Get-MultiProjectFragmentMetadata($element, [int] $expectedProcessId) { elseif (Test-MultiProjectAutomationPrefix $identity.value "open-project-") { $family = "project-fragment" } elseif ((Test-MultiProjectAutomationPrefix $identity.value "workspace-loop-bar-") -or (Test-MultiProjectAutomationPrefix $identity.value "workspace-toolbar-")) { $family = "workspace-marker" } - elseif ($ownership -ceq "owned") { - $staticIds = @("canvas-primary-action", "zoom-out", "actual-size", "zoom-in", "fit-canvas", - "overview-destination", "quick-chats-destination") - $chromePrefixes = @("sidebar-section-", "project-row-", "project-new-loop-", "project-disclosure-", - "quick-chats-header-", "quick-chat-new-", "quick-chats-disclosure-", "quick-chat-row-", "sidebar-update-banner-", - "header-attention-", "header-worktree-", "header-jump-", "header-toggle-panel-", "canvas-attention-review-", - "workspace-show-graph-", "workspace-stop-", "workspace-toggle-panel-", "workspace-detail-sparkline-", - "workspace-detail-start-", "workspace-detail-usage-", "workspace-tab-", "workspace-tab-close-", - "workspace-new-tab-", "workspace-split-right-", "workspace-split-down-") - if ($identity.value -cmatch '^workspace-restart-session-[0-9]+$' -or - $staticIds -ccontains $identity.value -or @($chromePrefixes | Where-Object { - Test-MultiProjectAutomationPrefix $identity.value $_ - }).Count -gt 0) { $family = "source-supported-chrome" } + elseif (Test-MultiProjectSourceSupportedChromeId $identity.value $ownership) { + $family = "source-supported-chrome" } } $ready = $ownership -cin @("owned", "foreign") -and $identity.state -ceq "available" -and @@ -5310,6 +5355,14 @@ function Get-MultiProjectClippedRectangle([double[]] $rect, [double[]] $canvas) return ,$clipped } +function Get-MultiProjectCardActivationRectangle([double[]] $card) { + if ($card.Count -ne 4 -or $card[2] -le $card[0] -or $card[3] -le $card[1]) { + throw "MULTIPROJECT_BOUNDS: positive card rectangle required for activation" + } + $height = $card[3] - $card[1] + return ,@($card[0], $card[1], $card[2], ($card[1] + [Math]::Max(1, $height * 0.25))) +} + function Get-MultiProjectPeerCounts($peer) { return [ordered]@{ receivedCount = $peer.receivedCount; requestCount = $peer.requestCount @@ -5817,6 +5870,7 @@ function Invoke-MultiProjectRenamePhase { (Test-MultiProjectAutomationPrefix $_.automationID.value "workspace-toolbar-")) }).Count } $matches = $matches -and (Test-MultiProjectObservedRoster $surface $fragmentProjection $owners $selectedOwner $multiProcess.Id) + $workspaceBaseMatched = $matches $matches = Test-MultiProjectObservedSurface $surface $matches $workspaceProjection $selectedOwner $multiProcess.Id $expectedProjectIds = @($owners | ForEach-Object { Get-MultiProjectAutomationId "open-project" $_.path }) $expectedCanvasRoster = Get-MultiProjectExpectedCanvasRoster $surface $owners $selectedOwner @@ -5862,6 +5916,7 @@ function Invoke-MultiProjectRenamePhase { foreignProjectFragmentCount = $foreignProjectCount; foreignCardFragmentCount = $foreignCardCount projectRowPolicy = "Exactly two isolated ordinary open-project rows; static Graph destination is not an ordinary project row. Any extra reserved-global row fails; no empty-global UIA lane assertion." workspaceMarkers = $workspaceProjection + workspaceBaseMatched = $workspaceBaseMatched workspaceProofLimit = "Workspace proof requires both owned markers emitted by App only when surface is workspace and its provider exists; absent chrome is a blocker, not project-card/selection proof." canvasBounds = $canvas } } @@ -5888,6 +5943,26 @@ function Invoke-MultiProjectRenamePhase { if ($observation.matched) { return $observation } Start-Sleep -Milliseconds 100 } + if ($surface -ceq "workspace" -and $observation.metadataReady -and $null -ne $selectedOwner) { + $expectedLoopBarId = Get-MultiProjectAutomationId "workspace-loop-bar" $selectedOwner.node + $expectedToolbarId = Get-MultiProjectAutomationId "workspace-toolbar" $selectedOwner.path + $markerDeadline = [DateTimeOffset]::UtcNow.AddSeconds(30) + $markerProof = Wait-MultiProjectWorkspaceMarkerPublication { + $multiProcess.Refresh() + Require (-not $multiProcess.HasExited) "multi-project shell exited while awaiting workspace markers" + $multiRoot = [System.Windows.Automation.AutomationElement]::FromHandle($multiWindow) + $graph = Find-FragmentByIdWithRetry $multiRoot "graph" $rawWalker + if ($null -eq $graph) { return @() } + return @(Get-DirectChildren $graph $rawWalker | ForEach-Object { + Get-MultiProjectFragmentMetadata $_ $multiProcess.Id + }) + } $expectedLoopBarId $expectedToolbarId $markerDeadline + Write-Host ("UIA_MULTIPROJECT_WORKSPACE_MARKER_REACQUIRE=" + + ($markerProof | ConvertTo-Json -Compress)) + $observation = Get-MultiProjectObservation $surface $selectedOwner + Write-Host ("UIA_MULTIPROJECT_OBSERVATION=" + ($observation | ConvertTo-Json -Depth 6 -Compress)) + if ($observation.matched) { return $observation } + } Write-UiaWaitAttribution "multi-project observation: $surface" $observationStartedUtc 0 ` $multiProcess.Id $multiWindow ([ordered]@{ attempts = $maximumAttempts }) $limit = if ($surface -ceq "workspace") { "mandatory owned workspace provider chrome remains unproved" } else { "complete source-supported node/caption roster remains unproved; workspace navigation was not requested" } @@ -5954,7 +6029,8 @@ function Invoke-MultiProjectRenamePhase { Invoke-MultiProjectControl "actual-size" $overview = Wait-MultiProjectObservation "overview" $row = @($overview.owners | Where-Object { $_.projectPath -ceq $owner.path })[0] - $click = Click-MultiProjectRectangle $row.card.bounds $overview.canvasBounds + $activation = Get-MultiProjectCardActivationRectangle $row.card.bounds + $click = Click-MultiProjectRectangle $activation $overview.canvasBounds $postedFallback = $false try { $workspace = Wait-MultiProjectObservation "workspace" $owner 10 @@ -5966,6 +6042,7 @@ function Invoke-MultiProjectRenamePhase { $workspace = Wait-MultiProjectObservation "workspace" $owner } $navigation.Add([ordered]@{ action = "shown overview loop" + sourceGeometry = [ordered]@{ card = $row.card.bounds; activation = $activation } input = [ordered]@{ native = $click; postedFallback = $postedFallback }; result = $workspace }) return $workspace } @@ -7077,6 +7154,95 @@ try { nonOverlapping = $true viewportIntersectionRequired = $true } | ConvertTo-Json -Compress -Depth 5)) + if ($UnwiredRecoveryOnly) { + Require ([GraphCodeUiaGateState]::PostFixtureMutation($shellWindow, 16)) ` + "unwired recovery fixture mutation was rejected" + Start-Sleep -Milliseconds 150 + $fitRecoveryCanvas = Find-FragmentByIdWithRetry $root "fit-canvas" $rawWalker + Require ($null -ne $fitRecoveryCanvas) ` + "unwired recovery fixture could not find Fit Canvas" + $fitRecoveryCanvas.GetCurrentPattern( + [System.Windows.Automation.InvokePattern]::Pattern + ).Invoke() + $root = Wait-ForRootReconnect $process @($rawWalker, $controlWalker) + $graph = Find-FragmentByIdWithRetry $root "graph" $rawWalker + $recoveryChildren = @(Get-DirectChildren $graph $rawWalker) + $wireControls = @($recoveryChildren | Where-Object { + $_.Current.AutomationId -match '^wire-node-' -and $_.Current.Name -ceq 'Wire it up' + }) + $markEntryControls = @($recoveryChildren | Where-Object { + $_.Current.AutomationId -match '^mark-entry-' -and $_.Current.Name -ceq 'Mark as entry' + }) + Require ($wireControls.Count -eq 1) ` + "unwired recovery fixture did not expose exactly one stable Wire it up control" + Require ($markEntryControls.Count -eq 1) ` + "unwired recovery fixture did not expose exactly one stable Mark as entry control" + foreach ($control in @($wireControls[0], $markEntryControls[0])) { + Require (($control.Current.BoundingRectangle.Width -gt 0) -and + ($control.Current.BoundingRectangle.Height -gt 0)) ` + "$($control.Current.Name) exposed empty bounds" + $null = $control.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern) + } + + $wireAutomationId = [string]$wireControls[0].Current.AutomationId + $markEntryAutomationId = [string]$markEntryControls[0].Current.AutomationId + $wireControls[0].GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + $wireStatus = $null + for ($attempt = 0; $attempt -lt 40; $attempt++) { + $wireStatus = Find-FragmentById $root "status" $rawWalker + if ($null -ne $wireStatus -and + $wireStatus.Current.Name -ceq 'Choose a target loop to wire this entry') { break } + Start-Sleep -Milliseconds 50 + } + Require (($null -ne $wireStatus) -and + ($wireStatus.Current.Name -ceq 'Choose a target loop to wire this entry')) ` + "Wire it up Invoke did not enter the existing edge-drag route" + $wireStatusText = [string]$wireStatus.Current.Name + Require ([GraphCodeUiaGateState]::PostKeyboard($shellWindow, 0x1B)) ` + "Wire it up edge-drag route could not be cancelled with Escape" + Start-Sleep -Milliseconds 100 + + $graph = Find-FragmentByIdWithRetry $root "graph" $rawWalker + $markEntryControl = @(Get-DirectChildren $graph $rawWalker | Where-Object { + $_.Current.AutomationId -ceq $markEntryAutomationId -and + $_.Current.Name -ceq 'Mark as entry' + }) | Select-Object -First 1 + Require ($null -ne $markEntryControl) ` + "Mark as entry disappeared before its stable Invoke route was exercised" + $markEntryControl.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + $postMarkChildren = @() + for ($attempt = 0; $attempt -lt 40; $attempt++) { + $graph = Find-FragmentByIdWithRetry $root "graph" $rawWalker + $postMarkChildren = @(Get-DirectChildren $graph $rawWalker) + if (@($postMarkChildren | Where-Object { + $_.Current.AutomationId -in @($wireAutomationId, $markEntryAutomationId) + }).Count -eq 0) { break } + Start-Sleep -Milliseconds 50 + } + Require (@($postMarkChildren | Where-Object { + $_.Current.AutomationId -in @($wireAutomationId, $markEntryAutomationId) + }).Count -eq 0) ` + "Mark as entry did not change the graph role and remove the unwired recovery controls" + $markedCard = @($postMarkChildren | Where-Object { + $_.Current.AutomationId -match '^canvas-card-' -and $_.Current.Name -ceq 'UIA loop C' + }) + Require ($markedCard.Count -eq 1) ` + "Mark as entry did not preserve the same stable loop card" + $markedStatus = Find-FragmentById $root "status" $rawWalker + Require (($null -ne $markedStatus) -and + ($markedStatus.Current.Name -ceq 'Marked as an entry for this session')) ` + "Mark as entry did not report its existing graph-model result" + Write-Host ("UIA_UNWIRED_RECOVERY_EVIDENCE=" + ([ordered]@{ + mode = "focused-live-gate" + wireAutomationId = $wireAutomationId + wireStatus = $wireStatusText + markEntryAutomationId = $markEntryAutomationId + markEntryStatus = [string]$markedStatus.Current.Name + controlsRemovedAfterEntry = $true + cardPreserved = [string]$markedCard[0].Current.AutomationId + } | ConvertTo-Json -Compress)) + return + } foreach ($card in $projectCards) { $null = $card.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern) $null = $card.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern) @@ -9499,6 +9665,83 @@ try { Require (@($unwiredLoopItems | Where-Object { $_.Id -eq $absentId }).Count -eq 0) ` "unwired loop context menu unexpectedly offered composite-only command $absentId : $unwiredLoopDescription" } + + $graph = Find-FragmentByIdWithRetry $root "graph" $rawWalker + $unwiredRecoveryChildren = @(Get-DirectChildren $graph $rawWalker) + $wireControls = @($unwiredRecoveryChildren | Where-Object { + $_.Current.AutomationId -match '^wire-node-' -and $_.Current.Name -ceq 'Wire it up' + }) + $markEntryControls = @($unwiredRecoveryChildren | Where-Object { + $_.Current.AutomationId -match '^mark-entry-' -and $_.Current.Name -ceq 'Mark as entry' + }) + Require ($wireControls.Count -eq 1) ` + "unwired canvas card did not expose exactly one stable Wire it up control" + Require ($markEntryControls.Count -eq 1) ` + "unwired canvas card did not expose exactly one stable Mark as entry control" + foreach ($control in @($wireControls[0], $markEntryControls[0])) { + Require (($control.Current.BoundingRectangle.Width -gt 0) -and + ($control.Current.BoundingRectangle.Height -gt 0)) ` + "$($control.Current.Name) exposed empty bounds" + $null = $control.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern) + } + + $wireAutomationId = [string]$wireControls[0].Current.AutomationId + $markEntryAutomationId = [string]$markEntryControls[0].Current.AutomationId + $wireControls[0].GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + $wireStatus = $null + for ($attempt = 0; $attempt -lt 40; $attempt++) { + $wireStatus = Find-FragmentById $root "status" $rawWalker + if ($null -ne $wireStatus -and + $wireStatus.Current.Name -ceq 'Choose a target loop to wire this entry') { break } + Start-Sleep -Milliseconds 50 + } + Require (($null -ne $wireStatus) -and + ($wireStatus.Current.Name -ceq 'Choose a target loop to wire this entry')) ` + "Wire it up Invoke did not enter the existing edge-drag route" + $wireStatusText = [string]$wireStatus.Current.Name + Require ([GraphCodeUiaGateState]::PostKeyboard($shellWindow, 0x1B)) ` + "Wire it up edge-drag route could not be cancelled with Escape" + Start-Sleep -Milliseconds 100 + + $graph = Find-FragmentByIdWithRetry $root "graph" $rawWalker + $markEntryControl = @(Get-DirectChildren $graph $rawWalker | Where-Object { + $_.Current.AutomationId -ceq $markEntryAutomationId -and + $_.Current.Name -ceq 'Mark as entry' + }) | Select-Object -First 1 + Require ($null -ne $markEntryControl) ` + "Mark as entry disappeared before its stable Invoke route was exercised" + $markEntryControl.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + $postMarkChildren = @() + for ($attempt = 0; $attempt -lt 40; $attempt++) { + $graph = Find-FragmentByIdWithRetry $root "graph" $rawWalker + $postMarkChildren = @(Get-DirectChildren $graph $rawWalker) + if (@($postMarkChildren | Where-Object { + $_.Current.AutomationId -in @($wireAutomationId, $markEntryAutomationId) + }).Count -eq 0) { break } + Start-Sleep -Milliseconds 50 + } + Require (@($postMarkChildren | Where-Object { + $_.Current.AutomationId -in @($wireAutomationId, $markEntryAutomationId) + }).Count -eq 0) ` + "Mark as entry did not change the graph role and remove the unwired recovery controls" + $markedCard = @($postMarkChildren | Where-Object { + $_.Current.AutomationId -match '^canvas-card-' -and $_.Current.Name -ceq 'UIA loop C' + }) + Require ($markedCard.Count -eq 1) ` + "Mark as entry did not preserve the same stable loop card" + $markedStatus = Find-FragmentById $root "status" $rawWalker + Require (($null -ne $markedStatus) -and + ($markedStatus.Current.Name -ceq 'Marked as an entry for this session')) ` + "Mark as entry did not report its existing graph-model result" + Write-Host ("UIA_UNWIRED_RECOVERY_EVIDENCE=" + ([ordered]@{ + wireAutomationId = $wireAutomationId + wireStatus = $wireStatusText + markEntryAutomationId = $markEntryAutomationId + markEntryStatus = [string]$markedStatus.Current.Name + controlsRemovedAfterEntry = $true + cardPreserved = [string]$markedCard[0].Current.AutomationId + } | ConvertTo-Json -Compress)) + $liveStatusAfterLoopMenus = Find-FragmentById $root "status" $rawWalker Require ($null -ne $liveStatusAfterLoopMenus) ` "shell UIA tree stopped answering after the loop context menus were dismissed" diff --git a/graphcode-windows/src/AccessibilityProvider.cpp b/graphcode-windows/src/AccessibilityProvider.cpp index 766112a3..6d725391 100644 --- a/graphcode-windows/src/AccessibilityProvider.cpp +++ b/graphcode-windows/src/AccessibilityProvider.cpp @@ -1267,6 +1267,10 @@ class Node final : public IRawElementProviderSimple, row.identity.rfind("quick-chat-workspace:", 0) == 0 ? L"quick-chat-workspace-" : row.identity.rfind("loop-disclosure:", 0) == 0 ? L"loop-disclosure-" : row.identity.rfind("attention-action:", 0) == 0 ? L"attention-action-" : + row.identity.rfind("wire-node:", 0) == 0 ? L"wire-node-" : + row.identity.rfind("mark-entry:", 0) == 0 ? L"mark-entry-" : + row.identity.rfind("overview-wire-node:", 0) == 0 ? L"overview-wire-node-" : + row.identity.rfind("overview-mark-entry:", 0) == 0 ? L"overview-mark-entry-" : row.identity.rfind("canvas-attention-review:", 0) == 0 ? L"canvas-attention-review-" : row.identity.rfind("workspace-toolbar:", 0) == 0 ? L"workspace-toolbar-" : row.identity.rfind("workspace-loop-bar:", 0) == 0 ? L"workspace-loop-bar-" : diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 6d172279..45ac3d62 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -1523,6 +1523,11 @@ const UiaDynamicTarget = union(enum) { path: []const u8, action: GraphCanvas.ReclaimAction, }, + unwired_action: struct { + project_path: []const u8, + node_id: []const u8, + action: GraphCanvas.UnwiredAction, + }, quick_chat: []const u8, workspace_show_graph, workspace_loop_restart, @@ -5121,6 +5126,18 @@ pub const App = struct { self.setStatus("Choose a target loop to wire this entry"); } + fn applyUnwiredAction(self: *App, index: usize, action: GraphCanvas.UnwiredAction) bool { + const graph = self.model.graph orelse return false; + if (index >= graph.nodes.items.len or !self.nodeIsUnwired(graph.nodes.items[index].id)) return false; + switch (action) { + .wire => self.beginWireSelectedNode(index), + .mark_entry => self.markSelectedNodeAsEntry(index), + } + self.syncAccessibility(); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + return true; + } + fn showNodeContextMenu(self: *App, index: usize, x: i32, y: i32) void { const graph = self.model.graph orelse return; if (index >= graph.nodes.items.len) return; @@ -8949,10 +8966,14 @@ pub const App = struct { const key = std.fmt.allocPrint(self.allocator, "{s}:{s}", .{ graph.project.path, node.id }) catch return; defer self.allocator.free(key); self.appendAccessibilityElement(&elements, &owned_identities, "project-card", key, node.title, 4, .{ .logical = bounds }, self.model.selected_index == index, false) catch return; - if (GraphCanvas.attentionActionVisible(node, graph.nodes.items, graph.edges.items)) { + if (self.nodeIsUnwired(node.id)) { + const actions = GraphCanvas.unwiredActionBounds(bounds, &self.canvas); + self.appendAccessibilityElement(&elements, &owned_identities, "wire-node", key, "Wire it up", 4, .{ .logical = actions.wire }, false, true) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "mark-entry", key, "Mark as entry", 4, .{ .logical = actions.mark_entry }, false, true) catch return; + } else if (GraphCanvas.attentionActionVisible(node, graph.nodes.items, graph.edges.items)) { self.appendAccessibilityElement(&elements, &owned_identities, "attention-action", key, GraphCanvas.attentionActionLabel(node), 4, .{ .logical = GraphCanvas.attentionActionBounds(bounds, &self.canvas) }, false, false) catch return; } - if (GraphCanvas.hasReclaimOffer(node, current_inspection, self.kept_worktree_paths.items)) { + if (!self.nodeIsUnwired(node.id) and GraphCanvas.hasReclaimOffer(node, current_inspection, self.kept_worktree_paths.items)) { const offer = GraphCanvas.reclaimOfferBounds(bounds); self.appendAccessibilityElement(&elements, &owned_identities, "reclaim", key, "Reclaim", 4, .{ .logical = offer.reclaim }, false, false) catch return; self.appendAccessibilityElement(&elements, &owned_identities, "keep", key, "Keep", 4, .{ .logical = offer.keep }, false, false) catch return; @@ -9044,6 +9065,11 @@ pub const App = struct { const key = std.fmt.allocPrint(self.allocator, "{s}:{s}", .{ graph.project.path, node.id }) catch return; defer self.allocator.free(key); self.appendAccessibilityElement(&elements, &owned_identities, "overview-card", key, node.title, 4, .{ .logical = bounds }, false, false) catch return; + if (GraphCanvas.overviewNodeRole(&self.canvas, graph_index, node_index) == .unwired) { + const actions = GraphCanvas.unwiredActionBounds(bounds, &self.canvas); + self.appendAccessibilityElement(&elements, &owned_identities, "overview-wire-node", key, "Wire it up", 4, .{ .logical = actions.wire }, false, true) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "overview-mark-entry", key, "Mark as entry", 4, .{ .logical = actions.mark_entry }, false, true) catch return; + } } }, .quick_chats => for (self.model.quick_chats.items, 0..) |chat, index| { @@ -9308,6 +9334,14 @@ pub const App = struct { defer self.allocator.free(project_card_identity); const attention_identity = std.fmt.allocPrint(self.allocator, "attention-action:{s}", .{key}) catch return false; defer self.allocator.free(attention_identity); + const wire_identity = std.fmt.allocPrint(self.allocator, "wire-node:{s}", .{key}) catch return false; + defer self.allocator.free(wire_identity); + const mark_entry_identity = std.fmt.allocPrint(self.allocator, "mark-entry:{s}", .{key}) catch return false; + defer self.allocator.free(mark_entry_identity); + const overview_wire_identity = std.fmt.allocPrint(self.allocator, "overview-wire-node:{s}", .{key}) catch return false; + defer self.allocator.free(overview_wire_identity); + const overview_mark_entry_identity = std.fmt.allocPrint(self.allocator, "overview-mark-entry:{s}", .{key}) catch return false; + defer self.allocator.free(overview_mark_entry_identity); const reclaim_identity = std.fmt.allocPrint(self.allocator, "reclaim:{s}", .{key}) catch return false; defer self.allocator.free(reclaim_identity); const keep_identity = std.fmt.allocPrint(self.allocator, "keep:{s}", .{key}) catch return false; @@ -9322,6 +9356,20 @@ pub const App = struct { if (target != null) return false; target = .{ .loop = .{ .project_path = graph.project.path, .node_id = node.id } }; } + if (Accessibility.worktreeIdentityPayload(wire_identity) == payload) { + if (target != null) return false; + target = .{ .unwired_action = .{ .project_path = graph.project.path, .node_id = node.id, .action = .wire } }; + } + if (Accessibility.worktreeIdentityPayload(mark_entry_identity) == payload) { + if (target != null) return false; + target = .{ .unwired_action = .{ .project_path = graph.project.path, .node_id = node.id, .action = .mark_entry } }; + } + if (Accessibility.worktreeIdentityPayload(overview_wire_identity) == payload or + Accessibility.worktreeIdentityPayload(overview_mark_entry_identity) == payload) + { + if (target != null) return false; + target = .{ .open_project = graph.project.path }; + } if (Accessibility.worktreeIdentityPayload(reclaim_identity) == payload) { if (target != null) return false; target = .{ .reclaim_offer = .{ .path = node.worktree_path, .action = .reclaim } }; @@ -9532,6 +9580,12 @@ pub const App = struct { self.openSelectedNode(); }, .composite_back => self.closeCompositeGroup(), + .unwired_action => |route| { + const active = self.model.graph orelse return false; + if (!std.mem.eql(u8, active.project.path, route.project_path)) return false; + const index = GraphModel.findNodeIndexByID(active.nodes.items, route.node_id) orelse return false; + if (!self.applyUnwiredAction(index, route.action)) return false; + }, .reclaim_offer => |offer| switch (offer.action) { .reclaim => self.reclaimWorktreeOffer(offer.path), .keep => self.keepWorktreeOffer(offer.path), @@ -10837,6 +10891,17 @@ fn onWindowMessage( .overview => { if (app.applyOverviewLaneAction(x, y, bounds)) { _ = c.InvalidateRect(hwnd, null, 0); + } else if (GraphCanvas.hitTestOverviewUnwiredAction(&app.model, x, y, &app.canvas, bounds)) |hit| { + const graph = app.model.graphs.items[hit.graph_index]; + if (app.selectProject(graph.project.path)) { + app.surface = .project; + app.workspace_controls.panel_visible = false; + app.layoutWorkspace(); + app.layoutEmptyStateControls(); + app.rebindWorkspace(graph.project.path); + } + app.syncAccessibility(); + _ = c.InvalidateRect(hwnd, null, 0); } else if (GraphCanvas.hitTestOverview(&app.model, x, y, &app.canvas, bounds)) |hit| { const graph = app.model.graphs.items[hit.graph_index]; _ = app.activateLoop(graph.project.path, graph.nodes.items[hit.node_index].id); @@ -10867,6 +10932,15 @@ fn onWindowMessage( app.inspectWorktrees(); } else if (GraphCanvas.hitTestCompositeBack(&app.model, x, y, bounds)) { app.closeCompositeGroup(); + } else if (GraphCanvas.hitTestUnwiredAction( + graph.nodes.items, + graph.edges.items, + app.declared_entry_ids.items, + x, + y, + &app.canvas, + )) |hit| { + _ = app.applyUnwiredAction(hit.node_index, hit.action); } else if (GraphCanvas.hitTestReclaimOffer( graph.nodes.items, app.currentWorktreeInspection(), @@ -22578,7 +22652,7 @@ test "loop card parity UIA actions share painted logical bounds and scale once" app.surface = .project; app.canvas = .{ .zoom = 1.25, .pan_x = -37, .pan_y = 23 }; _ = try app.model.updateFromFrame( - \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"project":{"path":"C:\\notice-a","name":"Card parity"},"nodes":[{"id":"question","title":"Named question","state":"running","presence":"awaitingInput"},{"id":"resolved","title":"Safe reclaim","state":"succeeded","worktreeBinding":{"path":"C:\\notice-a\\tree-1"}},{"id":"ordinary","title":"Ordinary idle","state":"idle"},{"id":"unsafe","title":"Primary checkout","state":"succeeded","worktreeBinding":{"path":"C:\\notice-a\\tree-0"}}],"edges":[]}}} + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"project":{"path":"C:\\notice-a","name":"Card parity"},"nodes":[{"id":"question","title":"Named question","state":"running","presence":"awaitingInput"},{"id":"resolved","title":"Safe reclaim","state":"succeeded","worktreeBinding":{"path":"C:\\notice-a\\tree-1"}},{"id":"ordinary","title":"Ordinary idle","state":"idle"},{"id":"unsafe","title":"Primary checkout","state":"succeeded","worktreeBinding":{"path":"C:\\notice-a\\tree-0"}}],"edges":[{"id":"question-resolved","from":"question","to":"resolved","kind":"handoff"},{"id":"resolved-ordinary","from":"resolved","to":"ordinary","kind":"handoff"},{"id":"ordinary-unsafe","from":"ordinary","to":"unsafe","kind":"handoff"}]}}} ); try std.testing.expect(app.model.selectProject("C:\\notice-a")); try installNoticeTestInspection(&app, "C:\\notice-a", 2, 1024); @@ -22600,6 +22674,97 @@ test "loop card parity UIA actions share painted logical bounds and scale once" } } +test "unwired inline recovery controls route pointer and stable UIA invokes to existing effects" { + const Sink = struct { + expected_wire: c.RECT, + expected_mark_entry: c.RECT, + wire_seen: usize = 0, + mark_entry_seen: usize = 0, + failure: ?anyerror = null, + + fn syncCanvasBounds(_: *@This(), _: c.RECT) void {} + + fn syncElements(self: *@This(), _: []const u8, elements: []const Accessibility.DynamicElement, _: WorktreeStatus.Policy, _: Accessibility.WorktreeCapabilities) void { + self.check(elements) catch |err| { + self.failure = err; + }; + } + + fn check(self: *@This(), elements: []const Accessibility.DynamicElement) !void { + for (elements) |element| { + const expected = if (std.mem.eql(u8, element.identity, "wire-node:C:\\recovery:goal")) + self.expected_wire + else if (std.mem.eql(u8, element.identity, "mark-entry:C:\\recovery:goal")) + self.expected_mark_entry + else + continue; + if (std.mem.startsWith(u8, element.identity, "wire-node:")) { + self.wire_seen += 1; + try std.testing.expectEqualStrings("Wire it up", element.name); + } else { + self.mark_entry_seen += 1; + try std.testing.expectEqualStrings("Mark as entry", element.name); + } + try std.testing.expect(element.invokable); + try std.testing.expectEqualDeep( + [4]i32{ expected.left, expected.top, expected.right, expected.bottom }, + [4]i32{ element.left, element.top, element.right, element.bottom }, + ); + } + } + }; + + var app = try overviewTestApp(96); + defer deinitOverviewTestApp(&app); + defer for (app.declared_entry_ids.items) |id| app.allocator.free(id); + app.surface = .project; + app.workspace_controls.rail_visible = false; + _ = try app.model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"project":{"path":"C:\\recovery","name":"Recovery"},"nodes":[{"id":"goal","title":"Goal","loopType":"goalBased","state":"idle"},{"id":"target","title":"Target","loopType":"sketch","state":"idle"}],"edges":[]}}} + ); + try std.testing.expect(app.model.selectProject("C:\\recovery")); + try std.testing.expect(app.refreshCanvasLayout(.{ .left = 0, .top = 0, .right = 1200, .bottom = 900 })); + const bounds = GraphCanvas.nodeBounds(0, &app.canvas); + const wire = c.RECT{ .left = bounds.left + 11, .top = bounds.top + 31, .right = bounds.left + 84, .bottom = bounds.top + 51 }; + const mark_entry = c.RECT{ .left = bounds.left + 90, .top = bounds.top + 31, .right = bounds.left + 176, .bottom = bounds.top + 51 }; + const target_bounds = GraphCanvas.nodeBounds(1, &app.canvas); + const target_mark_entry = c.RECT{ .left = target_bounds.left + 90, .top = target_bounds.top + 31, .right = target_bounds.left + 176, .bottom = target_bounds.top + 51 }; + + var sink = Sink{ .expected_wire = wire, .expected_mark_entry = mark_entry }; + app.syncAccessibilityTo(&sink, .{ .left = 0, .top = 0, .right = 1200, .bottom = 900 }); + if (sink.failure) |err| return err; + try std.testing.expectEqual(@as(usize, 1), sink.wire_seen); + try std.testing.expectEqual(@as(usize, 1), sink.mark_entry_seen); + + const wire_lparam: c.LPARAM = @intCast((wire.left + 2) | ((wire.top + 2) << 16)); + var result: c.LRESULT = 0; + try std.testing.expect(onWindowMessage(&app, app.window.hwnd, c.WM_LBUTTONDOWN, 0, wire_lparam, &result)); + try std.testing.expectEqualStrings("goal", app.edge_drag_source_id); + try std.testing.expect(app.canvas.edge_dragging); + try std.testing.expectEqualStrings("Choose a target loop to wire this entry", app.status()); + app.cancelCanvasInteraction(); + + const mark_lparam: c.LPARAM = @intCast((target_mark_entry.left + 2) | ((target_mark_entry.top + 2) << 16)); + try std.testing.expect(onWindowMessage(&app, app.window.hwnd, c.WM_LBUTTONDOWN, 0, mark_lparam, &result)); + try std.testing.expect(app.nodeIsDeclaredEntry("target")); + try std.testing.expect(!app.nodeIsUnwired("target")); + + const wire_identity = "wire-node:C:\\recovery:goal"; + try std.testing.expect(app.applyUiaDynamicInvoke(Accessibility.worktreeIdentityPayload(wire_identity))); + try std.testing.expectEqualStrings("goal", app.edge_drag_source_id); + try std.testing.expect(app.canvas.edge_dragging); + app.cancelCanvasInteraction(); + + const mark_identity = "mark-entry:C:\\recovery:goal"; + try std.testing.expect(app.applyUiaDynamicInvoke(Accessibility.worktreeIdentityPayload(mark_identity))); + try std.testing.expect(app.nodeIsDeclaredEntry("goal")); + try std.testing.expect(!app.nodeIsUnwired("goal")); + try std.testing.expectEqualStrings("Marked as an entry for this session", app.status()); + try std.testing.expect(app.refreshCanvasLayout(.{ .left = 0, .top = 0, .right = 1200, .bottom = 900 })); + try std.testing.expectEqual(@as(@TypeOf(app.canvas.project_layout.?.roles[0]), .entry), app.canvas.project_layout.?.roles[0]); + try std.testing.expectEqual(@as(@TypeOf(app.canvas.project_layout.?.roles[1]), .entry), app.canvas.project_layout.?.roles[1]); +} + fn loadActivationTestFixture(app: *App) !void { _ = try app.model.updateFromFrame( \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"project":{"path":"C:\\activation-fixture","name":"Activation fixture"},"nodes":[{"id":"stopped-loop","title":"Stopped loop","loopType":"turnBased","state":{"running":{}}},{"id":"target-loop","title":"Visible idle loop","loopType":"sketch","state":{"idle":{}}},{"id":"other-loop","title":"Unrelated loop","loopType":"turnBased","state":{"stopped":{}}}],"edges":[]}}} diff --git a/graphcode-windows/src/GraphCanvas.zig b/graphcode-windows/src/GraphCanvas.zig index 862f2ab0..d4c5cba7 100644 --- a/graphcode-windows/src/GraphCanvas.zig +++ b/graphcode-windows/src/GraphCanvas.zig @@ -865,6 +865,9 @@ pub fn headerWorktreeNotice(model: *const GraphModel.Model, inspection: *const W } pub const AttentionAction = enum { none, reply, inspect }; pub const ReclaimAction = enum { reclaim, keep }; +pub const UnwiredAction = enum { wire, mark_entry }; +pub const UnwiredHit = struct { node_index: usize, action: UnwiredAction }; +pub const OverviewUnwiredHit = struct { graph_index: usize, node_index: usize, action: UnwiredAction }; pub const ReclaimHit = struct { node_index: usize, action: ReclaimAction }; pub fn attentionRailBounds(width: i32) c.RECT { @@ -1453,6 +1456,12 @@ fn cachedOverviewLane(state: *const CanvasState, graph_index: usize) ?*const Lan return null; } +pub fn overviewNodeRole(state: *const CanvasState, graph_index: usize, node_index: usize) ?LaneLayout.Role { + const lane = cachedOverviewLane(state, graph_index) orelse return null; + if (node_index >= lane.layout.roles.len) return null; + return lane.layout.roles[node_index]; +} + pub fn overviewLaneName(graph: *const GraphModel.GraphSummary) []const u8 { return if (graph.project.isGlobal()) "No folder" else graph.project.name; } @@ -1799,6 +1808,31 @@ pub fn hitTestOverview( return null; } +pub fn hitTestOverviewUnwiredAction( + model: *const GraphModel.Model, + x: i32, + y: i32, + state: *const CanvasState, + graph_bounds: c.RECT, +) ?OverviewUnwiredHit { + if (!insideGraph(x, y, graph_bounds)) return null; + var graph_index = model.graphs.items.len; + while (graph_index > 0) { + graph_index -= 1; + const graph = &model.graphs.items[graph_index]; + const lane = cachedOverviewLane(state, graph_index) orelse continue; + var node_index = graph.nodes.items.len; + while (node_index > 0) { + node_index -= 1; + if (node_index >= lane.layout.roles.len or lane.layout.roles[node_index] != .unwired) continue; + const actions = unwiredActionBounds(overviewCardBounds(model, graph_index, node_index, graph_bounds, state), state); + if (insideGraph(x, y, actions.wire)) return .{ .graph_index = graph_index, .node_index = node_index, .action = .wire }; + if (insideGraph(x, y, actions.mark_entry)) return .{ .graph_index = graph_index, .node_index = node_index, .action = .mark_entry }; + } + } + return null; +} + pub fn hitTestQuickChat( chat_count: usize, x: i32, @@ -3009,13 +3043,13 @@ fn drawLoopCard( drawCardBody(hdc, allocator, node, bounds, state.zoom, role, attention, action, reclaim) catch |err| { std.log.warn("Card {s} presentation failed: {s}", .{ node.id, @errorName(err) }); }; - if (action) |primary_action| { + if (role != .unwired) if (action) |primary_action| { const action_bounds = attentionActionBounds(bounds, state); fill(hdc, action_bounds, 0x000A9FFF); drawTextRect(hdc, allocator, attentionActionLabelFor(primary_action), action_bounds, scaled(9, state), 0x00241703, c.DT_CENTER | c.DT_SINGLELINE | c.DT_VCENTER); - } + }; - if (show_reclaim and reclaim != null) { + if (role != .unwired and show_reclaim and reclaim != null) { const offer = reclaimOfferBounds(bounds); roundedControl(hdc, offer.reclaim, scaled(5, state), 0x003A3535, 0x00534F4F); drawCardText(hdc, allocator, "Reclaim", rect(offer.reclaim.left + scaled(9, state), offer.reclaim.top, offer.reclaim.right, offer.reclaim.bottom), 11 * state.zoom, .semibold, 0x00D7D7D7, false) catch |err| { @@ -3066,6 +3100,56 @@ fn attentionActionBoundsForZoom(bounds: c.RECT, zoom: f32) c.RECT { return rect(bounds.right - scaledValue(77, zoom), bounds.top + scaledValue(51, zoom), bounds.right - scaledValue(11, zoom), bounds.top + scaledValue(68, zoom)); } +pub const UnwiredActionBounds = struct { wire: c.RECT, mark_entry: c.RECT }; + +pub fn unwiredActionBounds(bounds: c.RECT, state: *const CanvasState) UnwiredActionBounds { + return unwiredActionBoundsForZoom(bounds, state.zoom); +} + +fn unwiredActionBoundsForZoom(bounds: c.RECT, zoom: f32) UnwiredActionBounds { + return .{ + .wire = rect( + bounds.left + scaledValue(11, zoom), + bounds.top + scaledValue(31, zoom), + bounds.left + scaledValue(84, zoom), + bounds.top + scaledValue(51, zoom), + ), + .mark_entry = rect( + bounds.left + scaledValue(90, zoom), + bounds.top + scaledValue(31, zoom), + bounds.left + scaledValue(176, zoom), + bounds.top + scaledValue(51, zoom), + ), + }; +} + +pub fn hitTestUnwiredAction( + nodes: []const GraphModel.Node, + edges: []const GraphModel.Edge, + declared_entries: []const []const u8, + x: i32, + y: i32, + state: *const CanvasState, +) ?UnwiredHit { + var index = nodes.len; + while (index > 0) { + index -= 1; + if (!nodeIsUnwired(nodes[index].id, edges, declared_entries)) continue; + const actions = unwiredActionBounds(nodeBounds(index, state), state); + if (insideGraph(x, y, actions.wire)) return .{ .node_index = index, .action = .wire }; + if (insideGraph(x, y, actions.mark_entry)) return .{ .node_index = index, .action = .mark_entry }; + } + return null; +} + +fn nodeIsUnwired(node_id: []const u8, edges: []const GraphModel.Edge, declared_entries: []const []const u8) bool { + for (edges) |edge| { + if (std.mem.eql(u8, edge.from, node_id) or std.mem.eql(u8, edge.to, node_id)) return false; + } + for (declared_entries) |id| if (std.mem.eql(u8, id, node_id)) return false; + return true; +} + pub fn attentionActionVisible(node: GraphModel.Node, nodes: []const GraphModel.Node, edges: []const GraphModel.Edge) bool { _ = nodes; _ = edges; @@ -3912,9 +3996,16 @@ fn drawCardBody( const layout = cardTextLayout(bounds, zoom, pill_width); try drawCardText(hdc, allocator, node.title, layout.title, 14.5 * zoom, .card_title, 0x00F4F4F4, true); try drawCardPill(hdc, allocator, presentation, layout.pill, zoom); - const live = if (reclaim) |offer| try reclaimLine(allocator, offer) else try cardLiveLine(allocator, node); - defer allocator.free(live); - try drawCardText(hdc, allocator, live, layout.live, 10.5 * zoom, .card_mono, 0x009F9C9C, true); + if (role == .unwired) { + const actions = unwiredActionBoundsForZoom(bounds, zoom); + roundedControl(hdc, actions.wire, scaledValue(5, zoom), 0x003A3535, 0x003A3535); + try drawCardText(hdc, allocator, "Wire it up", actions.wire, 11 * zoom, .semibold, 0x00D9D9D9, false); + try drawCardText(hdc, allocator, "Mark as entry", actions.mark_entry, 11 * zoom, .regular, 0x00808080, false); + } else { + const live = if (reclaim) |offer| try reclaimLine(allocator, offer) else try cardLiveLine(allocator, node); + defer allocator.free(live); + try drawCardText(hdc, allocator, live, layout.live, 10.5 * zoom, .card_mono, 0x009F9C9C, true); + } const chip_in_detail = drawCardMetaRow(hdc, allocator, node, layout, zoom, role) catch |err| switch (err) { error.CardMetadataTooWide => blk: { std.log.warn("Card {s} fixed metadata does not fit: {s}", .{ node.id, @errorName(err) }); @@ -3923,6 +4014,7 @@ fn drawCardBody( }, else => return err, }; + if (role == .unwired) return; if (action == null and reclaim == null and node.metric_sample_count >= 2 and node.metric_direction.len != 0) { var detail_bounds = layout.detail; if (chip_in_detail) { @@ -6113,6 +6205,25 @@ test "card live detail 764 production fixtures contain real trends and human-act try std.testing.expectEqualStrings("pass 2 · Rendering synthetic content", live); } +test "unwired loop card paints inline recovery controls instead of the old detail hint" { + var node = parityTestNode(); + node.activity = @constCast("No connections · right-click to recover"); + var dib = try ParityDib.init(320, 160); + defer dib.deinit(); + const bounds = rect(20, 20, 270, 116); + const rows = cardTextLayout(bounds, 1, 80); + fill(dib.dc, rect(0, 0, 320, 160), Tokens.loop_card_bottom); + drawLoopCard(dib.dc, std.testing.allocator, node, bounds, &.{}, .unwired, false, null, false, null, true); + try std.testing.expect(c.GdiFlush() != 0); + + const wire = rect(bounds.left + 11, bounds.top + 31, bounds.left + 84, bounds.top + 51); + const mark_entry = rect(bounds.left + 90, bounds.top + 31, bounds.left + 176, bounds.top + 51); + try std.testing.expect(dib.count(wire, 0x003A3535) > 20); + try std.testing.expect(dib.count(wire, 0x00D9D9D9) > 0); + try std.testing.expect(dib.count(mark_entry, 0x00808080) > 0); + try std.testing.expectEqual(@as(usize, 0), dib.count(rows.detail, 0x009F9C9C)); +} + test "card live detail 764 attention alone does not hide a real metric" { var node = cardLiveDetailMetricNode(); node.state = @constCast("failed"); @@ -7033,7 +7144,11 @@ fn verifyPaint764CardPixels( try std.testing.expect(dib.count(chip_bounds, chip.fill) > 20); } if (!attention and role == .unwired) { - try std.testing.expect(dib.count(rows.detail, 0x007ACDFF) > 0); + const actions = unwiredActionBoundsForZoom(bounds, zoom); + try std.testing.expect(dib.count(actions.wire, 0x003A3535) > 20); + try std.testing.expect(dib.count(actions.wire, 0x00D9D9D9) > 0); + try std.testing.expect(dib.count(actions.mark_entry, 0x00808080) > 0); + try std.testing.expectEqual(@as(usize, 0), dib.count(rows.detail, 0x009F9C9C)); } } @@ -9008,7 +9123,7 @@ test "loop card parity paint 764 overview preserves entry role content" { } } -test "loop card parity paint 764 overview paints primary action within owning card" { +test "loop card parity paint 764 overview unwired attention uses recovery controls" { const allocator = std.testing.allocator; var fixture = try paint764FixtureModel(allocator, .overview); defer fixture.model.deinit(); @@ -9037,33 +9152,27 @@ test "loop card parity paint 764 overview paints primary action within owning ca viewport_bounds.bottom, ); const card = overviewCardBounds(&fixture.model, 0, node_index, graph_bounds, &state); - const action = attentionActionBounds(card, &state); - const action_pixels = overview.count(action, 0x000A9FFF); - if (action_pixels == 0) { - std.debug.print( - "paint764 overview action RED node={s} expected={s} pixels-inside-action={d}\n", - .{ node.id, attentionActionLabel(node), action_pixels }, - ); - return error.Paint764OverviewPrimaryActionMissing; - } - var label_pixels: usize = 0; - var y = action.top; - while (y < action.bottom) : (y += 1) { - var x = action.left; - while (x < action.right) : (x += 1) { - if (c.GetPixel(overview.dc, x, y) != 0x000A9FFF) label_pixels += 1; - } + try std.testing.expectEqual(@as(?LaneLayout.Role, .unwired), overviewNodeRole(&state, 0, node_index)); + const actions = unwiredActionBounds(card, &state); + try std.testing.expect(overview.count(actions.wire, 0x003A3535) > 20); + try std.testing.expect(overview.count(actions.wire, 0x00D9D9D9) > 0); + try std.testing.expect(overview.count(actions.mark_entry, 0x00808080) > 0); + try std.testing.expectEqual(@as(usize, 0), overview.count(attentionActionBounds(card, &state), 0x000A9FFF)); + for ([_]struct { bounds: c.RECT, action: UnwiredAction }{ + .{ .bounds = actions.wire, .action = .wire }, + .{ .bounds = actions.mark_entry, .action = .mark_entry }, + }) |expected| { + const hit = hitTestOverviewUnwiredAction( + &fixture.model, + expected.bounds.left + 1, + expected.bounds.top + 1, + &state, + graph_bounds, + ) orelse return error.Paint764OverviewRecoveryHitMissing; + try std.testing.expectEqual(@as(usize, 0), hit.graph_index); + try std.testing.expectEqual(node_index, hit.node_index); + try std.testing.expectEqual(expected.action, hit.action); } - if (label_pixels == 0) return error.Paint764OverviewPrimaryActionLabelMissing; - const hit = hitTestOverview( - &fixture.model, - action.left + @divTrunc(action.right - action.left, 2), - action.top + @divTrunc(action.bottom - action.top, 2), - &state, - graph_bounds, - ) orelse return error.Paint764OverviewActionHitMissing; - try std.testing.expectEqual(@as(usize, 0), hit.graph_index); - try std.testing.expectEqual(node_index, hit.node_index); } test "loop card parity both production renderers paint named pill and mono meta regions" { @@ -9080,7 +9189,7 @@ test "loop card parity both production renderers paint named pill and mono meta var state = try modelTestState(&model, .{ .pan_x = 1200 }, viewport); defer state.deinitLayout(); fill(dib.dc, rect(0, 0, 2200, 500), 0); - drawNode(dib.dc, allocator, node, 0, null, &.{node}, &.{}, .unwired, null, &.{}, &state); + drawNode(dib.dc, allocator, node, 0, null, &.{node}, &.{}, .entry, null, &.{}, &state); const project = nodeBounds(0, &state); const presentation = cardState(node); const old = try selectCardFont(dib.dc, 10, .card_state); @@ -9096,7 +9205,7 @@ test "loop card parity both production renderers paint named pill and mono meta try std.testing.expect(dib.count(project_rows.meta, 0x00979595) > 0); const project_pill_pixels = dib.count(project_rows.pill, presentation.fill); fill(dib.dc, rect(0, 0, 2200, 500), 0); - try drawOverview(dib.dc, allocator, &model, viewport, &state, &.{}); + try drawOverview(dib.dc, allocator, &model, viewport, &state, &.{"parity-card"}); const overview = overviewCardBounds(&model, 0, 0, viewport, &state); const overview_rows = cardTextLayout(overview, 1, pill_width); try std.testing.expect(c.GdiFlush() != 0); @@ -9226,7 +9335,7 @@ test "loop card parity existing action fills use hit bounds and never paint over var state = try cardTestState(.{ .zoom = zoom, .pan_x = 252 - 382 * zoom, .pan_y = 84 - 134 * zoom }); defer state.deinitLayout(); fill(dib.dc, rect(0, 0, 1200, 500), 0); - for (nodes, 0..) |node, index| drawNode(dib.dc, allocator, node, index, null, &nodes, &.{}, .unwired, &inspection, &.{}, &state); + for (nodes, 0..) |node, index| drawNode(dib.dc, allocator, node, index, null, &nodes, &.{}, .interior, &inspection, &.{}, &state); const attention = attentionActionBounds(nodeBounds(0, &state), &state); const offer = reclaimOfferBounds(nodeBounds(1, &state)); try std.testing.expect(c.GdiFlush() != 0); diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index 0c3af1d1..6b82e184 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -703,7 +703,7 @@ native keyboard/accelerator/window proof; the workspace row remains Partial. | Loop card identity | Loop-type stripe, title, state pill, entry/cycle role | Project and overview cards now use loop-type-colored stripes while retaining lifecycle state text, START, UNWIRED, and attention labels. Focused color regression coverage passes; live evidence remains blocked. Narrowed 2026-10-13 to macOS scope (requirements macOS lacks were dropped; see the issue). Slice-2 source note: selected cards, including selected-unwired, retain the baseline blue-border styling and stripe-after-border layering inside the rounded clip as an explicit Windows compatibility exception; this is not a macOS selected-border parity claim. Warm face/halo remain independent of selection. Static attention endpoints do not implement macOS's 0.2-second cross-fade; macOS stroke alignment and visual/temporal acceptance remain unverified. **Tracked in [#727](https://github.com/scgopi/GraphCode/issues/727) (implementation gap).** | Partial | | Loop card live detail | Goal/prompt/check line, progress/metric change, type/optional branch/backend/age metadata matching the macOS compact card | Merged [#814](https://github.com/scgopi/GraphCode/pull/814) shares the compact project/overview body. `GraphCanvas.zig` `cardLiveLine` prioritizes activity, otherwise goal/trigger/check content, or Composite loop-count/pilot status; activity may include a metric pass prefix. `cardMetricDetail` supplies metric change separately. `nodeMetadata` emits type, optional worktree branch, non-default backend display name and age from createdAt; `cardMetaForWidth` elides the branch to preserve fixed metadata, and `drawCardBody` paints the distinct rows. Compact cards show **not token usage or model tier**; loop bar/right-panel fields are separate surfaces and cannot substantiate card rendering. Current source coverage explicitly excludes card progress/token metadata; no stale test-name or historical #399 UIA claim proves the new pixels. Visual acceptance is NotExecuted (#826); full #764 criteria remain open. | Partial | | Loop card attention | Reason-aware amber presentation and primary action | **Source-only G10 qualification, 2026-10-19:** native-AA warm face, amber hairline and halo were already present at the landed baseline and are preserved, not inferred missing from old C5. The degraded non-AA path now keeps its warm face/hairline instead of gray; selected outlines remain independent. A controlled 100% offscreen matrix separates Goal NEEDS YOU, Main/Composite BLOCKED, active-upstream non-attention, and wired/unwired selected/unselected variants. Existing reason predicates, primary actions, recovery and metric precedence are unchanged; native-AA and default contracts cover these distinctions. Historical Reply live UIA evidence ([#399](https://github.com/scgopi/GraphCode/pull/399), run https://github.com/scgopi/GraphCode/actions/runs/35638849754) does not qualify all current states. Unmatched topology, blur/kernel equivalence and runtime composition remain unproved. [Source-only evidence](#canvas-visual-finish-2026-10-19); [#764](https://github.com/scgopi/GraphCode/issues/764). | Partial | -| Unwired card recovery | Explanation, Wire it up, Mark as entry | Cards with no inbound or outbound edge now show an explicit UNWIRED warning and recovery explanation. Their native context menu exposes Wire it up, which enters the existing drag-to-connect flow, and Mark as entry, which changes the card to START for the session. Focused role/action tests plus live menu and post-action captures validate the flow **Reopened (visual) 2026-10-13:** the side-by-side review against the macOS reference found a text hint instead of the on-card Wire it up / Mark as entry controls. **Tracked in [#764](https://github.com/scgopi/GraphCode/issues/764) (implementation gap).** | Partial | +| Unwired card recovery | Explanation, Wire it up, Mark as entry | Cards with no inbound or outbound edge now render the macOS-visible inline `Wire it up` button and `Mark as entry` link while retaining the same context-menu actions. Pointer and stable-ID UIA routes share the existing edge-drag and declared-entry plumbing. Focused live UIA evidence observed `wire-node-1839192906933644187` enter `Choose a target loop to wire this entry`, then `mark-entry-1970733617255968265` report `Marked as an entry for this session`, remove both unwired controls, and preserve `canvas-card-1386805833417490675`; owned process cleanup was verified. The matched selected/unselected Goal scene adapter now records both visible controls before capture and exercises both routes afterward. **Remaining limit:** this change did not receive a newly authorized paired macOS/Windows screenshot review, so visual parity remains Partial rather than Validated. **Tracked in [#764](https://github.com/scgopi/GraphCode/issues/764).** | Partial | | Worktree reclaim offer | Reclaim and Keep actions on resolved card | Safe matching landed/clean/pushed worktrees retain separate Reclaim/Keep targets, revalidation and session Keep suppression. **Source-only G11 finish, 2026-10-19:** the common Reclaim button is rounded/bordered and both labels use 11-point semibold text in a shared 21-point row, with narrow-zoom paint/hit/UIA bounds and reclaim-over-metric precedence retained. The V2 Main STOPPED resolution/bytes copy is not an equivalent full card to the Windows Goal DONE fixture; no unavailable model copy was fabricated. Historical live descendants/Invoke/link evidence ([#385](https://github.com/scgopi/GraphCode/pull/385), run 35422364203) predates this finish. [Source-only evidence](#canvas-visual-finish-2026-10-19); current action execution and full-card visual equivalence are not established. | Partial | | Composite card actions | Open Group, Pilot Once, Arm Schedule | Canvas and sidebar composite menus expose all three actions. Open Group is live-validated; nested creates, edits, deletes, edge changes, pilot, and arm commands use the daemon's authoritative `subGraphCommand` envelope; and Arm Schedule is disabled unless the decoded pilot state is exactly `piloted` | Partial | | Edge presentation | Kind style, fired state, cycle label | Windows retains typed optional cycle guards and derives fired state from authoritative `fireCount`, with missing/null legacy fallback. Raw snapshot JSON reaches the production label formatter for maximum/until/flat-pass and empty-guard summaries; signed/null/malformed inputs, Unicode/long text, owned copies, refresh/composite transitions, and allocation-failure cleanup have executable coverage. Kind styling, selected emphasis, fixed 148-by-20 label bounds, ellipsis, and collision placement are retained. This is exact-string and static-geometry evidence only: full visible wording, the macOS context-menu summary, rendered pixels, and live label evidence remain unverified. Narrowed 2026-10-13 to macOS scope (requirements macOS lacks were dropped; see the issue). **Tracked in [#728](https://github.com/scgopi/GraphCode/issues/728) (implementation gap).** | Partial |