From caf7cadfe18b8fda69f65067b5894a36a9c26804 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 21 Sep 2026 09:47:49 -0700 Subject: [PATCH 1/4] Close canvas workspace detail gaps Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Tools/windows/uia-live-gate.ps1 | 73 ++++- graphcode-windows/src/App.zig | 64 ++++- graphcode-windows/src/GraphCanvas.zig | 400 +++++++++++++++++++++----- graphcode-windows/src/GraphModel.zig | 55 ++++ investigation/ui-parity-matrix.md | 18 +- 5 files changed, 526 insertions(+), 84 deletions(-) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index d16a42c9..98a01da4 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -823,6 +823,11 @@ try { $null = $card.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern) } $projectCardIds = @($projectCards | ForEach-Object { $_.Current.AutomationId }) + $attentionAction = @(Get-DirectChildren $graph $rawWalker | Where-Object { $_.Current.Name -eq "Reply" }) | Select-Object -First 1 + Require ($null -ne $attentionAction) "NEEDS YOU card omitted its reason-specific Reply action" + Require (($attentionAction.Current.BoundingRectangle.Width -gt 0) -and + ($attentionAction.Current.BoundingRectangle.Height -gt 0)) "Reply attention action had empty bounds" + $null = $attentionAction.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern) $reclaimOffer = @(Get-DirectChildren $graph $rawWalker | Where-Object { $_.Current.Name -eq "Reclaim" }) | Select-Object -First 1 $keepOffer = @(Get-DirectChildren $graph $rawWalker | Where-Object { $_.Current.Name -eq "Keep" }) | Select-Object -First 1 Require ($null -ne $reclaimOffer) "resolved card with a reclaimable worktree omitted its Reclaim descendant" @@ -838,7 +843,7 @@ try { # and Projects above) instead of assuming cards and the offer are contiguous blocks. $graphChildIds = @(Get-DirectChildren $graph $rawWalker | ForEach-Object { $_.Current.AutomationId } | Where-Object { $_ }) - $expectedGraphIds = @($projectCardIds + @($connectionAlert.Current.AutomationId, $reclaimOffer.Current.AutomationId, $keepOffer.Current.AutomationId) + $canvasActionIds) + $expectedGraphIds = @($projectCardIds + @($attentionAction.Current.AutomationId, $connectionAlert.Current.AutomationId, $reclaimOffer.Current.AutomationId, $keepOffer.Current.AutomationId) + $canvasActionIds) Require ((@($graphChildIds | Sort-Object) -join ",") -eq (@($expectedGraphIds | Sort-Object) -join ",")) ` "Graph exposed unexpected or missing children: $($graphChildIds -join ',')" $null = Assert-FragmentLinks $graph $rawWalker $graphChildIds "RawView Graph" @@ -1006,6 +1011,72 @@ try { "workspace chrome omitted a split control (found $($workspaceControls.Count) of 3: $(@($workspaceControls | ForEach-Object { $_.Current.Name }) -join '|'))" Require ($workspaceTabs.Count -ge 1) ` "workspace chrome exposed no tab children; found $(@($workspaceChildren | ForEach-Object { $_.Current.AutomationId }) -join '|')" + $workspacePanelToggle = @($workspaceChildren | Where-Object { + $_.Current.AutomationId -match '^workspace-toggle-panel-' -and $_.Current.Name -eq "Collapse loop panel" + }) | Select-Object -First 1 + $workspaceSparkline = @($workspaceChildren | Where-Object { + $_.Current.AutomationId -match '^workspace-detail-sparkline-' -and $_.Current.Name -eq "Metric sparkline" + }) | Select-Object -First 1 + $workspaceStart = @($workspaceChildren | Where-Object { + $_.Current.AutomationId -match '^workspace-detail-start-' -and $_.Current.Name -eq "Start time" + }) | Select-Object -First 1 + $workspaceUsage = @($workspaceChildren | Where-Object { + $_.Current.AutomationId -match '^workspace-detail-usage-' -and $_.Current.Name -match 'tokens$' + }) | Select-Object -First 1 + Require ($null -ne $workspacePanelToggle) "workspace right panel omitted collapse control" + Require ($null -ne $workspaceSparkline) "workspace right panel omitted metric sparkline child" + Require ($null -ne $workspaceStart) "workspace right panel omitted start-time child" + Require ($null -ne $workspaceUsage) "workspace right panel omitted token-usage child" + foreach ($detailChild in @($workspacePanelToggle, $workspaceSparkline, $workspaceStart, $workspaceUsage)) { + Require (($detailChild.Current.BoundingRectangle.Width -gt 0) -and + ($detailChild.Current.BoundingRectangle.Height -gt 0)) ` + "workspace right panel child $($detailChild.Current.AutomationId) has empty bounds" + } + $workspacePanelToggle.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + Start-Sleep -Milliseconds 150 + $workspaceChildren = @(Get-DirectChildren $graph $rawWalker) + $workspacePanelToggle = @($workspaceChildren | Where-Object { + $_.Current.AutomationId -match '^workspace-toggle-panel-' -and $_.Current.Name -eq "Expand loop panel" + }) | Select-Object -First 1 + Require ($null -ne $workspacePanelToggle) "workspace right panel did not expose expand control after collapse" + $workspacePanelToggle.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + Start-Sleep -Milliseconds 150 + $workspaceChildren = @(Get-DirectChildren $graph $rawWalker) + $workspaceTabs = @($workspaceChildren | Where-Object { + $_.Current.AutomationId -match '^workspace-tab-' -and $_.Current.Name -match 'tab$' + }) + $initialWorkspaceTabId = $workspaceTabs[0].Current.AutomationId + $newTab = @($workspaceChildren | Where-Object { + $_.Current.AutomationId -match '^workspace-new-tab-' -and $_.Current.Name -eq "New Tab" + }) | Select-Object -First 1 + Require ($null -ne $newTab) "workspace omitted New Tab before mounted-tab preservation check" + $newTab.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + for ($attempt = 0; $attempt -lt 60; $attempt++) { + Start-Sleep -Milliseconds 100 + $workspaceChildren = @(Get-DirectChildren $graph $rawWalker) + $workspaceTabs = @($workspaceChildren | Where-Object { + $_.Current.AutomationId -match '^workspace-tab-' -and $_.Current.Name -match 'tab$' + }) + if ($workspaceTabs.Count -ge 2) { break } + } + Require ($workspaceTabs.Count -ge 2) "New Tab did not expose a mounted background tab" + $newWorkspaceTabId = $workspaceTabs[1].Current.AutomationId + $workspaceTabs[0].GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + Start-Sleep -Milliseconds 150 + $workspaceTabs = @(Get-DirectChildren $graph $rawWalker | Where-Object { + $_.Current.AutomationId -match '^workspace-tab-' -and $_.Current.Name -match 'tab$' + }) + Require (($workspaceTabs[0].Current.AutomationId -eq $initialWorkspaceTabId) -and + ($workspaceTabs[1].Current.AutomationId -eq $newWorkspaceTabId)) ` + "switching to the mounted background tab changed terminal tab identity" + $workspaceTabs[1].GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + Start-Sleep -Milliseconds 150 + $workspaceTabs = @(Get-DirectChildren $graph $rawWalker | Where-Object { + $_.Current.AutomationId -match '^workspace-tab-' -and $_.Current.Name -match 'tab$' + }) + Require (($workspaceTabs[0].Current.AutomationId -eq $initialWorkspaceTabId) -and + ($workspaceTabs[1].Current.AutomationId -eq $newWorkspaceTabId)) ` + "switching back from the mounted background tab changed terminal tab identity" $surfaceActionPatterns["overview-destination"].Invoke() Start-Sleep -Milliseconds 150 Require ([GraphCodeUiaGateState]::PostTaggedExitCollision($process.MainWindowHandle)) ` diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 4137c06f..1bd15418 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -166,6 +166,7 @@ const UiaDynamicTarget = union(enum) { workspace_new_tab, workspace_split_right, workspace_split_down, + workspace_toggle_panel, workspace_tab: usize, workspace_tab_close: usize, }; @@ -2310,7 +2311,7 @@ pub const App = struct { fn installUiaFixture(self: *App, reset_sidebar: bool) void { if (reset_sidebar and envFlag("GRAPHCODE_UIA_RESET_SIDEBAR")) self.sidebar_state.clearExpandedNodes(); const graph_frame = - \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"uia-graph","project":{"path":"C:\\GraphCode\\fixture","name":"UIA project","remote":false},"nodes":[{"id":"11111111-1111-4111-8111-111111111111","title":"UIA loop A","loopType":"goalBased","state":"succeeded","activity":"checking tests","presence":{"presence":"idle","confidence":"reported"},"goal":{"summary":"All tests pass","predicate":"swift test","metric":{"command":"coverage","direction":"maximize"}},"modelTier":"capable","worktreeBinding":{"path":"C:\\fixture-safe","branch":"feature/parity"}},{"id":"22222222-2222-4222-8222-222222222222","title":"UIA loop B","loopType":"proactive","state":"running","activity":"needs response","presence":{"presence":"awaitingInput","confidence":"reported"},"subGraph":{"nodes":[{"id":"55555555-5555-4555-8555-555555555555","title":"UIA nested A","loopType":"turnBased","state":"idle"},{"id":"66666666-6666-4666-8666-666666666666","title":"UIA nested B","loopType":"goalBased","state":"running"}]}}],"edges":[{"id":"88888888-8888-4888-8888-888888888888","from":"11111111-1111-4111-8111-111111111111","to":"22222222-2222-4222-8222-222222222222","kind":"handoff"}]}}} + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"uia-graph","project":{"path":"C:\\GraphCode\\fixture","name":"UIA project","remote":false},"nodes":[{"id":"11111111-1111-4111-8111-111111111111","title":"UIA loop A","loopType":"goalBased","state":"succeeded","activity":"checking tests","presence":{"presence":"idle","confidence":"reported"},"createdAt":788918400,"goal":{"summary":"All tests pass","predicate":"swift test","metric":{"command":"coverage","direction":"maximize"}},"metricHistory":[{"value":1},{"value":2},{"value":3}],"usage":{"inputTokens":1200,"outputTokens":345},"modelTier":"capable","worktreeBinding":{"path":"C:\\fixture-safe","branch":"feature/parity"}},{"id":"22222222-2222-4222-8222-222222222222","title":"UIA loop B","loopType":"proactive","state":"running","activity":"needs response","presence":{"presence":"awaitingInput","confidence":"reported"},"createdAt":788918400,"usage":{"inputTokens":12,"outputTokens":34},"subGraph":{"nodes":[{"id":"55555555-5555-4555-8555-555555555555","title":"UIA nested A","loopType":"turnBased","state":"idle"},{"id":"66666666-6666-4666-8666-666666666666","title":"UIA nested B","loopType":"goalBased","state":"running"}]}}],"edges":[{"id":"88888888-8888-4888-8888-888888888888","from":"11111111-1111-4111-8111-111111111111","to":"22222222-2222-4222-8222-222222222222","kind":"handoff"}]}}} ; const chats_frame = \\{"version":2,"kind":"event","sequence":2,"event":{"quickChatsListed":[{"id":"33333333-3333-4333-8333-333333333333","title":"UIA chat A","backend":"claudeCode","createdAt":0,"activity":null},{"id":"44444444-4444-4444-8444-444444444444","title":"UIA chat B","backend":"copilot","createdAt":1,"activity":null}]}} @@ -3123,6 +3124,15 @@ pub const App = struct { self.handleAction(InputRouter.keyAction(key, ctrl, shift)); } + fn toggleWorkspaceDetailPanel(self: *App) void { + self.workspace_controls.panel_visible = !self.workspace_controls.panel_visible; + if (self.workspace_controls.panel_visible) self.surface = .workspace; + self.layoutWorkspace(); + self.syncAccessibility(); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + self.setStatus(if (self.workspace_controls.panel_visible) "Loop detail panel expanded" else "Loop detail panel collapsed"); + } + fn layoutWorkspace(self: *App) void { var client: c.RECT = undefined; if (c.GetClientRect(self.window.hwnd, &client) == 0) return; @@ -3151,7 +3161,7 @@ pub const App = struct { if (self.workspace_controls.rail_visible) Tokens.sidebar_width else 0, if (full_workspace) Tokens.header_height + Tokens.loop_bar_height else @max(0, client.bottom - panel_height), @max(0, client.right - (if (self.workspace_controls.rail_visible) Tokens.sidebar_width else 0) - - (if (full_workspace) Tokens.loop_detail_width else 0)), + (if (full_workspace and self.workspace_controls.panel_visible) Tokens.loop_detail_width else 0)), panel_height, ); } @@ -3569,6 +3579,9 @@ 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, bounds, self.model.selected_index == index, false) catch return; + if (GraphCanvas.hitTestAttentionAction(graph.nodes.items, graph.edges.items, bounds.right - 20, bounds.bottom - 12, &self.canvas) != null) { + self.appendAccessibilityElement(&elements, &owned_identities, "attention-action", key, GraphCanvas.attentionActionLabel(node), 4, GraphCanvas.attentionActionBounds(bounds, &self.canvas), false, false) catch return; + } if (GraphCanvas.hasReclaimOffer(node, if (self.worktree_inspection) |*value| value else null, self.kept_worktree_paths.items)) { const offer = GraphCanvas.reclaimOfferBounds(bounds); self.appendAccessibilityElement(&elements, &owned_identities, "reclaim", key, "Reclaim", 4, offer.reclaim, false, false) catch return; @@ -3578,7 +3591,7 @@ pub const App = struct { if (self.surface == .workspace) { if (self.workspace) |workspace| { const workspace_left = if (self.workspace_controls.rail_visible) Tokens.sidebar_width else 0; - const workspace_right = client.right - Tokens.loop_detail_width; + const workspace_right = client.right - (if (self.workspace_controls.panel_visible) Tokens.loop_detail_width else 0); const selected_index = self.model.selectedIndex() orelse 0; self.appendAccessibilityElement(&elements, &owned_identities, "workspace-toolbar", graph.project.path, graph.project.name, 4, .{ .left = workspace_left, .top = 0, .right = workspace_right, .bottom = Tokens.header_height }, false, false) catch return; self.appendAccessibilityElement(&elements, &owned_identities, "workspace-loop-bar", if (selected_index < graph.nodes.items.len) graph.nodes.items[selected_index].id else "none", "Selected loop workspace", 4, .{ .left = workspace_left, .top = Tokens.header_height, .right = workspace_right, .bottom = Tokens.header_height + Tokens.loop_bar_height }, false, false) catch return; @@ -3586,6 +3599,27 @@ pub const App = struct { if (selected_index < graph.nodes.items.len and !isResolvedLoopState(graph.nodes.items[selected_index].state)) { self.appendAccessibilityElement(&elements, &owned_identities, "workspace-stop", graph.nodes.items[selected_index].id, "Stop loop", 4, .{ .left = workspace_right - 196, .top = Tokens.header_height + 10, .right = workspace_right - 112, .bottom = Tokens.header_height + 36 }, false, false) catch return; } + const panel_toggle = if (self.workspace_controls.panel_visible) + GraphCanvas.loopDetailCollapseBounds(client.right) + else + GraphCanvas.loopDetailExpandBounds(client.right); + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-toggle-panel", "control", if (self.workspace_controls.panel_visible) "Collapse loop panel" else "Expand loop panel", 4, panel_toggle, false, true) catch return; + if (self.workspace_controls.panel_visible and selected_index < graph.nodes.items.len) { + const detail_left = client.right - Tokens.loop_detail_width; + const selected_node = graph.nodes.items[selected_index]; + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-detail-sparkline", selected_node.id, "Metric sparkline", 4, .{ .left = detail_left + 18, .top = client.bottom - 102, .right = client.right - 18, .bottom = client.bottom - 70 }, false, false) catch return; + if (selected_node.created_at != null) { + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-detail-start", selected_node.id, "Start time", 4, .{ .left = detail_left + 18, .top = client.bottom - 64, .right = client.right - 18, .bottom = client.bottom - 44 }, false, false) catch return; + } + if (selected_node.token_usage) |tokens| { + const usage_name = std.fmt.allocPrint(self.allocator, "{d} tokens", .{tokens}) catch return; + owned_identities.append(usage_name) catch { + self.allocator.free(usage_name); + return; + }; + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-detail-usage", selected_node.id, usage_name, 4, .{ .left = detail_left + 18, .top = client.bottom - 44, .right = client.right - 18, .bottom = client.bottom - 24 }, false, false) catch return; + } + } for (workspace.layout.tabs.items, 0..) |tab, tab_index| { const tab_key = std.fmt.allocPrint(self.allocator, "{d}", .{tab_index}) catch return; defer self.allocator.free(tab_key); @@ -3732,6 +3766,8 @@ pub const App = struct { defer self.allocator.free(overview_identity); const project_card_identity = std.fmt.allocPrint(self.allocator, "project-card:{s}", .{key}) catch return false; 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 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; @@ -3740,7 +3776,8 @@ pub const App = struct { defer self.allocator.free(loop_disclosure_identity); if (Accessibility.worktreeIdentityPayload(sidebar_identity) == payload or Accessibility.worktreeIdentityPayload(overview_identity) == payload or - Accessibility.worktreeIdentityPayload(project_card_identity) == payload) + Accessibility.worktreeIdentityPayload(project_card_identity) == payload or + Accessibility.worktreeIdentityPayload(attention_identity) == payload) { if (target != null) return false; target = .{ .loop = .{ .project_path = graph.project.path, .index = index } }; @@ -3798,6 +3835,7 @@ pub const App = struct { .{ .identity = "workspace-new-tab:control", .target = .workspace_new_tab }, .{ .identity = "workspace-split-right:control", .target = .workspace_split_right }, .{ .identity = "workspace-split-down:control", .target = .workspace_split_down }, + .{ .identity = "workspace-toggle-panel:control", .target = .workspace_toggle_panel }, }; for (workspace_static) |candidate| { if (Accessibility.worktreeIdentityPayload(candidate.identity) == payload) { @@ -3934,6 +3972,7 @@ pub const App = struct { .workspace_new_tab => self.handleAction(.new_tab), .workspace_split_right => self.handleAction(.split_horizontal), .workspace_split_down => self.handleAction(.split_vertical), + .workspace_toggle_panel => self.toggleWorkspaceDetailPanel(), .workspace_tab => |index| if (self.workspace) |workspace| workspace.selectTab(index) catch return false, .workspace_tab_close => |index| if (self.workspace) |workspace| workspace.closeTab(index) catch return false, } @@ -4266,11 +4305,12 @@ fn onWindowMessage( if (app.workspace_controls.panel_visible or app.surface == .workspace) { if (app.surface == .workspace) { if (workspaceGraph(&app.model)) |graph| { + const workspace_right = clientRight(hwnd) - (if (app.workspace_controls.panel_visible) Tokens.loop_detail_width else 0); TerminalWorkspace.Workspace.paintWorkspaceToolbar( hdc, app.allocator, if (app.workspace_controls.rail_visible) Tokens.sidebar_width else 0, - clientRight(hwnd) - Tokens.loop_detail_width, + workspace_right, graph.project.name, graph.project.path, ); @@ -4281,7 +4321,7 @@ fn onWindowMessage( hdc, app.allocator, if (app.workspace_controls.rail_visible) Tokens.sidebar_width else 0, - clientRight(hwnd) - Tokens.loop_detail_width, + workspace_right, graph.project.name, node.title, node.loop_type, @@ -4294,10 +4334,11 @@ fn onWindowMessage( isResolvedLoopState(node.state), ); } + if (!app.workspace_controls.panel_visible) GraphCanvas.paintLoopDetailExpandControl(hdc, app.allocator, clientRight(hwnd)); } } if (app.workspace) |workspace| workspace.paintChrome(hdc); - if (app.surface == .workspace) { + if (app.surface == .workspace and app.workspace_controls.panel_visible) { if (workspaceGraph(&app.model)) |graph| { const index = app.model.selectedIndex() orelse graph.nodes.items.len; GraphCanvas.paintLoopDetailRail( @@ -4565,6 +4606,12 @@ fn onWindowMessage( } if ((app.workspace_controls.panel_visible or app.surface == .workspace) and x >= rail_left and y >= workspace_top) { if (app.surface == .workspace) { + if (GraphCanvas.hitTestLoopDetailCollapse(x, y, client.right, app.workspace_controls.panel_visible)) { + app.toggleWorkspaceDetailPanel(); + _ = c.InvalidateRect(hwnd, null, 0); + result.* = 0; + return true; + } if (workspaceGraph(&app.model)) |graph| { const index = app.model.selectedIndex() orelse graph.nodes.items.len; if (index < graph.nodes.items.len) { @@ -4701,6 +4748,9 @@ fn onWindowMessage( .keep => app.keepWorktreeOffer(path), } _ = c.InvalidateRect(hwnd, null, 0); + } else if (GraphCanvas.hitTestAttentionAction(graph.nodes.items, graph.edges.items, x, y, &app.canvas)) |index| { + _ = app.selectNodeIndex(index); + app.openSelectedNode(); } else if (GraphCanvas.hitTestConnector(graph.nodes.items, x, y, &app.canvas, bounds)) |index| { if (app.edge_drag_source_id.len != 0) app.allocator.free(app.edge_drag_source_id); app.edge_drag_source_id = app.allocator.dupe(u8, graph.nodes.items[index].id) catch &.{}; diff --git a/graphcode-windows/src/GraphCanvas.zig b/graphcode-windows/src/GraphCanvas.zig index ac9823be..94220101 100644 --- a/graphcode-windows/src/GraphCanvas.zig +++ b/graphcode-windows/src/GraphCanvas.zig @@ -279,6 +279,7 @@ pub const OverviewHit = struct { graph_index: usize, node_index: usize }; pub const OverviewLaneAction = enum { open_project, inspect_worktrees }; pub const ZoomControl = enum { out, actual, in, fit }; pub const HeaderAction = enum { review_attention, inspect_worktrees, jump, toggle_panel }; +pub const AttentionAction = enum { reply, inspect }; pub const ReclaimAction = enum { reclaim, keep }; pub const ReclaimHit = struct { node_index: usize, action: ReclaimAction }; @@ -290,6 +291,24 @@ pub fn hitTestAttentionRail(x: i32, y: i32, width: i32) bool { return insideGraph(x, y, attentionRailBounds(width)); } +pub fn loopDetailCollapseBounds(client_right: i32) c.RECT { + return rect(client_right - Tokens.loop_detail_width + 172, Tokens.header_height + 12, client_right - 18, Tokens.header_height + 34); +} + +pub fn loopDetailExpandBounds(client_right: i32) c.RECT { + return rect(client_right - 104, Tokens.header_height + 8, client_right - 14, Tokens.header_height + 30); +} + +pub fn hitTestLoopDetailCollapse(x: i32, y: i32, client_right: i32, visible: bool) bool { + return insideGraph(x, y, if (visible) loopDetailCollapseBounds(client_right) else loopDetailExpandBounds(client_right)); +} + +pub fn paintLoopDetailExpandControl(hdc: c.HDC, allocator: std.mem.Allocator, client_right: i32) void { + const bounds = loopDetailExpandBounds(client_right); + fill(hdc, bounds, 0x00303035); + drawTextRect(hdc, allocator, "Loop panel", bounds, 10, 0x00D8D8DE, c.DT_CENTER | c.DT_SINGLELINE | c.DT_VCENTER); +} + pub fn renderBounds(client_right: i32, client_bottom: i32, controls: WorkspaceControls.State) RenderBounds { const left = if (controls.rail_visible) Tokens.sidebar_width else 0; const activity = if (controls.activity_enabled) Tokens.activity_strip_height else 0; @@ -413,8 +432,7 @@ pub fn paint( hdc, allocator, model, - rect(0, client.bottom - workspace_height - Tokens.activity_strip_height, - client.right, client.bottom - workspace_height), + rect(0, client.bottom - workspace_height - Tokens.activity_strip_height, client.right, client.bottom - workspace_height), ); } } @@ -458,62 +476,62 @@ pub fn hitTestCompositeBack(model: *const GraphModel.Model, x: i32, y: i32, boun } fn drawOverview( - hdc: c.HDC, - allocator: std.mem.Allocator, - model: *const GraphModel.Model, - bounds: c.RECT, - state: *const CanvasState, - ) void { - if (model.graphs.items.len == 0) { - const center_y = bounds.top + @divTrunc(bounds.bottom - bounds.top, 2) - 60; - drawTextRect(hdc, allocator, "Nothing running yet", rect(bounds.left + 40, center_y, bounds.right - 40, center_y + 34), 20, 0x00F2F2F2, c.DT_CENTER | c.DT_SINGLELINE); - drawTextRect(hdc, allocator, "Loops from every folder you open show up here, wired to how they run.", rect(bounds.left + 100, center_y + 40, bounds.right - 100, center_y + 86), 13, 0x00A8A8AE, c.DT_CENTER | c.DT_WORDBREAK); - return; - } - for (model.graphs.items, 0..) |graph, graph_index| { - const lane = overviewLaneBounds(model, graph_index, bounds, state); - roundedCard(hdc, lane, 0x001D1D21, false); - drawText(hdc, allocator, graph.project.name, lane.left + scaledValue(18, state.zoom), lane.top + scaledValue(16, state.zoom), scaledValue(14, state.zoom), 0x00E8E8E8); - const open = rect(lane.right - scaledValue(132, state.zoom), lane.top + scaledValue(10, state.zoom), lane.right - scaledValue(76, state.zoom), lane.top + scaledValue(30, state.zoom)); - const worktrees = rect(lane.right - scaledValue(72, state.zoom), lane.top + scaledValue(10, state.zoom), lane.right - scaledValue(18, state.zoom), lane.top + scaledValue(30, state.zoom)); - fill(hdc, open, 0x002D2418); - fill(hdc, worktrees, 0x00352B1C); - drawTextRect(hdc, allocator, "Open", open, scaledValue(9, state.zoom), 0x00E6E6E6, c.DT_CENTER | c.DT_SINGLELINE | c.DT_VCENTER); - drawTextRect(hdc, allocator, "Worktrees", worktrees, scaledValue(8, state.zoom), 0x00FFCD7A, c.DT_CENTER | c.DT_SINGLELINE | c.DT_VCENTER); - var index: usize = 0; - while (index < graph.nodes.items.len) : (index += 1) { - const card = overviewCardBounds(model, graph_index, index, bounds, state); - roundedCard(hdc, card, 0x00262626, false); - fill(hdc, rect(card.left, card.top, card.left + scaledValue(4, state.zoom), card.bottom), loopTypeColor(graph.nodes.items[index].loop_type)); - drawText(hdc, allocator, graph.nodes.items[index].title, card.left + scaledValue(14, state.zoom), card.top + scaledValue(16, state.zoom), scaledValue(13, state.zoom), 0x00FFFFFF); - drawText(hdc, allocator, graph.nodes.items[index].state, card.left + scaledValue(14, state.zoom), card.top + scaledValue(46, state.zoom), scaledValue(10, state.zoom), 0x00B8B8B8); - } + hdc: c.HDC, + allocator: std.mem.Allocator, + model: *const GraphModel.Model, + bounds: c.RECT, + state: *const CanvasState, +) void { + if (model.graphs.items.len == 0) { + const center_y = bounds.top + @divTrunc(bounds.bottom - bounds.top, 2) - 60; + drawTextRect(hdc, allocator, "Nothing running yet", rect(bounds.left + 40, center_y, bounds.right - 40, center_y + 34), 20, 0x00F2F2F2, c.DT_CENTER | c.DT_SINGLELINE); + drawTextRect(hdc, allocator, "Loops from every folder you open show up here, wired to how they run.", rect(bounds.left + 100, center_y + 40, bounds.right - 100, center_y + 86), 13, 0x00A8A8AE, c.DT_CENTER | c.DT_WORDBREAK); + return; + } + for (model.graphs.items, 0..) |graph, graph_index| { + const lane = overviewLaneBounds(model, graph_index, bounds, state); + roundedCard(hdc, lane, 0x001D1D21, false); + drawText(hdc, allocator, graph.project.name, lane.left + scaledValue(18, state.zoom), lane.top + scaledValue(16, state.zoom), scaledValue(14, state.zoom), 0x00E8E8E8); + const open = rect(lane.right - scaledValue(132, state.zoom), lane.top + scaledValue(10, state.zoom), lane.right - scaledValue(76, state.zoom), lane.top + scaledValue(30, state.zoom)); + const worktrees = rect(lane.right - scaledValue(72, state.zoom), lane.top + scaledValue(10, state.zoom), lane.right - scaledValue(18, state.zoom), lane.top + scaledValue(30, state.zoom)); + fill(hdc, open, 0x002D2418); + fill(hdc, worktrees, 0x00352B1C); + drawTextRect(hdc, allocator, "Open", open, scaledValue(9, state.zoom), 0x00E6E6E6, c.DT_CENTER | c.DT_SINGLELINE | c.DT_VCENTER); + drawTextRect(hdc, allocator, "Worktrees", worktrees, scaledValue(8, state.zoom), 0x00FFCD7A, c.DT_CENTER | c.DT_SINGLELINE | c.DT_VCENTER); + var index: usize = 0; + while (index < graph.nodes.items.len) : (index += 1) { + const card = overviewCardBounds(model, graph_index, index, bounds, state); + roundedCard(hdc, card, 0x00262626, false); + fill(hdc, rect(card.left, card.top, card.left + scaledValue(4, state.zoom), card.bottom), loopTypeColor(graph.nodes.items[index].loop_type)); + drawText(hdc, allocator, graph.nodes.items[index].title, card.left + scaledValue(14, state.zoom), card.top + scaledValue(16, state.zoom), scaledValue(13, state.zoom), 0x00FFFFFF); + drawText(hdc, allocator, graph.nodes.items[index].state, card.left + scaledValue(14, state.zoom), card.top + scaledValue(46, state.zoom), scaledValue(10, state.zoom), 0x00B8B8B8); } } +} fn drawQuickChats( - hdc: c.HDC, - allocator: std.mem.Allocator, - model: *const GraphModel.Model, - bounds: c.RECT, - state: *const CanvasState, - ) void { - if (model.quick_chats.items.len == 0) { - const center_y = bounds.top + @divTrunc(bounds.bottom - bounds.top, 2) - 60; - drawTextRect(hdc, allocator, "No chats yet", rect(bounds.left + 40, center_y, bounds.right - 40, center_y + 34), 20, 0x00F2F2F2, c.DT_CENTER | c.DT_SINGLELINE); - drawTextRect(hdc, allocator, "A quick chat is a bare session for questions that are not a loop's work.", rect(bounds.left + 100, center_y + 40, bounds.right - 100, center_y + 86), 13, 0x00A8A8AE, c.DT_CENTER | c.DT_WORDBREAK); - return; - } - const rows = (model.quick_chats.items.len + 2) / 3; - const band = transformedRect(bounds, state, 24, 34, @max(760, bounds.right - bounds.left - 48), @as(i32, @intCast(rows * 104 + 32))); - roundedCard(hdc, band, 0x001D1D21, false); - for (model.quick_chats.items, 0..) |chat, index| { - const card = quickChatCardBounds(index, bounds, state); - roundedCard(hdc, card, 0x00262626, false); - fill(hdc, rect(card.left, card.top, card.left + scaledValue(4, state.zoom), card.bottom), 0x007A7A7A); - drawText(hdc, allocator, chat.title, card.left + scaledValue(14, state.zoom), card.top + scaledValue(12, state.zoom), scaledValue(13, state.zoom), 0x00FFFFFF); - drawText(hdc, allocator, if (std.mem.eql(u8, chat.backend, "claudeCode")) "chat" else chat.backend, card.left + scaledValue(14, state.zoom), card.top + scaledValue(37, state.zoom), scaledValue(10, state.zoom), 0x009A9A9A); - } + hdc: c.HDC, + allocator: std.mem.Allocator, + model: *const GraphModel.Model, + bounds: c.RECT, + state: *const CanvasState, +) void { + if (model.quick_chats.items.len == 0) { + const center_y = bounds.top + @divTrunc(bounds.bottom - bounds.top, 2) - 60; + drawTextRect(hdc, allocator, "No chats yet", rect(bounds.left + 40, center_y, bounds.right - 40, center_y + 34), 20, 0x00F2F2F2, c.DT_CENTER | c.DT_SINGLELINE); + drawTextRect(hdc, allocator, "A quick chat is a bare session for questions that are not a loop's work.", rect(bounds.left + 100, center_y + 40, bounds.right - 100, center_y + 86), 13, 0x00A8A8AE, c.DT_CENTER | c.DT_WORDBREAK); + return; + } + const rows = (model.quick_chats.items.len + 2) / 3; + const band = transformedRect(bounds, state, 24, 34, @max(760, bounds.right - bounds.left - 48), @as(i32, @intCast(rows * 104 + 32))); + roundedCard(hdc, band, 0x001D1D21, false); + for (model.quick_chats.items, 0..) |chat, index| { + const card = quickChatCardBounds(index, bounds, state); + roundedCard(hdc, card, 0x00262626, false); + fill(hdc, rect(card.left, card.top, card.left + scaledValue(4, state.zoom), card.bottom), 0x007A7A7A); + drawText(hdc, allocator, chat.title, card.left + scaledValue(14, state.zoom), card.top + scaledValue(12, state.zoom), scaledValue(13, state.zoom), 0x00FFFFFF); + drawText(hdc, allocator, if (std.mem.eql(u8, chat.backend, "claudeCode")) "chat" else chat.backend, card.left + scaledValue(14, state.zoom), card.top + scaledValue(37, state.zoom), scaledValue(10, state.zoom), 0x009A9A9A); + } } fn overviewLaneHeight(node_count: usize) i32 { @@ -826,11 +844,22 @@ fn attentionRail( const rail = attentionRailBounds(width); fill(hdc, rail, 0x002D2418); drawText(hdc, allocator, label, Tokens.sidebar_width + 34, Tokens.header_height + 21, 12, 0x00FFCD7A); - const oldest = if (model.attention_entries.items.len != 0) model.attention_entries.items[0].node.title else "none"; + var age_buffer: [96]u8 = undefined; + const oldest = if (model.attention_entries.items.len != 0) + attentionAgeLabel(&age_buffer, model.attention_entries.items[0].node) + else + "none"; drawText(hdc, allocator, "Review", Tokens.sidebar_width + 210, Tokens.header_height + 21, 11, 0x00E6E6E6); drawText(hdc, allocator, oldest, Tokens.sidebar_width + 274, Tokens.header_height + 21, 10, 0x00B8B8B8); } +fn attentionAgeLabel(buffer: []u8, node: GraphModel.Node) []const u8 { + if (node.created_at) |created| { + return std.fmt.bufPrint(buffer, "oldest {s}: {s}", .{ elapsedLabel(created), node.title }) catch node.title; + } + return node.title; +} + fn activityStrip( hdc: c.HDC, allocator: std.mem.Allocator, @@ -969,7 +998,7 @@ fn edgeKindColor(kind: []const u8) u32 { fn edgeLabel(buffer: []u8, edge: GraphModel.Edge) []const u8 { const kind = if (edge.kind.len == 0) "handoff" else edge.kind; if (edge.fire_count != 0) - return std.fmt.bufPrint(buffer, "{s} · {s} · fired {d}", .{ kind, edge.condition, edge.fire_count }) catch kind; + return std.fmt.bufPrint(buffer, "{s} · {s} · retry ×{d}", .{ kind, edge.condition, edge.fire_count }) catch kind; if (!std.mem.eql(u8, edge.condition, "always")) return std.fmt.bufPrint(buffer, "{s} · {s}", .{ kind, edge.condition }) catch kind; return kind; @@ -1055,12 +1084,18 @@ fn drawNode( if (role == .unwired and !reclaim_offer) drawText(hdc, allocator, "No connections · right-click to recover", x + scaled(14, state), y + layout.state_y + scaled(43, state), scaled(8, state), 0x00FFCD7A); } - if (layout.show_attention) drawText(hdc, allocator, "NEEDS YOU", bounds.right - scaled(88, state), y + scaled(8, state), scaled(9, state), 0x00FFB340); + if (layout.show_attention) { + drawText(hdc, allocator, "NEEDS YOU", bounds.right - scaled(88, state), y + scaled(8, state), scaled(9, state), 0x00FFB340); + const action = attentionActionBounds(bounds, state); + fill(hdc, action, 0x00FF9F0A); + drawTextRect(hdc, allocator, attentionActionLabel(node), action, scaled(9, state), 0x00241703, c.DT_CENTER | c.DT_SINGLELINE | c.DT_VCENTER); + } if (state.hovered_connector == index) { const connector = connectorPositionForIndex(nodes, index, true, state); fill(hdc, rect(connector.x - connectorRadius(state), connector.y - connectorRadius(state), connector.x + connectorRadius(state), connector.y + connectorRadius(state)), 0x00FFCD7A); drawTextRect(hdc, allocator, "+", rect(connector.x - scaled(8, state), connector.y - scaled(8, state), connector.x + scaled(8, state), connector.y + scaled(8, state)), scaled(12, state), 0x00262626, c.DT_CENTER | c.DT_SINGLELINE | c.DT_VCENTER); } + if (reclaim_offer) { const offer = reclaimOfferBounds(bounds); fill(hdc, offer.reclaim, 0x003A3A44); @@ -1069,6 +1104,38 @@ fn drawNode( } } +pub fn attentionActionLabel(node: GraphModel.Node) []const u8 { + return switch (attentionActionForNode(node)) { + .reply => "Reply", + .inspect => "Inspect", + }; +} + +pub fn attentionActionForNode(node: GraphModel.Node) AttentionAction { + if (std.mem.eql(u8, node.state, "running") and std.mem.eql(u8, node.presence, "awaitingInput")) return .reply; + return .inspect; +} + +pub fn attentionActionBounds(bounds: c.RECT, state: *const CanvasState) c.RECT { + return rect(bounds.right - scaled(78, state), bounds.bottom - scaled(28, state), bounds.right - scaled(12, state), bounds.bottom - scaled(8, state)); +} + +pub fn hitTestAttentionAction( + nodes: []const GraphModel.Node, + edges: []const GraphModel.Edge, + x: i32, + y: i32, + state: *const CanvasState, +) ?usize { + var index = nodes.len; + while (index > 0) { + index -= 1; + if (!needsAttention(nodes[index], nodes, edges)) continue; + if (insideGraph(x, y, attentionActionBounds(nodeBounds(index, state), state))) return index; + } + return null; +} + const ReclaimOfferBounds = struct { reclaim: c.RECT, keep: c.RECT }; pub fn reclaimOfferBounds(bounds: c.RECT) ReclaimOfferBounds { @@ -1120,13 +1187,82 @@ fn nodePrimaryDetail(node: GraphModel.Node) []const u8 { } fn nodeMetadata(buffer: []u8, node: GraphModel.Node) []const u8 { - if (node.worktree_branch.len != 0 and node.model_tier.len != 0) - return std.fmt.bufPrint(buffer, "{s} · {s}", .{ node.worktree_branch, node.model_tier }) catch node.worktree_branch; - if (node.worktree_branch.len != 0) return node.worktree_branch; - if (node.model_tier.len != 0) return node.model_tier; + var stream = std.io.fixedBufferStream(buffer); + const writer = stream.writer(); + var wrote = false; + if (node.metric_passes != 0) { + writer.print("pass {d}", .{node.metric_passes}) catch return buffer[0..stream.pos]; + wrote = true; + } + if (node.metric_sample_count >= 2) { + if (wrote) writer.writeAll(" · ") catch return buffer[0..stream.pos]; + writeMetricChange(writer, node) catch return buffer[0..stream.pos]; + wrote = true; + } + if (node.backend.len != 0) { + if (wrote) writer.writeAll(" · ") catch return buffer[0..stream.pos]; + writer.writeAll(node.backend) catch return buffer[0..stream.pos]; + wrote = true; + } + + if (node.created_at) |created| { + if (wrote) writer.writeAll(" · ") catch return buffer[0..stream.pos]; + writer.writeAll(elapsedLabel(created)) catch return buffer[0..stream.pos]; + wrote = true; + } + if (node.token_usage) |tokens| { + if (wrote) writer.writeAll(" · ") catch return buffer[0..stream.pos]; + formatTokenUsage(writer, tokens) catch return buffer[0..stream.pos]; + wrote = true; + } + if (node.worktree_branch.len != 0) { + if (wrote) writer.writeAll(" · ") catch return buffer[0..stream.pos]; + writer.writeAll(node.worktree_branch) catch return buffer[0..stream.pos]; + wrote = true; + } + if (node.model_tier.len != 0) { + if (wrote) writer.writeAll(" · ") catch return buffer[0..stream.pos]; + writer.writeAll(node.model_tier) catch return buffer[0..stream.pos]; + wrote = true; + } if (node.metric_command.len != 0) - return std.fmt.bufPrint(buffer, "metric · {s}", .{if (node.metric_direction.len != 0) node.metric_direction else "configured"}) catch "metric"; - return ""; + if (!wrote) return std.fmt.bufPrint(buffer, "metric · {s}", .{if (node.metric_direction.len != 0) node.metric_direction else "configured"}) catch "metric"; + return buffer[0..stream.pos]; +} + +fn writeMetricChange(writer: anytype, node: GraphModel.Node) !void { + const first = node.metric_samples[0]; + const last = node.metric_samples[@as(usize, node.metric_sample_count) - 1]; + const gain = if (std.mem.eql(u8, node.metric_direction, "minimize")) first - last else last - first; + try writer.writeAll("metric "); + try writeMetricNumber(writer, first); + try writer.writeAll(" -> "); + try writeMetricNumber(writer, last); + try writer.writeAll(if (gain > 0) " better" else if (gain < 0) " worse" else " flat"); +} + +fn writeMetricNumber(writer: anytype, value: f64) !void { + if (@abs(value) < 1_000_000 and value == @round(value)) + return writer.print("{d}", .{@as(i64, @intFromFloat(value))}); + return writer.print("{d:.2}", .{value}); +} + +fn elapsedLabel(created_at: u64) []const u8 { + const normalized = if (created_at < 1_000_000_000_000) created_at *| 1000 else created_at; + const now = std.time.milliTimestamp(); + const created: i64 = @intCast(@min(normalized, @as(u64, std.math.maxInt(i64)))); + const elapsed_ms: u64 = if (now > created) @intCast(now - created) else 0; + const seconds = elapsed_ms / 1000; + if (seconds < 60) return "0m"; + if (seconds < 3600) return "<1h"; + if (seconds < 86_400) return ">1h"; + return ">1d"; +} + +fn formatTokenUsage(writer: anytype, tokens: u32) !void { + if (tokens >= 1_000_000) return writer.print("{d}M tok", .{tokens / 1_000_000}); + if (tokens >= 1000) return writer.print("{d}k tok", .{tokens / 1000}); + return writer.print("{d} tok", .{tokens}); } pub fn nodeBounds(index: usize, state: *const CanvasState) c.RECT { @@ -1334,6 +1470,9 @@ pub fn paintLoopDetailRail( fill(hdc, rect(left, top, client_right, client_bottom), 0x0028282C); fill(hdc, rect(left, top, left + 1, client_bottom), 0x0045454B); drawText(hdc, allocator, "LOOP MAP", left + 18, top + 16, 11, 0x009898A0); + const collapse = loopDetailCollapseBounds(client_right); + fill(hdc, collapse, 0x00303035); + drawTextRect(hdc, allocator, "Collapse", collapse, 10, 0x00D8D8DE, c.DT_CENTER | c.DT_SINGLELINE | c.DT_VCENTER); const map_top = top + 44; roundedCard(hdc, rect(left + 18, map_top, client_right - 18, map_top + 72), 0x00303035, false); @@ -1388,14 +1527,84 @@ pub fn paintLoopDetailRail( y += 34; } - const footer_top = @max(y + 18, client_bottom - 148); + const footer_top = @max(y + 18, client_bottom - 188); fill(hdc, rect(left + 18, footer_top, client_right - 18, footer_top + 1), 0x0045454B); drawText(hdc, allocator, "DETAIL", left + 18, footer_top + 14, 11, 0x009898A0); const branch = if (node.worktree_branch.len != 0) node.worktree_branch else if (node.worktree_path.len != 0) node.worktree_path else "Primary checkout"; drawText(hdc, allocator, branch, left + 18, footer_top + 38, 12, 0x00D8D8DE); const metric = if (node.metric_command.len != 0) node.metric_command else if (node.goal_summary.len != 0) node.goal_summary else "No metric configured"; drawText(hdc, allocator, metric, left + 18, footer_top + 62, 12, 0x009898A0); - if (node.model_tier.len != 0) drawText(hdc, allocator, node.model_tier, left + 18, footer_top + 86, 12, 0x007AB8FF); + paintMetricSparkline(hdc, node, rect(left + 18, footer_top + 86, client_right - 18, footer_top + 118)); + var meta: [160]u8 = undefined; + const meta_text = loopDetailFooter(&meta, node); + if (meta_text.len != 0) drawText(hdc, allocator, meta_text, left + 18, footer_top + 124, 11, 0x007AB8FF); + if (node.model_tier.len != 0) drawText(hdc, allocator, node.model_tier, left + 18, footer_top + 148, 12, 0x007AB8FF); +} + +fn loopDetailFooter(buffer: []u8, node: GraphModel.Node) []const u8 { + var stream = std.io.fixedBufferStream(buffer); + const writer = stream.writer(); + var wrote = false; + if (node.created_at) |created| { + writer.writeAll("started ") catch return buffer[0..stream.pos]; + writer.writeAll(startTimeLabel(created)) catch return buffer[0..stream.pos]; + wrote = true; + } + if (node.token_usage) |tokens| { + if (wrote) writer.writeAll(" · ") catch return buffer[0..stream.pos]; + formatTokenUsage(writer, tokens) catch return buffer[0..stream.pos]; + wrote = true; + } + if (node.backend.len != 0) { + if (wrote) writer.writeAll(" · ") catch return buffer[0..stream.pos]; + writer.writeAll(node.backend) catch return buffer[0..stream.pos]; + } + return buffer[0..stream.pos]; +} + +fn startTimeLabel(created_at: u64) []const u8 { + const normalized = if (created_at < 1_000_000_000_000) created_at *| 1000 else created_at; + const now = std.time.milliTimestamp(); + const created: i64 = @intCast(@min(normalized, @as(u64, std.math.maxInt(i64)))); + const elapsed_ms: u64 = if (now > created) @intCast(now - created) else 0; + const minutes = elapsed_ms / 1000 / 60; + if (minutes < 1) return "just now"; + if (minutes < 60) return "<1h ago"; + if (minutes < 1440) return "today"; + return "earlier"; +} + +fn paintMetricSparkline(hdc: c.HDC, node: GraphModel.Node, bounds: c.RECT) void { + fill(hdc, bounds, 0x00222226); + if (node.metric_sample_count == 0) { + drawTextRect(hdc, std.heap.page_allocator, "No metric samples", bounds, 10, 0x007A7A82, c.DT_CENTER | c.DT_SINGLELINE | c.DT_VCENTER); + return; + } + const samples = node.metric_samples[0..@as(usize, node.metric_sample_count)]; + var min = samples[0]; + var max = samples[0]; + for (samples) |value| { + min = @min(min, value); + max = @max(max, value); + } + const range = if (max > min) max - min else 1; + const count: i32 = @intCast(samples.len); + var previous_x = bounds.left + 8; + var previous_y = bounds.bottom - 8 - @as(i32, @intFromFloat(((samples[0] - min) / range) * @as(f64, @floatFromInt(bounds.bottom - bounds.top - 16)))); + const pen = c.CreatePen(c.PS_SOLID, 2, 0x007AB8FF); + if (pen == null) return; + const old = c.SelectObject(hdc, pen); + var index: usize = 1; + while (index < samples.len) : (index += 1) { + const x = bounds.left + 8 + @divTrunc(@as(i32, @intCast(index)) * @max(1, bounds.right - bounds.left - 16), @max(1, count - 1)); + const y = bounds.bottom - 8 - @as(i32, @intFromFloat(((samples[index] - min) / range) * @as(f64, @floatFromInt(bounds.bottom - bounds.top - 16)))); + _ = c.MoveToEx(hdc, previous_x, previous_y, null); + _ = c.LineTo(hdc, x, y); + previous_x = x; + previous_y = y; + } + _ = c.SelectObject(hdc, old); + _ = c.DeleteObject(pen); } fn paintRelationRow( @@ -1488,8 +1697,18 @@ fn drawTextRect( const wide = std.unicode.utf8ToUtf16LeAlloc(allocator, text_value) catch return; defer allocator.free(wide); const font = c.CreateFontW( - -size, 0, 0, 0, c.FW_NORMAL, 0, 0, 0, c.DEFAULT_CHARSET, - c.OUT_DEFAULT_PRECIS, c.CLIP_DEFAULT_PRECIS, c.CLEARTYPE_QUALITY, + -size, + 0, + 0, + 0, + c.FW_NORMAL, + 0, + 0, + 0, + c.DEFAULT_CHARSET, + c.OUT_DEFAULT_PRECIS, + c.CLIP_DEFAULT_PRECIS, + c.CLEARTYPE_QUALITY, c.DEFAULT_PITCH | c.FF_DONTCARE, std.unicode.utf8ToUtf16LeStringLiteral("Segoe UI").ptr, ); @@ -1723,6 +1942,53 @@ test "loop card detail prioritizes goal and preserves metadata" { try std.testing.expectEqualStrings("feature/parity · capable", nodeMetadata(&buffer, node)); } +test "loop card metadata includes backend elapsed and token usage when reported" { + const node = GraphModel.Node{ + .id = @constCast("node"), + .title = @constCast("Live"), + .loop_type = @constCast("goalBased"), + .state = @constCast("running"), + .activity = @constCast(""), + .presence = @constCast("busy"), + .backend = @constCast("copilotCLI"), + .created_at = 0, + .metric_passes = 3, + .metric_samples = [_]f64{ 1, 2, 3, 0, 0, 0, 0, 0 }, + .metric_sample_count = 3, + .metric_direction = @constCast("maximize"), + .token_usage = 12_345, + }; + var buffer: [128]u8 = undefined; + const metadata = nodeMetadata(&buffer, node); + try std.testing.expect(std.mem.indexOf(u8, metadata, "pass 3") != null); + try std.testing.expect(std.mem.indexOf(u8, metadata, "metric 1 -> 3 better") != null); + try std.testing.expect(std.mem.indexOf(u8, metadata, "copilotCLI") != null); + try std.testing.expect(std.mem.indexOf(u8, metadata, "12k tok") != null); +} + +test "attention cards expose reason-specific primary actions" { + const awaiting = GraphModel.Node{ + .id = @constCast("awaiting"), + .title = @constCast("Question"), + .loop_type = @constCast("turnBased"), + .state = @constCast("running"), + .activity = @constCast(""), + .presence = @constCast("awaitingInput"), + }; + const failed = GraphModel.Node{ + .id = @constCast("failed"), + .title = @constCast("Broken"), + .loop_type = @constCast("goalBased"), + .state = @constCast("failed"), + .activity = @constCast(""), + .presence = @constCast("idle"), + }; + try std.testing.expectEqual(AttentionAction.reply, attentionActionForNode(awaiting)); + try std.testing.expectEqualStrings("Reply", attentionActionLabel(awaiting)); + try std.testing.expectEqual(AttentionAction.inspect, attentionActionForNode(failed)); + try std.testing.expectEqualStrings("Inspect", attentionActionLabel(failed)); +} + test "unwired roles require explicit session entry acknowledgement" { const no_edges = [_]GraphModel.Edge{}; try std.testing.expectEqual(NodeRole.unwired, nodeRole(&no_edges, "loose", &.{})); diff --git a/graphcode-windows/src/GraphModel.zig b/graphcode-windows/src/GraphModel.zig index 7713e9ac..3d90b12e 100644 --- a/graphcode-windows/src/GraphModel.zig +++ b/graphcode-windows/src/GraphModel.zig @@ -22,6 +22,8 @@ pub const Node = struct { stall_after_seconds: ?f64 = null, created_at: ?u64 = null, metric_passes: u32 = 0, + metric_samples: [8]f64 = [_]f64{0} ** 8, + metric_sample_count: u8 = 0, token_usage: ?u32 = null, worktree_path: []u8 = @constCast(""), worktree_branch: []u8 = &.{}, @@ -1026,6 +1028,8 @@ fn cloneNode(allocator: std.mem.Allocator, node: Node) !Node { .stall_after_seconds = node.stall_after_seconds, .created_at = node.created_at, .metric_passes = node.metric_passes, + .metric_samples = node.metric_samples, + .metric_sample_count = node.metric_sample_count, .token_usage = node.token_usage, .worktree_path = try allocator.dupe(u8, node.worktree_path), .worktree_branch = try allocator.dupe(u8, node.worktree_branch), @@ -1071,6 +1075,7 @@ fn decodeNodes( const object = bytes[start .. end + 1]; const scalar_object = try withoutJsonObjectField(allocator, object, "subGraph"); defer allocator.free(scalar_object); + const samples = jsonMetricSamples(scalar_object, "metricHistory"); try nodes.append(.{ .id = try duplicateJsonString(allocator, scalar_object, "id"), .title = try duplicateJsonStringOr(allocator, scalar_object, "title", "Untitled"), @@ -1091,6 +1096,8 @@ fn decodeNodes( .stall_after_seconds = jsonFloat(scalar_object, "stallAfterSeconds"), .created_at = jsonNumber64(scalar_object, "createdAt"), .metric_passes = jsonArrayObjectCount(scalar_object, "metricHistory"), + .metric_samples = samples.values, + .metric_sample_count = samples.count, .token_usage = jsonUsageTotal(scalar_object), .worktree_path = try duplicateWorktreePath(allocator, scalar_object), .worktree_branch = try duplicateWorktreeBranch(allocator, scalar_object), @@ -1168,6 +1175,40 @@ fn jsonArrayObjectCount(object: []const u8, key: []const u8) u32 { return count; } +const MetricSamples = struct { + values: [8]f64 = [_]f64{0} ** 8, + count: u8 = 0, +}; + +fn jsonMetricSamples(object: []const u8, key: []const u8) MetricSamples { + const needle = std.fmt.allocPrint(std.heap.page_allocator, "\"{s}\":[", .{key}) catch return .{}; + defer std.heap.page_allocator.free(needle); + const start = std.mem.indexOf(u8, object, needle) orelse return .{}; + const close = std.mem.indexOfScalarPos(u8, object, start + needle.len, ']') orelse return .{}; + const array = object[start + needle.len .. close]; + var result = MetricSamples{}; + var cursor: usize = 0; + while (cursor < array.len) { + const value_key = std.mem.indexOfPos(u8, array, cursor, "\"value\":") orelse break; + const value = std.mem.trimLeft(u8, array[value_key + "\"value\":".len ..], " "); + var end: usize = 0; + while (end < value.len and (std.ascii.isDigit(value[end]) or value[end] == '.' or value[end] == '-' or value[end] == '+' or value[end] == 'e' or value[end] == 'E')) : (end += 1) {} + if (end != 0) { + if (std.fmt.parseFloat(f64, value[0..end])) |sample| { + if (result.count == result.values.len) { + std.mem.copyForwards(f64, result.values[0 .. result.values.len - 1], result.values[1..]); + result.values[result.values.len - 1] = sample; + } else { + result.values[result.count] = sample; + result.count += 1; + } + } else |_| {} + } + cursor = value_key + "\"value\":".len + end; + } + return result; +} + fn jsonUsageTotal(object: []const u8) ?u32 { const input = jsonNumber(object, "inputTokens") orelse jsonNumber(object, "inputTokenCount") orelse 0; const output = jsonNumber(object, "outputTokens") orelse jsonNumber(object, "outputTokenCount") orelse 0; @@ -1817,6 +1858,20 @@ test "fireCount parses complete positive numeric tokens" { try std.testing.expectEqual(@as(usize, 0), model.attentionCount()); } +test "metric history samples retain recent values for sparklines" { + var model = Model.init(std.testing.allocator); + defer model.deinit(); + const frame = + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"g","project":{"path":"C:\\work\\graph","name":"Graph"},"nodes":[{"id":"a","title":"A","state":"running","metricHistory":[{"value":5},{"value":4},{"value":3},{"value":2},{"value":1},{"value":0},{"value":-1},{"value":-2},{"value":-3}]}],"edges":[]}}} + ; + _ = try model.updateFromFrame(frame); + const node = model.graph.?.nodes.items[0]; + try std.testing.expectEqual(@as(u32, 9), node.metric_passes); + try std.testing.expectEqual(@as(u8, 8), node.metric_sample_count); + try std.testing.expectEqual(@as(f64, 4), node.metric_samples[0]); + try std.testing.expectEqual(@as(f64, -3), node.metric_samples[7]); +} + test "attention cursor is independent from ordinary selection" { var model = Model.init(std.testing.allocator); defer model.deinit(); diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index f94e4dac..e556bbd0 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -69,21 +69,21 @@ Statuses: | Zoom controls | Zoom out, actual size, zoom in, fit with shortcuts/help | Visible bottom-right controls provide zoom out, percentage/actual size, zoom in, and fit. A visible shortcut/help line now accompanies the controls; Ctrl+-, Ctrl+0, Ctrl+=, and Ctrl+9 remain represented in the View menu, and the live UIA provider exposes invokable controls with bounds. Live re-capture remains blocked by the local shell toolchain | Partial | | New Loop canvas button | Visible top-right add action | A live-validated top-right New Loop button is now present on non-empty project canvases and remains centered in the empty state | Validated | | Composite breadcrumb | Current group, project back action, loop count | Open Group swaps the project canvas to the authoritative nested graph, renders its cards and edges through the normal interactive canvas, and exposes a clickable `Project > Group` breadcrumb with loop count that restores and reselects the parent. Nested graph selection survives daemon refreshes, and the populated live UIA gate invokes Open Group, verifies both nested cards, and invokes the bounded Back breadcrumb to restore the parent canvas | Validated | -| Canvas attention rail | Count/oldest context and Review action | The rail now exposes a clickable Review target and shows the oldest attention item title alongside the count. Focused hit testing passes; true age data is not present in the current daemon model and live UIA evidence remains blocked | Partial | +| Canvas attention rail | Count/oldest context and Review action | The rail exposes a clickable Review target and now uses `createdAt` from the daemon model when present to show a true `oldest ` label alongside the oldest attention item title. Focused hit testing passes; live UIA evidence remains pending | Partial | | Node positioning | Persisted positions and direct card movement where supported | Project cards can be dragged directly, with movement transformed correctly at non-default zoom, shared geometry/hit testing updated during the drag, and capture-loss cancellation restoring the prior position. Offsets are keyed to stable node identity, remapped across daemon reorder, and atomically persisted under the configured GraphCode support directory. Focused reorder/reload regressions and a real physical drag capture validate the complete flow | Validated | -| Connector handles | Hover handles and drag-to-connect | The right-edge connector now tracks hover, paints a visible handle and plus affordance, and preserves the existing drag-to-connect path. Focused rendering/input coverage passes; live evidence remains blocked | Partial | +| Connector handles | Hover handles and drag-to-connect | The right-edge connector tracks hover, paints a visible handle and plus affordance, and preserves the drag-to-connect path. Focused rendering/input coverage remains the available evidence; live hover/drag capture is still pending because the current UIA gate does not yet synthesize hover/drag pointer messages | 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 | Partial | -| Loop card live detail | Goal/prompt/check line, progress, metric change, elapsed/backend/model/worktree metadata | Cards now prioritize goal, trigger, or check detail, retain current activity, and show model/worktree or metric metadata in compact secondary lines. Focused tests and a real goal-loop fixture validate the richer card; measured progress/change, elapsed time, backend identity, and token usage remain incomplete | Partial | -| Loop card attention | Reason-aware amber presentation and primary action | Cards retain the NEEDS YOU presentation and the attention rail Review action now routes to the attention cursor. A card-level reason-specific primary button is still absent; live evidence remains blocked | Partial | +| Loop card live detail | Goal/prompt/check line, progress, metric change, elapsed/backend/model/worktree metadata | Cards prioritize goal, trigger, or check detail, retain current activity, and now add metric pass/change text, elapsed age, backend identity, token usage, model tier, and worktree/branch metadata from the same decoded daemon fields used by the workspace loop bar. Focused card metadata tests cover the compact detail string; live UIA recapture is pending | Partial | +| Loop card attention | Reason-aware amber presentation and primary action | NEEDS YOU cards now render a card-level reason-specific primary button: `Reply` for reported awaiting-input sessions and `Inspect` for other attention reasons, both routed through the normal loop-opening path. Focused action-label tests and a live-gate assertion for the deterministic awaiting-input card were added; status remains Partial until that gate passes in CI | 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 | Validated | | Worktree reclaim offer | Reclaim and Keep actions on resolved card | Safe resolved cards with a matching landed, clean, pushed worktree expose separate Reclaim and Keep targets. Reclaim revalidates safety and Keep suppresses the offer for the session. Canvas Reclaim/Keep descendants are now emitted through the UIA provider and invoke the same fail-closed paths. Focused geometry/safety coverage passes, and `Tools\windows\uia-live-gate.ps1` now asserts the live Reclaim/Keep descendants under the Graph fragment (name, non-empty bounds, InvokePattern, and correct RawView/ControlView sibling linkage) via the `windows-shell` CI job (PR #385, run 35422364203, passing) | Validated | | 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` | Validated | -| Edge presentation | Kind style, fired state, cycle label | Project edges retain kind-specific styling, condition/fired labels, and selected emphasis; dense labels now shift vertically to avoid overlap with earlier labels. Full cycle-guard wording is still limited by the current edge model, and live evidence remains blocked | Partial | +| Edge presentation | Kind style, fired state, cycle label | Project edges retain kind-specific styling, condition/fired labels, and selected emphasis; fired repeat labels now use the macOS-style `retry ×N` copy when `fireCount` is present. Full `loop fireCount/cycleGuard.summary` wording remains blocked because the Windows edge model still does not decode `cycleGuard`; live edge evidence is pending | Partial | | Edge creation sheet | Kind/condition/transform/cycle controls with conditional validation | A guided native form provides endpoint selectors, kind/condition/transform controls, conditional fields, cycle guards, inline validation, keyboard traversal, and scrolling. Focused form coverage remains green; teaching polish/macOS visual treatment and live recapture remain incomplete | Partial | | Node creation sheet | Loop-type teaching tiles, conditional fields, backend/model/branch pickers, recap, validation reason | A guided native form provides loop-type/backend/model choices, type-specific fields, explanatory copy, accessible checkboxes, inline validation, keyboard traversal, and scrolling while preserving hidden wire values. Focused tests remain green; teaching tiles, branch picker, recap, macOS visual treatment, and live recapture remain incomplete | Partial | -| Node update/rename | Dedicated rename prompt and safe typed updates | Rename retains its dedicated safe prompt. The canvas context menu now also exposes Edit Details..., backed by the typed `NativeForms.update` editor and authoritative `sendUpdateNodeForm` path for goal, predicate, polling/stall, metric, trigger/check, and model fields. Focused form/wire coverage passes; live editor evidence remains blocked | Partial | +| Node update/rename | Dedicated rename prompt and safe typed updates | Rename retains its dedicated safe prompt. The canvas context menu exposes Edit Details..., backed by the typed `NativeForms.update` editor and authoritative `sendUpdateNodeForm` path for goal, predicate, polling/stall, metric, trigger/check, and model fields. Focused form/wire coverage remains the available evidence; live editor invocation evidence is still pending | Partial | | Delete confirmations | Named object, consequences, safe default | Loop deletion names the loop and explains graph-connection removal. Edge deletion now names both endpoint loops and the connection kind, explains that the loops remain, re-resolves the stable edge after confirmation, and defaults to cancellation | Validated | -| Canvas context menu | Folder actions on background; complete node/edge actions | Background retains Create Edge; node menus expose composite Open Group/Pilot/Arm actions plus Edit Details and no longer show the non-macOS Message/Memo actions. Focused menu coverage passes; live menu/UIA evidence remains blocked | Partial | +| Canvas context menu | Folder actions on background; complete node/edge actions | Background retains Create Edge; node menus expose composite Open Group/Pilot/Arm actions plus Edit Details and no longer show the non-macOS Message/Memo actions. Focused menu coverage remains the available evidence; live context-menu/UIA evidence is still pending because the current live gate does not synthesize a right-click menu walkthrough | Partial | ## Quick Chats @@ -106,8 +106,8 @@ Statuses: | Tab pills | Named tabs, selection, state indicator, shortcuts, per-tab close | The native tab strip paints agent/shell/split labels, live state indicators, Ctrl+1-style shortcut hints, and per-tab close affordances. Close routing removes only the selected tab topology and refuses the final tab. The live gate's `workspace-tab-*` assertion now runs against the real shell build and passes on `windows-shell` (run https://github.com/scgopi/GraphCode/actions/runs/35415967793) | Validated | | Split controls | Visible Split Right, Split Down, New Tab buttons | The terminal tab bar renders distinct New Tab, Split Right, and Split Down controls with shared geometry helpers used by painting and hit testing, plus UIA children and focused gap-boundary regression coverage. The live gate's split-control assertion (`workspace-(new-tab\|split-right\|split-down)-*`, all three present) now runs against the real shell build and passes on `windows-shell` (run https://github.com/scgopi/GraphCode/actions/runs/35415967793) | Validated | | Pane headers | agent/shell identity, backend/shell detail, focused state | Product-owned pane headers distinguish agent and shell panes, label the zmx session detail, add truthful backend: agent/backend: shell detail, and retain the explicit focused-pane accent. Focused rendering unit coverage plus the live `windows-shell` CI run (real workspace/terminal panes, run https://github.com/scgopi/GraphCode/actions/runs/35415967793) provide the side-by-side live evidence that was previously blocked | Validated | -| Mounted background tabs | Switching preserves live terminal surfaces | Covered by workspace implementation tests | Partial | -| Right loop panel | Minimap, upstream/downstream, fired conditions, metric sparkline, branch/start/usage footer | The full workspace still reserves the graph-canvas-owned right rail with a selected-loop map, upstream/downstream cards, fired-edge coloring, edge conditions, branch/worktree identity, metric/goal detail, and model tier. Metric sparkline, start time, token usage, collapse control, and dedicated UIA children remain incomplete; this session was consumed end-to-end by stabilizing the live UIA gate (see below) and did not reach this row's remaining implementation work, so it stays deferred/outstanding rather than falsely claimed | Partial | +| Mounted background tabs | Switching preserves live terminal surfaces | Workspace implementation tests still cover the topology, and the live UIA gate now creates a second mounted tab, switches between the original and background tab, and asserts both tab automation identities survive the round trip without shell exit/reconnection | Partial | +| Right loop panel | Minimap, upstream/downstream, fired conditions, metric sparkline, branch/start/usage footer | The full workspace right rail now includes the selected-loop map, upstream/downstream cards, fired-edge coloring, edge conditions, branch/worktree identity, metric/goal detail, model tier, a metric-history sparkline from decoded samples, start-time/usage/backend footer text, a collapse/expand control that no longer reserves rail width while hidden, and dedicated UIA children for sparkline/start/usage/toggle. The live UIA gate asserts those children and toggles collapse/expand; status remains Partial until the updated gate passes in CI | Partial | | Show in Graph | Visible loop-bar and menu action | The restored Loop menu and native loop bar expose Show in Graph; the workspace UIA tree exposes a stable invokable Show in Graph child, and focused hit testing covers the visible action. The live gate's `workspace-show-graph-*` assertion and the return-to-graph-card walkthrough now run against the real shell build and pass on `windows-shell` (run https://github.com/scgopi/GraphCode/actions/runs/35415967793) | Validated | **Live-gate infrastructure fix (this session):** the `windows-shell` CI job's `uia-live-gate.ps1` step was, until now, never actually exercising any of the workspace chrome above: `App.init()` unconditionally skipped `Workspace.init()` under `GRAPHCODE_UIA_GATE=1` regardless of whether a real `zmx` executable was supplied (a pre-existing guard predating this workstream), so every "Partial" row above had never been run against a real workspace at all. Fixed in `App.zig` to build the real workspace under the gate whenever `GRAPHCODE_ZMX` is present. That surfaced a second, genuine regression: the newly-real terminal surface competed for native Win32 keyboard focus with the rest of the UI after navigating away from the workspace (`App.openGlobalOverview()` and friends). Root-caused to `Workspace.poll()` (driven by the main window's 100ms `WM_TIMER`) unconditionally draining terminal output and calling `winghostty_surface_notify_accessibility_text()` regardless of workspace visibility, which kept re-asserting UI Automation focus on the terminal no matter what Win32-level focus fixes were made. Fixed by adding `Workspace.collapse()`/`Workspace.collapsed`, skipping `resize()`/`syncTopology()`'s pane refocus and terminal-output polling entirely while the workspace is hidden, plus a `WM_ACTIVATE` handler that reasserts the app's own focus policy after `DefWindowProc`'s default child-focus restoration on window reactivation. All of this is now covered by the passing `windows-shell` CI job (commits `a31813b`..`cba010f`, run https://github.com/scgopi/GraphCode/actions/runs/35415967793). Note: the separate `windows-spikes`/`windows-hardening` jobs (`validate.ps1 -Task all`) run the identical gate script but under much heavier CI load and still intermittently hit this same assertion's 15-second retry window; this has been confirmed as pre-existing, cross-branch flakiness unrelated to this workstream (an unrelated sibling branch, `coneilen-microsoft-repository-settings-parity`, shows both a pass and an unrelated failure on the same job across consecutive runs), not a regression introduced here. From 4bbfd0ed3200c18bd37ecc4b753cdd5b4f7a0a07 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 21 Sep 2026 10:28:05 -0700 Subject: [PATCH 2/4] Preserve fired edge wording without cycle guard Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- graphcode-windows/src/GraphCanvas.zig | 2 +- investigation/ui-parity-matrix.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/graphcode-windows/src/GraphCanvas.zig b/graphcode-windows/src/GraphCanvas.zig index 94220101..e10ba1db 100644 --- a/graphcode-windows/src/GraphCanvas.zig +++ b/graphcode-windows/src/GraphCanvas.zig @@ -998,7 +998,7 @@ fn edgeKindColor(kind: []const u8) u32 { fn edgeLabel(buffer: []u8, edge: GraphModel.Edge) []const u8 { const kind = if (edge.kind.len == 0) "handoff" else edge.kind; if (edge.fire_count != 0) - return std.fmt.bufPrint(buffer, "{s} · {s} · retry ×{d}", .{ kind, edge.condition, edge.fire_count }) catch kind; + return std.fmt.bufPrint(buffer, "{s} · {s} · fired {d}", .{ kind, edge.condition, edge.fire_count }) catch kind; if (!std.mem.eql(u8, edge.condition, "always")) return std.fmt.bufPrint(buffer, "{s} · {s}", .{ kind, edge.condition }) catch kind; return kind; diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index e556bbd0..7c73a6b5 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -78,7 +78,7 @@ Statuses: | 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 | Validated | | Worktree reclaim offer | Reclaim and Keep actions on resolved card | Safe resolved cards with a matching landed, clean, pushed worktree expose separate Reclaim and Keep targets. Reclaim revalidates safety and Keep suppresses the offer for the session. Canvas Reclaim/Keep descendants are now emitted through the UIA provider and invoke the same fail-closed paths. Focused geometry/safety coverage passes, and `Tools\windows\uia-live-gate.ps1` now asserts the live Reclaim/Keep descendants under the Graph fragment (name, non-empty bounds, InvokePattern, and correct RawView/ControlView sibling linkage) via the `windows-shell` CI job (PR #385, run 35422364203, passing) | Validated | | 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` | Validated | -| Edge presentation | Kind style, fired state, cycle label | Project edges retain kind-specific styling, condition/fired labels, and selected emphasis; fired repeat labels now use the macOS-style `retry ×N` copy when `fireCount` is present. Full `loop fireCount/cycleGuard.summary` wording remains blocked because the Windows edge model still does not decode `cycleGuard`; live edge evidence is pending | Partial | +| Edge presentation | Kind style, fired state, cycle label | Project edges retain kind-specific styling, condition/fired labels, and selected emphasis. macOS only uses `retry ×N` when an edge has a `cycleGuard`; the Windows edge model still does not decode `cycleGuard`, so ordinary fired edges retain the accurate `fired N` wording and guarded retry/cycle-summary wording remains blocked on that protocol/model field. Live edge evidence is pending | Partial | | Edge creation sheet | Kind/condition/transform/cycle controls with conditional validation | A guided native form provides endpoint selectors, kind/condition/transform controls, conditional fields, cycle guards, inline validation, keyboard traversal, and scrolling. Focused form coverage remains green; teaching polish/macOS visual treatment and live recapture remain incomplete | Partial | | Node creation sheet | Loop-type teaching tiles, conditional fields, backend/model/branch pickers, recap, validation reason | A guided native form provides loop-type/backend/model choices, type-specific fields, explanatory copy, accessible checkboxes, inline validation, keyboard traversal, and scrolling while preserving hidden wire values. Focused tests remain green; teaching tiles, branch picker, recap, macOS visual treatment, and live recapture remain incomplete | Partial | | Node update/rename | Dedicated rename prompt and safe typed updates | Rename retains its dedicated safe prompt. The canvas context menu exposes Edit Details..., backed by the typed `NativeForms.update` editor and authoritative `sendUpdateNodeForm` path for goal, predicate, polling/stall, metric, trigger/check, and model fields. Focused form/wire coverage remains the available evidence; live editor invocation evidence is still pending | Partial | From b3adffe529277a1a4e95ac622eaae75ff76f3f1d Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 21 Sep 2026 10:44:36 -0700 Subject: [PATCH 3/4] Wait for workspace detail UIA children Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Tools/windows/uia-live-gate.ps1 | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 98a01da4..bbf38531 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -982,6 +982,10 @@ try { $workspaceShowGraph = $null $workspaceTabs = @() $workspaceControls = @() + $workspacePanelToggle = $null + $workspaceSparkline = $null + $workspaceStart = $null + $workspaceUsage = $null for ($attempt = 0; $attempt -lt 100; $attempt++) { $workspaceChildren = @(Get-DirectChildren $graph $rawWalker) $workspaceToolbar = @($workspaceChildren | Where-Object { @@ -997,8 +1001,22 @@ try { $_.Current.AutomationId -match '^workspace-(new-tab|split-right|split-down)-' -and $_.Current.Name -in @("New Tab", "Split Right", "Split Down") }) + $workspacePanelToggle = @($workspaceChildren | Where-Object { + $_.Current.AutomationId -match '^workspace-toggle-panel-' -and $_.Current.Name -eq "Collapse loop panel" + }) | Select-Object -First 1 + $workspaceSparkline = @($workspaceChildren | Where-Object { + $_.Current.AutomationId -match '^workspace-detail-sparkline-' -and $_.Current.Name -eq "Metric sparkline" + }) | Select-Object -First 1 + $workspaceStart = @($workspaceChildren | Where-Object { + $_.Current.AutomationId -match '^workspace-detail-start-' -and $_.Current.Name -eq "Start time" + }) | Select-Object -First 1 + $workspaceUsage = @($workspaceChildren | Where-Object { + $_.Current.AutomationId -match '^workspace-detail-usage-' -and $_.Current.Name -match 'tokens$' + }) | Select-Object -First 1 if (($null -ne $workspaceToolbar) -and ($null -ne $workspaceShowGraph) -and - ($workspaceTabs.Count -ge 1) -and ($workspaceControls.Count -eq 3)) { + ($workspaceTabs.Count -ge 1) -and ($workspaceControls.Count -eq 3) -and + ($null -ne $workspacePanelToggle) -and ($null -ne $workspaceSparkline) -and + ($null -ne $workspaceStart) -and ($null -ne $workspaceUsage)) { break } Start-Sleep -Milliseconds 100 @@ -1011,18 +1029,6 @@ try { "workspace chrome omitted a split control (found $($workspaceControls.Count) of 3: $(@($workspaceControls | ForEach-Object { $_.Current.Name }) -join '|'))" Require ($workspaceTabs.Count -ge 1) ` "workspace chrome exposed no tab children; found $(@($workspaceChildren | ForEach-Object { $_.Current.AutomationId }) -join '|')" - $workspacePanelToggle = @($workspaceChildren | Where-Object { - $_.Current.AutomationId -match '^workspace-toggle-panel-' -and $_.Current.Name -eq "Collapse loop panel" - }) | Select-Object -First 1 - $workspaceSparkline = @($workspaceChildren | Where-Object { - $_.Current.AutomationId -match '^workspace-detail-sparkline-' -and $_.Current.Name -eq "Metric sparkline" - }) | Select-Object -First 1 - $workspaceStart = @($workspaceChildren | Where-Object { - $_.Current.AutomationId -match '^workspace-detail-start-' -and $_.Current.Name -eq "Start time" - }) | Select-Object -First 1 - $workspaceUsage = @($workspaceChildren | Where-Object { - $_.Current.AutomationId -match '^workspace-detail-usage-' -and $_.Current.Name -match 'tokens$' - }) | Select-Object -First 1 Require ($null -ne $workspacePanelToggle) "workspace right panel omitted collapse control" Require ($null -ne $workspaceSparkline) "workspace right panel omitted metric sparkline child" Require ($null -ne $workspaceStart) "workspace right panel omitted start-time child" From 4ef6c6cd03ba06d51fffe4bb4502cf94a65b5df3 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 21 Sep 2026 10:58:54 -0700 Subject: [PATCH 4/4] Expose workspace detail automation ids Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Tools/windows/uia-live-gate.ps1 | 4 +++- graphcode-windows/src/AccessibilityProvider.cpp | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index bbf38531..4f74a2f5 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -823,7 +823,9 @@ try { $null = $card.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern) } $projectCardIds = @($projectCards | ForEach-Object { $_.Current.AutomationId }) - $attentionAction = @(Get-DirectChildren $graph $rawWalker | Where-Object { $_.Current.Name -eq "Reply" }) | Select-Object -First 1 + $attentionAction = @(Get-DirectChildren $graph $rawWalker | Where-Object { + $_.Current.AutomationId -match '^attention-action-' -and $_.Current.Name -eq "Reply" + }) | Select-Object -First 1 Require ($null -ne $attentionAction) "NEEDS YOU card omitted its reason-specific Reply action" Require (($attentionAction.Current.BoundingRectangle.Width -gt 0) -and ($attentionAction.Current.BoundingRectangle.Height -gt 0)) "Reply attention action had empty bounds" diff --git a/graphcode-windows/src/AccessibilityProvider.cpp b/graphcode-windows/src/AccessibilityProvider.cpp index 55b38ed5..64afd838 100644 --- a/graphcode-windows/src/AccessibilityProvider.cpp +++ b/graphcode-windows/src/AccessibilityProvider.cpp @@ -798,10 +798,15 @@ class Node final : public IRawElementProviderSimple, row.identity.rfind("quick-chat-row:", 0) == 0 ? L"quick-chat-row-" : 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("workspace-toolbar:", 0) == 0 ? L"workspace-toolbar-" : row.identity.rfind("workspace-loop-bar:", 0) == 0 ? L"workspace-loop-bar-" : row.identity.rfind("workspace-show-graph:", 0) == 0 ? L"workspace-show-graph-" : row.identity.rfind("workspace-stop:", 0) == 0 ? L"workspace-stop-" : + row.identity.rfind("workspace-toggle-panel:", 0) == 0 ? L"workspace-toggle-panel-" : + row.identity.rfind("workspace-detail-sparkline:", 0) == 0 ? L"workspace-detail-sparkline-" : + row.identity.rfind("workspace-detail-start:", 0) == 0 ? L"workspace-detail-start-" : + row.identity.rfind("workspace-detail-usage:", 0) == 0 ? L"workspace-detail-usage-" : row.identity.rfind("workspace-tab-close:", 0) == 0 ? L"workspace-tab-close-" : row.identity.rfind("workspace-tab:", 0) == 0 ? L"workspace-tab-" : row.identity.rfind("workspace-new-tab:", 0) == 0 ? L"workspace-new-tab-" :