From d87ad0e8779feda6d7614f8bdf876104f0a601c0 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Wed, 28 Oct 2026 01:26:06 -0700 Subject: [PATCH] Match cycle card hierarchy Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/Invoke-UiCaptureWorker.ps1 | 42 ++++ .../AccessibilityProvider.Native.Tests.cpp | 60 ++++++ .../AccessibilityProvider.Native.Tests.ps1 | 4 +- Tools/windows/Tests/UiCaptureDriver.Tests.ps1 | 22 ++- .../windows/Tests/ValidationRunner.Tests.ps1 | 6 + Tools/windows/UI-CAPTURE-SCENARIOS.md | 2 +- Tools/windows/uia-live-gate.ps1 | 170 +++++++++++++++- .../src/AccessibilityProvider.cpp | 9 +- graphcode-windows/src/App.zig | 90 +++++++++ graphcode-windows/src/GraphCanvas.zig | 185 +++++++++++++++++- 10 files changed, 576 insertions(+), 14 deletions(-) diff --git a/Tools/windows/Invoke-UiCaptureWorker.ps1 b/Tools/windows/Invoke-UiCaptureWorker.ps1 index 042797f4..effe1d07 100644 --- a/Tools/windows/Invoke-UiCaptureWorker.ps1 +++ b/Tools/windows/Invoke-UiCaptureWorker.ps1 @@ -2114,6 +2114,48 @@ function Invoke-CapMatchedScenes { bounds=@($mark[0].Current.BoundingRectangle.Left,$mark[0].Current.BoundingRectangle.Top, $mark[0].Current.BoundingRectangle.Right,$mark[0].Current.BoundingRectangle.Bottom)}}) } + if ($scene -cmatch '^s-main-cycle-') { + $actionIdentity="attention-action:$($fixture.graphs[0].project.path):$($state[0].nodeId)" + $reasonIdentity="cycle-reason:$($fixture.graphs[0].project.path):$($state[0].nodeId)" + $actionId=Get-CapMatchedAutomationId $actionIdentity + $reasonId=Get-CapMatchedAutomationId $reasonIdentity + $graph=Find-CapAutomationElement -RootHandle $app.Window -ProcessIdentity $app.Run.Identity ` + -ExpectedProcessId $app.Run.Identity.Pid -Name 'Graph' -AutomationId 'graph' + $children=@(Get-CapDirectChildren -Parent $graph) + $inspect=@($children | Where-Object {$_.Current.AutomationId -ceq $actionId -and + $_.Current.Name -ceq 'Inspect'}) + $reason=@($children | Where-Object {$_.Current.AutomationId -ceq $reasonId -and + $_.Current.Name -ceq 'cycle · no entry point'}) + if ($inspect.Count -ne 1 -or $reason.Count -ne 1) { + throw "Matched cycle card omitted its separate Inspect action or footer reason for $scene." + } + if ($inspect[0].Current.ProcessId -ne $app.Run.Identity.Pid -or + $reason[0].Current.ProcessId -ne $app.Run.Identity.Pid -or + $inspect[0].Current.ControlType -ne [Windows.Automation.ControlType]::Button -or + $reason[0].Current.ControlType -ne [Windows.Automation.ControlType]::Text) { + throw "Matched cycle card exposed the wrong UIA ownership or control types for $scene." + } + $null=$inspect[0].GetCurrentPattern([Windows.Automation.InvokePattern]::Pattern) + $reasonPattern=$null + if ($reason[0].TryGetCurrentPattern([Windows.Automation.InvokePattern]::Pattern,[ref]$reasonPattern)) { + throw "Matched cycle footer reason incorrectly exposed Invoke for $scene." + } + $inspectBounds=$inspect[0].Current.BoundingRectangle + $reasonBounds=$reason[0].Current.BoundingRectangle + if ($inspectBounds.Width -le 0 -or $inspectBounds.Height -le 0 -or + $reasonBounds.Width -le 0 -or $reasonBounds.Height -le 0 -or + $inspectBounds.Bottom -gt $reasonBounds.Top) { + throw "Matched cycle card did not keep Inspect above the footer reason for $scene." + } + $app.SObservation | Add-Member -NotePropertyName cycleHierarchy -NotePropertyValue ` + ([pscustomobject]@{inspect=[pscustomobject]@{name='Inspect';automationId=$actionId + controlType='Button';invokable=$true;bounds=@($inspectBounds.Left,$inspectBounds.Top, + $inspectBounds.Right,$inspectBounds.Bottom)} + reason=[pscustomobject]@{name='cycle · no entry point';automationId=$reasonId + controlType='Text';invokable=$false;bounds=@($reasonBounds.Left,$reasonBounds.Top, + $reasonBounds.Right,$reasonBounds.Bottom)} + selected=[bool]$state[0].selected;cardIdentity=$card[0].AutomationId}) + } 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 diff --git a/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp b/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp index c72515c8..bd8699bc 100644 --- a/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp +++ b/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp @@ -510,6 +510,65 @@ static void nativeUnwiredRecoveryChecks(IRawElementProviderSimple *provider, HWN if (projects) projects->Release(); } +static void nativeCycleHierarchyChecks(IRawElementProviderSimple *provider, HWND hwnd, + IRawElementProviderFragment *root) { + const char *identities[] = { + "attention-action:C:\\fixture:main", + "cycle-reason:C:\\fixture:main", + }; + const char *names[] = {"Inspect", "cycle \xC2\xB7 no entry point"}; + const int parents[] = {4, 4}; + const int none[] = {0, 0}; + const int actions[] = {1, 0}; + const int bounds[] = { + 412, 151, 478, 168, + 261, 175, 478, 188, + }; + auto update = reinterpret_cast(&gc_uia_update); + check(update(provider, "Cycle hierarchy", identities, names, parents, none, none, actions, bounds, + 2, 0, 1, 0, 0, 0, 0) == S_OK, + "cycle hierarchy 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 *inspect = child(graph, NavigateDirection_FirstChild); + auto *reason = child(inspect, NavigateDirection_NextSibling); + check(stringProperty(inspect, UIA_AutomationIdPropertyId).rfind(L"attention-action-", 0) == 0, + "Inspect keeps its stable action automation id"); + check(stringProperty(reason, UIA_AutomationIdPropertyId).rfind(L"cycle-reason-", 0) == 0, + "cycle reason has a stable metadata automation id"); + checkName(inspect, L"Inspect"); + checkName(reason, L"cycle \u00b7 no entry point"); + check(invokable(inspect), "Inspect remains invokable"); + check(!invokable(reason), "cycle reason is metadata, not an action"); + + IRawElementProviderSimple *reason_simple = nullptr; + check(reason && reason->QueryInterface(IID_PPV_ARGS(&reason_simple)) == S_OK && reason_simple, + "cycle reason exposes native UIA properties"); + if (reason_simple) { + VARIANT control_type; + VariantInit(&control_type); + check(reason_simple->GetPropertyValue(UIA_ControlTypePropertyId, &control_type) == S_OK && + control_type.vt == VT_I4 && control_type.lVal == UIA_TextControlTypeId, + "cycle reason is exposed as Text"); + VariantClear(&control_type); + reason_simple->Release(); + } + POINT origin{0, 0}; + ClientToScreen(hwnd, &origin); + checkRect(inspect, origin, 412, 151, 66, 17, "Inspect retains its body action bounds"); + checkRect(reason, origin, 261, 175, 217, 13, "cycle reason occupies the footer bounds"); + + if (reason) reason->Release(); + if (inspect) inspect->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"); @@ -744,6 +803,7 @@ static void nativeCommandAndScrollChecks() { nativeHeaderOverflowChecks(provider, capture, root); nativeBannerInvokeChecks(provider, capture, root); nativeUnwiredRecoveryChecks(provider, capture, root); + nativeCycleHierarchyChecks(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 289c293f..102c9186 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: 240 checks, 0 failures" }).Count -ne 1) { - throw "Native accessibility provider test-exe did not report exactly one 240-check, zero-failure summary" + if (@($nativeOutput | Where-Object { $_ -eq "Native accessibility provider: 259 checks, 0 failures" }).Count -ne 1) { + throw "Native accessibility provider test-exe did not report exactly one 259-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 903b878a..01223086 100644 --- a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 +++ b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 @@ -2917,6 +2917,11 @@ function Invoke-CapMatchedContracts { $proof=Assert-CapMatchedFixture -Fixture $fixture -ProjectRoot 'C:\fixture' Assert-CapTest ($proof.states.Count -eq 6 -and $proof.nodes -eq 8 -and $proof.macSource -ceq '2ccf303a04f995184aae1d4b738a9eca0f71b79e') 'Matched fixture typed inventory differs.' + $workerSource=Get-Content -LiteralPath $workerPath -Raw + Assert-CapTest ($workerSource.Contains("cycle-reason:") -and + $workerSource.Contains("Matched cycle card did not keep Inspect above the footer reason") -and + $workerSource.Contains("controlType='Text';invokable=`$false")) ` + 'Matched cycle scenes do not require separate body action and footer-reason UIA evidence.' $wire=Invoke-CapPeerWireCase -Peer B -Case clean -Source (Get-CapBPeerSource) ` -FixturePath (Join-Path $driverDirectory 'fixtures\matched-v12.json') -CaseRoot (Join-Path $scratch 'matched-fixture-wire') return @{fixture=$proof;wire=$wire;qualifiedStates=$proof.states.Count;nodes=$proof.nodes @@ -3349,7 +3354,7 @@ function Invoke-CapMatchedTabContracts { $terminalSource -cmatch '(?s)pub fn canCloseTab\(self: \*const Workspace\) bool \{\s*return self\.layout\.tabs\.items\.len > 1;\s*\}' -and $accessibilitySource.Contains('sidebar_scroll: bool = false,') -and $providerSource -cmatch '(?s)bool supportsScrollItem\(\) const \{.*?if \(isRowKey\(id_\)\) return state_->rows\.at\(id_\)\.scrolls;' -and - $providerSource -cmatch '(?s)if \(isRowKey\(id_\)\) \{.*?bool_value = sidebar_error_footer.*?: true;') ` + $providerSource -cmatch '(?s)if \(isRowKey\(id_\)\) \{.*?bool_value = static_text.*?: true;') ` 'Product tab close source contract no longer matches the capture interpretation.' foreach ($vector in @( @('workspace-tab:0','workspace-tab-2015407614065646652'), @@ -4047,10 +4052,18 @@ function Invoke-CapMatchedSceneRoutingContract { } function New-CapMockElement([string]$Id,[string]$Name,[int]$ProcessId,[double[]]$Bounds) { $current=[pscustomobject]@{AutomationId=$Id;Name=$Name;ProcessId=$ProcessId + ControlType=$(if ($Id -clike 'cycle-reason-*') {[Windows.Automation.ControlType]::Text} + else {[Windows.Automation.ControlType]::Button}) 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]@{}} + $element | Add-Member -MemberType ScriptMethod -Name TryGetCurrentPattern -Value { + param($Pattern,[ref]$Value) + if ($this.Current.AutomationId -clike 'cycle-reason-*') {return $false} + $Value.Value=[pscustomobject]@{} + return $true + } return $element } function Find-CapAutomationElement { @@ -4073,6 +4086,13 @@ function Invoke-CapMatchedSceneRoutingContract { $rows.Add((New-CapMockElement (Get-CapMatchedAutomationId ("mark-entry:"+$fixture.graphs[0].project.path+":"+$nodeId)) ` 'Mark as entry' 4242 @(340,131,426,151))) } + if ($scene -cmatch '^s-main-cycle-') { + $nodeId=@($fixture.states | Where-Object scene -CEQ $scene)[0].nodeId + $rows.Add((New-CapMockElement (Get-CapMatchedAutomationId ("attention-action:"+$fixture.graphs[0].project.path+":"+$nodeId)) ` + 'Inspect' 4242 @(412,151,478,168))) + $rows.Add((New-CapMockElement (Get-CapMatchedAutomationId ("cycle-reason:"+$fixture.graphs[0].project.path+":"+$nodeId)) ` + 'cycle · no entry point' 4242 @(261,175,478,188))) + } return $rows.ToArray() } function Send-CapMatchedEscape {param($AppRun) $model.wireArmed=$false;return $true} diff --git a/Tools/windows/Tests/ValidationRunner.Tests.ps1 b/Tools/windows/Tests/ValidationRunner.Tests.ps1 index 0c53bfcf..f1b85993 100644 --- a/Tools/windows/Tests/ValidationRunner.Tests.ps1 +++ b/Tools/windows/Tests/ValidationRunner.Tests.ps1 @@ -3683,6 +3683,12 @@ try { if ($uiaErrors.Count -ne 0) { throw "RED: UIA live gate source does not parse" } + if ($uiaLiveGateSource -notmatch '\[switch\]\$CycleHierarchyOnly' -or + $uiaLiveGateSource -notmatch 'PostFixtureMutation\(\$shellWindow, 23\)' -or + $uiaLiveGateSource -notmatch 'UIA_CYCLE_HIERARCHY_EVIDENCE=' -or + $uiaLiveGateSource -notmatch 'UIA_CYCLE_HIERARCHY_SHA256=') { + throw "RED: UIA live gate does not exercise and hash both exact cycle-card hierarchy scenes" + } $chromeClassifier = $uiaAst.Find({ param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and diff --git a/Tools/windows/UI-CAPTURE-SCENARIOS.md b/Tools/windows/UI-CAPTURE-SCENARIOS.md index a1d17406..bb20408e 100644 --- a/Tools/windows/UI-CAPTURE-SCENARIOS.md +++ b/Tools/windows/UI-CAPTURE-SCENARIOS.md @@ -359,7 +359,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 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-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. Both scenes require a separate owned `Inspect` Button in the body and non-invokable `cycle · no entry point` Text in the metadata footer, with positive bounds and the body action ending above the footer reason. The selected scene also retains the same stable card identity and observed selection after the normal card-activation/show-graph route. | | `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. | | `s-tabs-goal-needs-you` | One Goal tab, agent asks state, selected focused pane. | diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index e4c1b331..92e7e804 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -4,7 +4,8 @@ param( [string] $Zmx = "", [string[]] $ArgumentList = @(), [switch]$SidebarParityOnly, - [switch]$UnwiredRecoveryOnly + [switch]$UnwiredRecoveryOnly, + [switch]$CycleHierarchyOnly ) $ErrorActionPreference = "Stop" @@ -6630,9 +6631,11 @@ try { bottom = [double]$updateBannerBounds.Bottom } $foregroundWindow = [GraphCodeUiaGateState]::CurrentForegroundWindow() - Require (Test-UpdateBannerClickTarget $updateBannerTarget $process.Id $shellWindow ` - $foregroundWindow $focusedElement.Current.ProcessId ([string]$status.Current.Name)) ` - "sidebar update banner click target lost shell process, window, focus, status, or UIA identity" + if (-not $CycleHierarchyOnly) { + Require (Test-UpdateBannerClickTarget $updateBannerTarget $process.Id $shellWindow ` + $foregroundWindow $focusedElement.Current.ProcessId ([string]$status.Current.Name)) ` + "sidebar update banner click target lost shell process, window, focus, status, or UIA identity" + } $updateBannerScreenX = [int](($updateBannerTarget.left + $updateBannerTarget.right) / 2) $updateBannerScreenY = [int](($updateBannerTarget.top + $updateBannerTarget.bottom) / 2) $updateBannerClickX = 0 @@ -7154,6 +7157,165 @@ try { nonOverlapping = $true viewportIntersectionRequired = $true } | ConvertTo-Json -Compress -Depth 5)) + if ($CycleHierarchyOnly) { + Require ([GraphCodeUiaGateState]::PostFixtureMutation($shellWindow, 23)) ` + "cycle hierarchy fixture mutation was rejected" + Start-Sleep -Milliseconds 200 + $root = Wait-ForRootReconnect $process @($rawWalker, $controlWalker) + $graph = Find-FragmentByIdWithRetry $root "graph" $rawWalker + $selectedChildren = @(Get-DirectChildren $graph $rawWalker) + $selectedCard = @($selectedChildren | Where-Object { + $_.Current.AutomationId -match '^canvas-card-' -and $_.Current.Name -ceq 'Main' + }) + $otherCard = @($selectedChildren | Where-Object { + $_.Current.AutomationId -match '^canvas-card-' -and $_.Current.Name -ceq 'Other' + }) + $selectedInspect = @($selectedChildren | Where-Object { + $_.Current.AutomationId -match '^attention-action-' -and $_.Current.Name -ceq 'Inspect' + }) + $selectedReason = @($selectedChildren | Where-Object { + $_.Current.AutomationId -match '^cycle-reason-' -and $_.Current.Name -ceq 'cycle · no entry point' + }) + Require ($selectedCard.Count -eq 1 -and $otherCard.Count -eq 1) ` + "selected cycle fixture did not expose exact Main and Other cards" + Require ($selectedInspect.Count -eq 2 -and $selectedReason.Count -eq 2) ` + "selected cycle fixture did not expose two separate cycle actions and footer reasons" + $mainInspect = @($selectedInspect | Where-Object { + $_.Current.BoundingRectangle.Left -ge $selectedCard[0].Current.BoundingRectangle.Left -and + $_.Current.BoundingRectangle.Right -le $selectedCard[0].Current.BoundingRectangle.Right -and + $_.Current.BoundingRectangle.Top -ge $selectedCard[0].Current.BoundingRectangle.Top -and + $_.Current.BoundingRectangle.Bottom -le $selectedCard[0].Current.BoundingRectangle.Bottom + }) + $mainReason = @($selectedReason | Where-Object { + $_.Current.BoundingRectangle.Left -ge $selectedCard[0].Current.BoundingRectangle.Left -and + $_.Current.BoundingRectangle.Right -le $selectedCard[0].Current.BoundingRectangle.Right -and + $_.Current.BoundingRectangle.Top -ge $selectedCard[0].Current.BoundingRectangle.Top -and + $_.Current.BoundingRectangle.Bottom -le $selectedCard[0].Current.BoundingRectangle.Bottom + }) + Require ($mainInspect.Count -eq 1 -and $mainReason.Count -eq 1) ` + "selected Main cycle card did not own one Inspect and one footer reason geometry" + $selectedState = $selectedCard[0].GetCurrentPattern( + [System.Windows.Automation.SelectionItemPattern]::Pattern + ).Current.IsSelected + Require $selectedState "s-main-cycle-selected did not retain selected card state" + Require ($mainInspect[0].Current.ControlType -eq [System.Windows.Automation.ControlType]::Button -and + $mainReason[0].Current.ControlType -eq [System.Windows.Automation.ControlType]::Text) ` + "s-main-cycle-selected exposed incorrect action/reason control types" + $null = $mainInspect[0].GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern) + $reasonInvoke = $null + Require (-not $mainReason[0].TryGetCurrentPattern( + [System.Windows.Automation.InvokePattern]::Pattern, [ref]$reasonInvoke + )) "s-main-cycle-selected footer reason incorrectly exposed Invoke" + Require ($mainInspect[0].Current.BoundingRectangle.Bottom -le + $mainReason[0].Current.BoundingRectangle.Top) ` + "s-main-cycle-selected did not keep Inspect above its footer reason" + $selectedEvidence = [ordered]@{ + scene = 's-main-cycle-selected' + cardAutomationId = [string]$selectedCard[0].Current.AutomationId + selected = [bool]$selectedState + inspectAutomationId = [string]$mainInspect[0].Current.AutomationId + inspectBounds = @($mainInspect[0].Current.BoundingRectangle.Left, + $mainInspect[0].Current.BoundingRectangle.Top, + $mainInspect[0].Current.BoundingRectangle.Right, + $mainInspect[0].Current.BoundingRectangle.Bottom) + reasonAutomationId = [string]$mainReason[0].Current.AutomationId + reasonBounds = @($mainReason[0].Current.BoundingRectangle.Left, + $mainReason[0].Current.BoundingRectangle.Top, + $mainReason[0].Current.BoundingRectangle.Right, + $mainReason[0].Current.BoundingRectangle.Bottom) + } + + $selectedCard[0].GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + Start-Sleep -Milliseconds 150 + $root = Wait-ForRootReconnect $process @($rawWalker, $controlWalker) + $graph = Find-FragmentByIdWithRetry $root "graph" $rawWalker + $showGraph = @(Get-DirectChildren $graph $rawWalker | Where-Object { + $_.Current.AutomationId -match '^workspace-show-graph-' -and $_.Current.Name -ceq 'Show in Graph' + }) + Require ($showGraph.Count -eq 1) ` + "selected Main cycle card activation did not enter its workspace" + $showGraph[0].GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + Start-Sleep -Milliseconds 150 + $root = Wait-ForRootReconnect $process @($rawWalker, $controlWalker) + $graph = Find-FragmentByIdWithRetry $root "graph" $rawWalker + $otherCard = @(Get-DirectChildren $graph $rawWalker | Where-Object { + $_.Current.AutomationId -match '^canvas-card-' -and $_.Current.Name -ceq 'Other' + }) + Require ($otherCard.Count -eq 1) "Other card disappeared before unselected cycle evidence" + $otherCard[0].GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + Start-Sleep -Milliseconds 150 + $root = Wait-ForRootReconnect $process @($rawWalker, $controlWalker) + $graph = Find-FragmentByIdWithRetry $root "graph" $rawWalker + $showGraph = @(Get-DirectChildren $graph $rawWalker | Where-Object { + $_.Current.AutomationId -match '^workspace-show-graph-' -and $_.Current.Name -ceq 'Show in Graph' + }) + Require ($showGraph.Count -eq 1) "Other card activation did not enter its workspace" + $showGraph[0].GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + Start-Sleep -Milliseconds 150 + + $root = Wait-ForRootReconnect $process @($rawWalker, $controlWalker) + $graph = Find-FragmentByIdWithRetry $root "graph" $rawWalker + $unselectedChildren = @(Get-DirectChildren $graph $rawWalker) + $unselectedCard = @($unselectedChildren | Where-Object { + $_.Current.AutomationId -ceq $selectedEvidence.cardAutomationId -and $_.Current.Name -ceq 'Main' + }) + $unselectedInspect = @($unselectedChildren | Where-Object { + $_.Current.AutomationId -ceq $selectedEvidence.inspectAutomationId -and $_.Current.Name -ceq 'Inspect' + }) + $unselectedReason = @($unselectedChildren | Where-Object { + $_.Current.AutomationId -ceq $selectedEvidence.reasonAutomationId -and + $_.Current.Name -ceq 'cycle · no entry point' + }) + Require ($unselectedCard.Count -eq 1 -and $unselectedInspect.Count -eq 1 -and + $unselectedReason.Count -eq 1) ` + "s-main-cycle-unselected did not preserve stable card/action/reason identities" + $unselectedState = $unselectedCard[0].GetCurrentPattern( + [System.Windows.Automation.SelectionItemPattern]::Pattern + ).Current.IsSelected + Require (-not $unselectedState) "s-main-cycle-unselected remained selected" + Require ($unselectedInspect[0].Current.BoundingRectangle.Bottom -le + $unselectedReason[0].Current.BoundingRectangle.Top) ` + "s-main-cycle-unselected did not keep Inspect above its footer reason" + $unselectedReasonInvoke = $null + Require (-not $unselectedReason[0].TryGetCurrentPattern( + [System.Windows.Automation.InvokePattern]::Pattern, [ref]$unselectedReasonInvoke + )) "s-main-cycle-unselected footer reason incorrectly exposed Invoke" + $evidence = [ordered]@{ + mode = 'focused-live-uia' + projectRoot = [Environment]::GetEnvironmentVariable('GRAPHCODE_GATE_CWD') + selected = $selectedEvidence + unselected = [ordered]@{ + scene = 's-main-cycle-unselected' + cardAutomationId = [string]$unselectedCard[0].Current.AutomationId + selected = [bool]$unselectedState + inspectAutomationId = [string]$unselectedInspect[0].Current.AutomationId + inspectBounds = @($unselectedInspect[0].Current.BoundingRectangle.Left, + $unselectedInspect[0].Current.BoundingRectangle.Top, + $unselectedInspect[0].Current.BoundingRectangle.Right, + $unselectedInspect[0].Current.BoundingRectangle.Bottom) + reasonAutomationId = [string]$unselectedReason[0].Current.AutomationId + reasonBounds = @($unselectedReason[0].Current.BoundingRectangle.Left, + $unselectedReason[0].Current.BoundingRectangle.Top, + $unselectedReason[0].Current.BoundingRectangle.Right, + $unselectedReason[0].Current.BoundingRectangle.Bottom) + } + selectedActivation = [ordered]@{ + invokedCardAutomationId = [string]$selectedCard[0].Current.AutomationId + workspaceShowGraphObserved = $true + stableIdentityAfterReturn = [string]$unselectedCard[0].Current.AutomationId + } + } + $json = $evidence | ConvertTo-Json -Compress -Depth 8 + $hasher = [Security.Cryptography.SHA256]::Create() + try { + $sha256 = [Convert]::ToHexString($hasher.ComputeHash([Text.Encoding]::UTF8.GetBytes($json))) + } finally { + $hasher.Dispose() + } + Write-Host "UIA_CYCLE_HIERARCHY_EVIDENCE=$json" + Write-Host "UIA_CYCLE_HIERARCHY_SHA256=$sha256" + return + } if ($UnwiredRecoveryOnly) { Require ([GraphCodeUiaGateState]::PostFixtureMutation($shellWindow, 16)) ` "unwired recovery fixture mutation was rejected" diff --git a/graphcode-windows/src/AccessibilityProvider.cpp b/graphcode-windows/src/AccessibilityProvider.cpp index 6d725391..9fd4f628 100644 --- a/graphcode-windows/src/AccessibilityProvider.cpp +++ b/graphcode-windows/src/AccessibilityProvider.cpp @@ -264,11 +264,12 @@ class Node final : public IRawElementProviderSimple, property == UIA_IsContentElementPropertyId) { if (isRowKey(id_)) { const Row &row = state_->rows.at(id_); - const bool sidebar_error_footer = + const bool static_text = row.identity.rfind("sidebar-error-footer:", 0) == 0 || row.identity.rfind("workspace-toolbar:", 0) == 0 || - row.identity.rfind("worktree-loading:", 0) == 0; - bool_value = sidebar_error_footer + row.identity.rfind("worktree-loading:", 0) == 0 || + row.identity.rfind("cycle-reason:", 0) == 0; + bool_value = static_text ? (property != UIA_IsKeyboardFocusablePropertyId) : true; if (property == UIA_IsEnabledPropertyId && isHeader(row.identity)) @@ -1267,6 +1268,7 @@ 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("cycle-reason:", 0) == 0 ? L"cycle-reason-" : 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-" : @@ -1322,6 +1324,7 @@ class Node final : public IRawElementProviderSimple, return UIA_TextControlTypeId; } if (row.identity.rfind("worktree-loading:", 0) == 0) return UIA_TextControlTypeId; + if (row.identity.rfind("cycle-reason:", 0) == 0) return UIA_TextControlTypeId; const bool action = row.identity.rfind("sidebar-section:", 0) == 0 || row.identity.rfind("needs-you-header:", 0) == 0 || diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 45ac3d62..a7d479e0 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -7039,6 +7039,24 @@ pub const App = struct { _ = c.InvalidateRect(self.window.hwnd, null, 0); return; } + if (mutation == 23) { + self.applyUiaFixtureGraph(.project, "Cycle hierarchy", 56, + \\[{"id":"20000000-0000-4000-8000-000000000001","title":"Main","loopType":"sketch","backend":"copilotCLI","state":"blocked","createdAt":814011000,"firstInstruction":"Offline closed cycle"},{"id":"20000000-0000-4000-8000-000000000002","title":"Cycle peer","loopType":"sketch","backend":"copilotCLI","state":"blocked","createdAt":814011000,"firstInstruction":"Offline closed cycle peer"},{"id":"77777777-7777-4777-8777-777777777777","title":"Other","loopType":"sketch","state":"idle","createdAt":814011000}] + , + \\[{"id":"50000000-0000-4000-8000-000000000001","from":"20000000-0000-4000-8000-000000000001","to":"20000000-0000-4000-8000-000000000002","kind":"handoff","blocksTarget":true},{"id":"50000000-0000-4000-8000-000000000002","from":"20000000-0000-4000-8000-000000000002","to":"20000000-0000-4000-8000-000000000001","kind":"handoff","blocksTarget":true}] + ) catch |err| { + self.reportWorktreeError("Unable to prepare cycle hierarchy UIA fixture", err); + return; + }; + _ = self.model.setSelectedID("20000000-0000-4000-8000-000000000001"); + self.surface = .project; + self.workspace_controls.panel_visible = false; + self.layoutWorkspace(); + self.layoutEmptyStateControls(); + self.syncAccessibility(); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + return; + } const dialog = if (self.worktree_dialog) |*value| value else return; const target_path = if (mutation == 2 or mutation == 3) self.ownedUiaFixturePath(if (mutation == 2) .safe else .unsafe) catch |err| { self.reportWorktreeError("UIA fixture owner unavailable", err); @@ -8973,6 +8991,10 @@ pub const App = struct { } 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.projectNodeRole(&self.canvas, index) == .cycle_only) { + self.appendAccessibilityElement(&elements, &owned_identities, "cycle-reason", key, "cycle · no entry point", 4, .{ .logical = GraphCanvas.cycleReasonBounds(bounds, &self.canvas) }, false, false) catch return; + elements.items[elements.items.len - 1].invokable = false; + } 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; @@ -22674,6 +22696,74 @@ test "loop card parity UIA actions share painted logical bounds and scale once" } } +test "closed cycle UIA separates the footer reason from Inspect and preserves selected activation" { + const Sink = struct { + selected: bool, + checked: bool = false, + card_seen: usize = 0, + action_seen: usize = 0, + reason_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.checked = true; + self.check(elements) catch |err| { + self.failure = err; + }; + } + + fn check(self: *@This(), elements: []const Accessibility.DynamicElement) !void { + var action_bottom: i32 = 0; + var reason_top: i32 = 0; + for (elements) |element| { + if (std.mem.eql(u8, element.identity, "project-card:C:\\cycle:main")) { + self.card_seen += 1; + try std.testing.expectEqual(self.selected, element.selected); + try std.testing.expect(element.invokable); + } else if (std.mem.eql(u8, element.identity, "attention-action:C:\\cycle:main")) { + self.action_seen += 1; + try std.testing.expectEqualStrings("Inspect", element.name); + try std.testing.expect(element.invokable); + action_bottom = element.bottom; + } else if (std.mem.eql(u8, element.identity, "cycle-reason:C:\\cycle:main")) { + self.reason_seen += 1; + try std.testing.expectEqualStrings("cycle · no entry point", element.name); + try std.testing.expect(!element.invokable); + reason_top = element.top; + } + } + try std.testing.expectEqual(@as(usize, 1), self.card_seen); + try std.testing.expectEqual(@as(usize, 1), self.action_seen); + try std.testing.expectEqual(@as(usize, 1), self.reason_seen); + try std.testing.expect(action_bottom > 0 and action_bottom <= reason_top); + } + }; + + for ([_]u32{ 96, 192 }) |dpi| { + for ([_]bool{ false, true }) |selected| { + var app = try overviewTestApp(dpi); + defer deinitOverviewTestApp(&app); + app.surface = .project; + _ = try app.model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"project":{"path":"C:\\cycle","name":"Cycle"},"nodes":[{"id":"main","title":"Main","loopType":"sketch","backend":"copilotCLI","state":"blocked","firstInstruction":"Closed cycle"},{"id":"peer","title":"Cycle peer","loopType":"sketch","backend":"copilotCLI","state":"blocked","firstInstruction":"Closed cycle peer"},{"id":"other","title":"Other","loopType":"sketch","state":"idle"}],"edges":[{"id":"a","from":"main","to":"peer","kind":"handoff","blocksTarget":true},{"id":"b","from":"peer","to":"main","kind":"handoff","blocksTarget":true}]}}} + ); + try std.testing.expect(app.model.selectProject("C:\\cycle")); + try std.testing.expect(app.selectNodeIndex(if (selected) 0 else 2)); + var sink = Sink{ .selected = selected }; + app.syncAccessibilityTo(&sink, .{ .left = 0, .top = 0, .right = 1200, .bottom = 900 }); + try std.testing.expect(sink.checked); + if (sink.failure) |err| return err; + if (selected) { + try std.testing.expect(app.applyUiaDynamicInvoke(Accessibility.worktreeIdentityPayload("project-card:C:\\cycle:main"))); + try std.testing.expectEqual(GraphCanvas.Surface.workspace, app.surface); + try std.testing.expectEqualStrings("main", app.model.selected().?.id); + } + } + } +} + test "unwired inline recovery controls route pointer and stable UIA invokes to existing effects" { const Sink = struct { expected_wire: c.RECT, diff --git a/graphcode-windows/src/GraphCanvas.zig b/graphcode-windows/src/GraphCanvas.zig index d4c5cba7..7e0607bd 100644 --- a/graphcode-windows/src/GraphCanvas.zig +++ b/graphcode-windows/src/GraphCanvas.zig @@ -1462,6 +1462,12 @@ pub fn overviewNodeRole(state: *const CanvasState, graph_index: usize, node_inde return lane.layout.roles[node_index]; } +pub fn projectNodeRole(state: *const CanvasState, node_index: usize) ?LaneLayout.Role { + const layout = state.project_layout orelse return null; + if (node_index >= layout.roles.len) return null; + return layout.roles[node_index]; +} + pub fn overviewLaneName(graph: *const GraphModel.GraphSummary) []const u8 { return if (graph.project.isGlobal()) "No folder" else graph.project.name; } @@ -3045,8 +3051,24 @@ fn drawLoopCard( }; 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 (primary_action == .inspect) { + roundedControl(hdc, action_bounds, scaled(5, state), 0x000A9FFF, 0x000A9FFF); + drawCardText( + hdc, + allocator, + attentionActionLabelFor(primary_action), + action_bounds, + 11 * state.zoom, + .semibold, + 0x00241703, + false, + ) catch |err| { + std.log.warn("Attention action label unavailable: {s}", .{@errorName(err)}); + }; + } else { + 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 (role != .unwired and show_reclaim and reclaim != null) { @@ -3100,6 +3122,15 @@ 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 fn cycleReasonBounds(bounds: c.RECT, state: *const CanvasState) c.RECT { + return rect( + bounds.left + scaledValue(11, state.zoom), + bounds.bottom - scaledValue(21, state.zoom), + bounds.right - scaledValue(11, state.zoom), + bounds.bottom - scaledValue(8, state.zoom), + ); +} + pub const UnwiredActionBounds = struct { wire: c.RECT, mark_entry: c.RECT }; pub fn unwiredActionBounds(bounds: c.RECT, state: *const CanvasState) UnwiredActionBounds { @@ -3671,6 +3702,81 @@ test "paint export: loop-outcomes" { std.debug.print("loop outcome export: 4 scenes x 3 repeats, matching RGB hashes; production decoder/card paint; no desktop capture\n", .{}); } +const cycleCardEvidenceFrame = + \\{"graphChanged":{"project":{"path":"A","name":"Cycle fixture"},"nodes":[{"id":"main","title":"Main","loopType":"sketch","backend":"copilotCLI","state":"blocked","createdAt":1,"firstInstruction":"Offline closed cycle"},{"id":"peer","title":"Cycle peer","loopType":"sketch","backend":"copilotCLI","state":"blocked","createdAt":1,"firstInstruction":"Offline closed cycle peer"}],"edges":[{"id":"a","from":"main","to":"peer","kind":"handoff","blocksTarget":true},{"id":"b","from":"peer","to":"main","kind":"handoff","blocksTarget":true}]}} +; + +test "paint export: cycle-card-hierarchy" { + const allocator = std.testing.allocator; + const root = std.process.getEnvVarOwned(allocator, "GRAPHCODE_PAINT_EXPORT_DIR") catch |err| switch (err) { + error.EnvironmentVariableNotFound => return error.SkipZigTest, + else => return err, + }; + defer allocator.free(root); + if (root.len == 0) return error.PaintExportDirectoryEmpty; + const commit = try std.process.getEnvVarOwned(allocator, "GRAPHCODE_PAINT_SOURCE_COMMIT"); + defer allocator.free(commit); + if (commit.len != 40) return error.PaintExportSourceCommitInvalid; + for (commit) |character| if (!std.ascii.isHex(character)) return error.PaintExportSourceCommitInvalid; + var directory = try std.fs.cwd().openDir(root, .{ .iterate = true }); + defer directory.close(); + var iterator = directory.iterate(); + if (try iterator.next() != null) return error.PaintExportRootNotEmpty; + try chrome764Native(); + + var model = GraphModel.Model.init(allocator); + defer model.deinit(); + _ = try model.updateFromFrame(cycleCardEvidenceFrame); + const graph = model.graph.?; + const roles = try resolveCardRoles(allocator, graph.nodes.items, graph.edges.items, &.{}); + defer allocator.free(roles); + try std.testing.expectEqual(NodeRole.cycle_only, roles[0]); + try std.testing.expectEqual(AttentionAction.inspect, primaryCardAction(graph.nodes.items[0]).?); + + var fixture_hash: [32]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(cycleCardEvidenceFrame, &fixture_hash, .{}); + var receipt = std.array_list.Managed(u8).init(allocator); + defer receipt.deinit(); + try receipt.writer().print( + "{{\"evidenceClass\":\"privacy-safe raw unresampled offscreen component paint\",\"sourceCommit\":\"{s}\",\"fixtureSha256\":\"{x}\",\"logicalDpi\":96,\"width\":360,\"height\":190,\"zoom\":1,\"scenes\":[", + .{ commit, &fixture_hash }, + ); + for ([_]bool{ false, true }, 0..) |selected, scene_index| { + var first_rgb: [32]u8 = undefined; + const scene = if (selected) "s-main-cycle-selected-component" else "s-main-cycle-unselected-component"; + for (0..3) |repeat| { + const dib = try ParityDib.init(360, 190); + defer dib.deinit(); + fill(dib.dc, rect(0, 0, 360, 190), Tokens.canvas_tone); + const bounds = rect(55, 47, 55 + Tokens.loop_card_width, 47 + Tokens.loop_card_height); + drawLoopCardShadow(dib.dc, allocator, graph.nodes.items[0], bounds, &.{}, .cycle_only, false, selected); + drawLoopCard(dib.dc, allocator, graph.nodes.items[0], bounds, &.{}, .cycle_only, false, .inspect, selected, null, true); + const rgb = try dib.rgbSha256(allocator); + if (repeat == 0) first_rgb = rgb else try std.testing.expectEqualSlices(u8, &first_rgb, &rgb); + const name = try std.fmt.allocPrint(allocator, "{s}-{d}.bmp", .{ scene, repeat + 1 }); + defer allocator.free(name); + const path = try std.fs.path.join(allocator, &.{ root, name }); + defer allocator.free(path); + const digest = try dib.writeBmp(allocator, path); + if (repeat == 0) { + if (scene_index != 0) try receipt.append(','); + try receipt.writer().print( + "{{\"scene\":\"{s}\",\"nodeId\":\"main\",\"selected\":{},\"file\":\"{s}\",\"repeats\":3,\"bmpSha256\":\"{x}\",\"rgbSha256\":\"{x}\"}}", + .{ scene, selected, name, &digest, &rgb }, + ); + } + } + } + try receipt.appendSlice("]}\n"); + var file = try directory.createFile("cycle-card-hierarchy-receipt.json", .{ .exclusive = true }); + defer file.close(); + try file.writeAll(receipt.items); + var fixture_file = try directory.createFile("cycle-card-hierarchy-frame.json", .{ .exclusive = true }); + defer fixture_file.close(); + try fixture_file.writeAll(cycleCardEvidenceFrame); + std.debug.print("cycle card export: 2 scenes x 3 repeats, matching RGB hashes; raw unresampled offscreen component paint; no desktop capture\n", .{}); +} + fn cardPilotName(pilot: []const u8) []const u8 { if (std.mem.eql(u8, pilot, "notPiloted")) return "Not piloted"; if (std.mem.eql(u8, pilot, "piloting")) return "Piloting"; @@ -3928,6 +4034,20 @@ fn cardMetaForWidth(hdc: c.HDC, allocator: std.mem.Allocator, node: GraphModel.N return result; } +fn cardMetaForWidthAllowingFullElision(hdc: c.HDC, allocator: std.mem.Allocator, node: GraphModel.Node, now: u64, width: i32) ![]u8 { + if (cardMetaForWidth(hdc, allocator, node, now, width)) |meta| return meta else |err| switch (err) { + error.CardMetadataTooWide => {}, + else => return err, + } + const full = try nodeMetadata(allocator, node, node.worktree_branch, now); + defer allocator.free(full); + const wide = try std.unicode.utf8ToUtf16LeAlloc(allocator, full); + defer allocator.free(wide); + const shortened = try cardBranchForWidth(hdc, allocator, wide, width) orelse return error.CardMetadataTooWide; + defer allocator.free(shortened); + return std.unicode.utf16LeToUtf8Alloc(allocator, shortened); +} + fn drawCardText(hdc: c.HDC, allocator: std.mem.Allocator, text: []const u8, bounds: c.RECT, size: f32, style: AppFont.Style, color: u32, ellipsis: bool) !void { if (text.len == 0 or bounds.right <= bounds.left) return; const wide = try std.unicode.utf8ToUtf16LeAlloc(allocator, text); @@ -4083,7 +4203,7 @@ fn cardRoleChip(role: NodeRole) ?CardRoleChip { .stroke = blendCardColor(0x000A9FFF, Tokens.loop_card_bottom, 28), .padding = 6, .radius = 5, - .font_size = 9.5, + .font_size = 10, .font_style = .card_mono, }, .interior, .unwired => null, @@ -4123,6 +4243,16 @@ fn cardMetaPresentation( const gap = scaledValue(5, zoom); const reserved_bounds = rect(full_text_bounds.left, full_text_bounds.top, full_text_bounds.right - chip_width - gap, full_text_bounds.bottom); const now: u64 = @intCast(@max(0, std.time.timestamp())); + if (role == .cycle_only) return .{ + .text = try cardMetaForWidthAllowingFullElision( + hdc, + allocator, + node, + now, + reserved_bounds.right - reserved_bounds.left, + ), + .chip_in_detail = false, + }; const meta = cardMetaForWidth(hdc, allocator, node, now, reserved_bounds.right - reserved_bounds.left) catch |err| switch (err) { error.CardMetadataTooWide => return .{ .text = try cardMetaForWidth(hdc, allocator, node, now, full_text_bounds.right - full_text_bounds.left), @@ -9746,6 +9876,55 @@ test "card roles use Mac sequencing reachability including spawn but not message try std.testing.expect(label_pixels > 0); } +test "closed cycle card keeps Inspect in the body and its reason in the metadata footer" { + var node = graphRoleTestNode("cycle-hierarchy"); + node.title = @constCast("Main"); + node.state = @constCast("blocked"); + node.backend = @constCast("copilotCLI"); + node.created_at = 1; + const nodes = [_]GraphModel.Node{node}; + const chip = cardRoleChip(.cycle_only).?; + + for ([_]f32{ 0.55, 1, 1.8 }) |zoom| { + var state = try cardTestState(.{ .zoom = zoom, .pan_x = 140, .pan_y = 30 }); + defer state.deinitLayout(); + const dib = try ParityDib.init(1600, 600); + defer dib.deinit(); + const bounds = nodeBounds(0, &state); + for ([_]bool{ false, true }) |selected| { + fill(dib.dc, rect(0, 0, 1600, 600), Tokens.canvas_tone); + drawLoopCard(dib.dc, std.testing.allocator, node, bounds, &state, .cycle_only, false, .inspect, selected, null, true); + try std.testing.expect(c.GdiFlush() != 0); + const presentation = cardState(node); + const old_font = try selectCardFont(dib.dc, 10 * zoom, .card_state); + const pill_width = (try measuredText(dib.dc, std.testing.allocator, presentation.word)) + scaledValue(22, zoom); + _ = c.SelectObject(dib.dc, old_font); + const layout = cardTextLayout(bounds, zoom, pill_width); + const role = try cardMetaPresentation(dib.dc, std.testing.allocator, node, layout, zoom, .cycle_only); + defer std.testing.allocator.free(role.text); + try std.testing.expect(!role.chip_in_detail); + + const chip_width = try cardRoleChipWidth(dib.dc, std.testing.allocator, .cycle_only, zoom); + const chip_height = scaledValue(14, zoom); + const chip_top = @divTrunc(layout.meta.top + layout.meta.bottom - chip_height, 2); + const chip_bounds = rect(layout.meta.right - chip_width, chip_top, layout.meta.right, chip_top + chip_height); + const action = attentionActionBounds(bounds, &state); + try std.testing.expect(action.bottom <= layout.meta.top); + try std.testing.expect(chip_bounds.top >= layout.meta.top - scaledValue(1, zoom)); + try std.testing.expect(chip_bounds.bottom <= layout.meta.bottom + scaledValue(1, zoom)); + try std.testing.expect(chip_bounds.left >= bounds.left and chip_bounds.right <= bounds.right); + try std.testing.expect(dib.count(action, 0x000A9FFF) > 0); + try std.testing.expect(dib.count(chip_bounds, chip.fill) > 0); + try std.testing.expectEqual(@as(usize, 0), dib.count(rect(layout.detail.left, layout.detail.top, action.left, layout.detail.bottom), chip.fill)); + try std.testing.expectEqual(@as(?usize, 0), hitTestAttentionAction(&nodes, &.{}, action.left + 1, action.top + 1, &state)); + } + } + try std.testing.expectEqual(@as(f32, 10), chip.font_size); + try std.testing.expectEqual(blendCardColor(0x000A9FFF, Tokens.loop_card_bottom, 12), chip.fill); + try std.testing.expectEqual(blendCardColor(0x007ACDFF, Tokens.loop_card_bottom, 90), chip.ink); + try std.testing.expectEqual(blendCardColor(0x000A9FFF, Tokens.loop_card_bottom, 28), chip.stroke.?); +} + test "safe resolved worktrees expose distinct reclaim and keep targets" { var inspection = WorktreeStatus.Inspection{ .entries = std.array_list.Managed(WorktreeStatus.Entry).init(std.testing.allocator),