diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 0f8a8293..981521a0 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -560,7 +560,24 @@ try { $null = $card.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern) } $projectCardIds = @($projectCards | ForEach-Object { $_.Current.AutomationId }) - $graphChildIds = @($projectCardIds + @($connectionAlert.Current.AutomationId) + $canvasActionIds) + $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" + Require ($null -ne $keepOffer) "resolved card with a reclaimable worktree omitted its Keep descendant" + Require (($reclaimOffer.Current.BoundingRectangle.Width -gt 0) -and + ($reclaimOffer.Current.BoundingRectangle.Height -gt 0)) "Reclaim descendant had empty bounds" + Require (($keepOffer.Current.BoundingRectangle.Width -gt 0) -and + ($keepOffer.Current.BoundingRectangle.Height -gt 0)) "Keep descendant had empty bounds" + $null = $reclaimOffer.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern) + $null = $keepOffer.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern) + # The Reclaim/Keep descendants for a resolved card are siblings placed immediately + # after that card, so derive the expected order from the live tree (as with Loops + # 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) + 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" $null = Assert-FragmentLinks $graph $controlWalker $graphChildIds "ControlView Graph" $projectCards[1].GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 1660bd16..21c156b1 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -150,6 +150,10 @@ const UiaDynamicTarget = union(enum) { }, active_loop: usize, composite_back, + reclaim_offer: struct { + path: []const u8, + action: GraphCanvas.ReclaimAction, + }, quick_chat: []const u8, }; @@ -1027,6 +1031,35 @@ pub const App = struct { self.client.sendRenameNode(project_path, updated_graph.nodes.items[updated_index].id, title_value); } + fn editSelectedNodeDetails(self: *App) void { + const graph = self.model.graph orelse return; + const index = self.model.selectedIndex() orelse return; + if (index >= graph.nodes.items.len) return; + const node = graph.nodes.items[index]; + var update = NativeForms.update(self.window.hwnd, self.allocator, .{ + .goal_summary = if (node.goal_summary.len == 0) null else node.goal_summary, + .goal_predicate = if (node.goal_predicate.len == 0) null else node.goal_predicate, + .poll_interval_seconds = node.poll_interval_seconds, + .stall_after_seconds = node.stall_after_seconds, + .metric_command = if (node.metric_command.len == 0) null else node.metric_command, + .metric_direction = if (node.metric_direction.len == 0) null else node.metric_direction, + .trigger_prompt = if (node.trigger_prompt.len == 0) null else node.trigger_prompt, + .check_description = if (node.check_description.len == 0) null else node.check_description, + .model_tier = if (node.model_tier.len == 0) null else node.model_tier, + }) catch { + self.setStatus("Unable to open node details form"); + return; + } orelse return; + defer update.deinit(self.allocator); + const current_graph = self.model.graph orelse return; + if (!std.mem.eql(u8, current_graph.project.path, graph.project.path)) return; + const current_index = GraphModel.findNodeIndexByID(current_graph.nodes.items, node.id) orelse { + self.setStatus("Loop changed while editing details"); + return; + }; + self.client.sendUpdateNodeForm(current_graph.project.path, current_graph.nodes.items[current_index].id, update); + } + fn createEdge(self: *App) void { const graph = self.model.graph orelse return; if (graph.nodes.items.len < 2) return; @@ -1775,6 +1808,7 @@ pub const App = struct { const index = GraphModel.findNodeIndexByID(graph.nodes.items, stable.id) orelse return; if (!self.selectNodeIndex(index)) return; switch (action) { + .edit_node => self.editSelectedNodeDetails(), .rename_node => self.editSelectedNode(), .stop_node => self.stopSelectedNode(), .delete_node => self.deleteSelectedNode(), @@ -3184,6 +3218,11 @@ pub const App = struct { const key = std.fmt.allocPrint(self.allocator, "{s}:{s}", .{ graph.project.path, node.id }) catch return; defer self.allocator.free(key); self.appendAccessibilityElement(&elements, &owned_identities, "project-card", key, node.title, 4, bounds, self.model.selected_index == index, 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; + self.appendAccessibilityElement(&elements, &owned_identities, "keep", key, "Keep", 4, offer.keep, false, false) catch return; + } } }, .overview => for (self.model.graphs.items, 0..) |graph, graph_index| { @@ -3315,6 +3354,10 @@ 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 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; + defer self.allocator.free(keep_identity); const loop_disclosure_identity = std.fmt.allocPrint(self.allocator, "loop-disclosure:{s}", .{key}) catch return false; defer self.allocator.free(loop_disclosure_identity); if (Accessibility.worktreeIdentityPayload(sidebar_identity) == payload or @@ -3324,6 +3367,14 @@ pub const App = struct { if (target != null) return false; target = .{ .loop = .{ .project_path = graph.project.path, .index = index } }; } + if (Accessibility.worktreeIdentityPayload(reclaim_identity) == payload) { + if (target != null) return false; + target = .{ .reclaim_offer = .{ .path = node.worktree_path, .action = .reclaim } }; + } + if (Accessibility.worktreeIdentityPayload(keep_identity) == payload) { + if (target != null) return false; + target = .{ .reclaim_offer = .{ .path = node.worktree_path, .action = .keep } }; + } if (Accessibility.worktreeIdentityPayload(loop_disclosure_identity) == payload) { if (target != null) return false; target = .{ .loop_disclosure = .{ .project_path = graph.project.path, .index = index } }; @@ -3440,6 +3491,10 @@ pub const App = struct { self.openSelectedNode(); }, .composite_back => self.closeCompositeGroup(), + .reclaim_offer => |offer| switch (offer.action) { + .reclaim => self.reclaimWorktreeOffer(offer.path), + .keep => self.keepWorktreeOffer(offer.path), + }, .quick_chat => |id| { self.client.sendOpenQuickChat(id); self.setStatus("Opening quick chat..."); @@ -3995,6 +4050,12 @@ fn onWindowMessage( result.* = 0; return true; } + if (app.model.attentionCount() != 0 and GraphCanvas.hitTestAttentionRail(x, y, client.right)) { + app.handleAction(.cycle_attention); + _ = c.InvalidateRect(hwnd, null, 0); + result.* = 0; + return true; + } const routing = inputBounds(client.right, client.bottom, app.workspace_controls); const workspace_top = if (app.surface == .workspace and app.workspace_controls.panel_visible) Tokens.header_height @@ -4066,7 +4127,25 @@ fn onWindowMessage( } switch (app.surface) { .overview => { - if (GraphCanvas.hitTestOverview(&app.model, x, y, &app.canvas, bounds)) |hit| { + var lane_action: ?GraphCanvas.OverviewLaneAction = null; + for (app.model.graphs.items, 0..) |_, graph_index| { + if (GraphCanvas.overviewLaneActionAt(&app.model, graph_index, x, y, bounds, &app.canvas)) |action| { + lane_action = action; + if (action == .inspect_worktrees) { + if (app.selectProject(app.model.graphs.items[graph_index].project.path)) app.inspectWorktrees(); + } else if (app.selectProject(app.model.graphs.items[graph_index].project.path)) { + app.surface = .project; + app.workspace_controls.panel_visible = false; + app.layoutWorkspace(); + app.layoutEmptyStateControls(); + app.rebindWorkspace(app.model.graphs.items[graph_index].project.path); + } + break; + } + } + if (lane_action != null) { + _ = c.InvalidateRect(hwnd, null, 0); + } else if (GraphCanvas.hitTestOverview(&app.model, x, y, &app.canvas, bounds)) |hit| { const graph = app.model.graphs.items[hit.graph_index]; if (app.selectProject(graph.project.path)) { app.surface = .workspace; @@ -4444,6 +4523,7 @@ fn onWindowMessage( }, c.WM_MOUSEMOVE => { const hover_y = mouseY(lparam); + const hover_x = mouseX(lparam); const next_hover = if (mouseX(lparam) >= 0 and mouseX(lparam) < Tokens.sidebar_width) hover_y else -1; if (next_hover != app.sidebar_hover_y) { app.sidebar_hover_y = next_hover; @@ -4466,6 +4546,22 @@ fn onWindowMessage( result.* = 0; return true; } + if (app.model.graph) |graph| { + var client: c.RECT = undefined; + _ = c.GetClientRect(hwnd, &client); + const canvas_render_bounds = inputBounds(client.right, client.bottom, app.workspace_controls).canvas; + const canvas_bounds = c.RECT{ + .left = canvas_render_bounds.left, + .top = canvas_render_bounds.top, + .right = canvas_render_bounds.right, + .bottom = canvas_render_bounds.bottom, + }; + const next_connector = GraphCanvas.hitTestConnector(graph.nodes.items, hover_x, hover_y, &app.canvas, canvas_bounds); + if (next_connector != app.canvas.hovered_connector) { + app.canvas.hovered_connector = next_connector; + _ = c.InvalidateRect(hwnd, null, 0); + } + } }, c.WM_MOUSEWHEEL => { const wheel = CanvasInput.decodeWheelMessage(lparam, wparam); diff --git a/graphcode-windows/src/GraphCanvas.zig b/graphcode-windows/src/GraphCanvas.zig index ecefbaf2..ac9823be 100644 --- a/graphcode-windows/src/GraphCanvas.zig +++ b/graphcode-windows/src/GraphCanvas.zig @@ -33,6 +33,7 @@ pub const CanvasState = struct { node_drag_x: i32 = 0, node_drag_y: i32 = 0, node_drag_origin: NodeOffset = .{}, + hovered_connector: ?usize = null, pub fn beginPan(self: *CanvasState, x: i32, y: i32) void { self.dragging = true; @@ -220,7 +221,11 @@ pub const CanvasState = struct { } pub fn zoomAt(self: *CanvasState, x: i32, y: i32, wheel_delta: i16) void { - const factor: f32 = if (wheel_delta > 0) 1.1 else 0.9; + const steps = @max(1, @abs(@as(i32, wheel_delta)) / 120); + const factor: f32 = if (wheel_delta > 0) + std.math.pow(f32, 1.1, @floatFromInt(steps)) + else + std.math.pow(f32, 0.9, @floatFromInt(steps)); self.zoomBy(x, y, factor); } @@ -271,11 +276,20 @@ pub const CardTextLayout = struct { pub const RenderBounds = struct { left: i32, top: i32, right: i32, bottom: i32 }; pub const Surface = enum { project, overview, quick_chats, workspace }; 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 ReclaimAction = enum { reclaim, keep }; pub const ReclaimHit = struct { node_index: usize, action: ReclaimAction }; +pub fn attentionRailBounds(width: i32) c.RECT { + return rect(Tokens.sidebar_width + 20, Tokens.header_height + 12, width - 20, Tokens.header_height + 43); +} + +pub fn hitTestAttentionRail(x: i32, y: i32, width: i32) bool { + return insideGraph(x, y, attentionRailBounds(width)); +} + 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; @@ -460,11 +474,17 @@ fn drawOverview( 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), stateColor(graph.nodes.items[index].state, 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); } @@ -535,6 +555,16 @@ pub fn overviewCardBounds( ); } +pub fn overviewLaneActionAt(model: *const GraphModel.Model, graph_index: usize, x: i32, y: i32, bounds: c.RECT, state: *const CanvasState) ?OverviewLaneAction { + if (graph_index >= model.graphs.items.len) return null; + const lane = overviewLaneBounds(model, graph_index, bounds, state); + 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)); + if (insideGraph(x, y, open)) return .open_project; + if (insideGraph(x, y, worktrees)) return .inspect_worktrees; + return null; +} + pub fn quickChatCardBounds(index: usize, bounds: c.RECT, state: *const CanvasState) c.RECT { const column = @as(i32, @intCast(index % 3)); const row = @as(i32, @intCast(index / 3)); @@ -562,6 +592,15 @@ fn zoomButtonBounds(bounds: c.RECT, index: i32) c.RECT { fn drawZoomControls(hdc: c.HDC, allocator: std.mem.Allocator, bounds: c.RECT, state: *const CanvasState) void { roundedCard(hdc, zoomControlsBounds(bounds), 0x0026262A, false); + drawTextRect( + hdc, + allocator, + "Zoom Ctrl+- Ctrl+0 Ctrl+= Ctrl+9", + rect(bounds.right - 292, bounds.bottom - 68, bounds.right - 12, bounds.bottom - 50), + 9, + 0x008A8A8A, + c.DT_RIGHT | c.DT_SINGLELINE | c.DT_VCENTER, + ); const labels = [_][]const u8{ "-", "", "+", "Fit" }; for (labels, 0..) |label, index| { const button = zoomButtonBounds(bounds, @intCast(index)); @@ -784,9 +823,12 @@ fn attentionRail( "1 loop needs you" else std.fmt.bufPrint(&count, "{d} loops need you", .{model.attentionCount()}) catch "loops need you"; - fill(hdc, rect(Tokens.sidebar_width + 20, Tokens.header_height + 12, width - 20, Tokens.header_height + 43), 0x002D2418); + const rail = attentionRailBounds(width); + fill(hdc, rail, 0x002D2418); drawText(hdc, allocator, label, Tokens.sidebar_width + 34, Tokens.header_height + 21, 12, 0x00FFCD7A); - drawText(hdc, allocator, "Ctrl+Tab review", Tokens.sidebar_width + 210, Tokens.header_height + 21, 11, 0x00B8B8B8); + const oldest = if (model.attention_entries.items.len != 0) model.attention_entries.items[0].node.title 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 activityStrip( @@ -866,6 +908,8 @@ fn drawEdges(hdc: c.HDC, graph: GraphModel.Graph, state: *const CanvasState) voi } fn drawEdgeLabels(hdc: c.HDC, allocator: std.mem.Allocator, graph: GraphModel.Graph, state: *const CanvasState) void { + var placed: [128]c.RECT = undefined; + var placed_count: usize = 0; for (graph.edges.items, 0..) |edge, index| { const from = connectorPosition(graph.nodes.items, edge.from, true, state) orelse continue; const to = connectorPosition(graph.nodes.items, edge.to, false, state) orelse continue; @@ -882,16 +926,34 @@ fn drawEdgeLabels(hdc: c.HDC, allocator: std.mem.Allocator, graph: GraphModel.Gr var label_buffer: [128]u8 = undefined; const label = edgeLabel(&label_buffer, edge); const center_x = @divTrunc(from.x + to.x, 2); - const label_y = if (@abs(to.y - from.y) < 40) + var label_y = if (@abs(to.y - from.y) < 40) @min(from.y, to.y) - 76 else @divTrunc(from.y + to.y, 2) - 10; - const bounds = rect(center_x - 74, label_y, center_x + 74, label_y + 20); + var bounds = rect(center_x - 74, label_y, center_x + 74, label_y + 20); + var attempts: usize = 0; + while (attempts < 12 and overlapsPlaced(bounds, placed[0..placed_count])) : (attempts += 1) { + label_y += 24; + bounds = rect(center_x - 74, label_y, center_x + 74, label_y + 20); + } + if (placed_count < placed.len) { + placed[placed_count] = bounds; + placed_count += 1; + } fill(hdc, bounds, Tokens.canvas_tone); drawTextRect(hdc, allocator, label, bounds, 10, color, c.DT_CENTER | c.DT_SINGLELINE | c.DT_END_ELLIPSIS); } } +fn overlapsPlaced(candidate: c.RECT, placed: []const c.RECT) bool { + for (placed) |other| { + if (candidate.left < other.right and candidate.right > other.left and + candidate.top < other.bottom and candidate.bottom > other.top) + return true; + } + return false; +} + fn edgeKindPenStyle(kind: []const u8) c_int { if (std.mem.eql(u8, kind, "message")) return c.PS_DOT; if (std.mem.eql(u8, kind, "spawn")) return c.PS_DASH; @@ -971,7 +1033,7 @@ fn drawNode( const attention = needsAttention(node, nodes, edges); const selected_card = selected == index; roundedCard(hdc, bounds, if (selected_card) 0x00345D8C else 0x00262626, selected_card); - const stripe = stateColor(node.state, attention); + const stripe = loopTypeColor(node.loop_type); fill(hdc, rect(x, y, x + scaled(Tokens.loop_card_stripe, state), y + bounds.bottom - y), stripe); const role = nodeRole(edges, node.id, declared_entries); const reclaim_offer = hasReclaimOffer(node, inspection, kept_worktrees); @@ -994,6 +1056,11 @@ fn drawNode( 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 (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); @@ -1004,14 +1071,14 @@ fn drawNode( const ReclaimOfferBounds = struct { reclaim: c.RECT, keep: c.RECT }; -fn reclaimOfferBounds(bounds: c.RECT) ReclaimOfferBounds { +pub fn reclaimOfferBounds(bounds: c.RECT) ReclaimOfferBounds { return .{ .reclaim = rect(bounds.left + 12, bounds.bottom - 24, bounds.left + 78, bounds.bottom - 5), .keep = rect(bounds.left + 84, bounds.bottom - 24, bounds.left + 126, bounds.bottom - 5), }; } -fn hasReclaimOffer( +pub fn hasReclaimOffer( node: GraphModel.Node, inspection: ?*const WorktreeStatus.Inspection, kept_worktrees: []const []const u8, @@ -1084,6 +1151,14 @@ fn stateColor(state: []const u8, attention: bool) u32 { return 0x00909090; } +fn loopTypeColor(loop_type: []const u8) u32 { + if (std.mem.eql(u8, loop_type, "composite") or std.mem.eql(u8, loop_type, "proactive")) return 0x00C77DFF; + if (std.mem.eql(u8, loop_type, "goalBased")) return 0x000A84FF; + if (std.mem.eql(u8, loop_type, "turnBased")) return 0x00D6A649; + if (std.mem.eql(u8, loop_type, "message")) return 0x006BD58D; + return 0x00909090; +} + fn needsAttention(node: GraphModel.Node, nodes: []const GraphModel.Node, edges: []const GraphModel.Edge) bool { if (std.mem.eql(u8, node.state, "failed") or std.mem.eql(u8, node.state, "stalled")) return true; if (std.mem.eql(u8, node.state, "running") and std.mem.eql(u8, node.presence, "awaitingInput")) return true; @@ -1454,6 +1529,23 @@ test "canvas zoom keeps the graph point beneath the cursor stable" { try std.testing.expectApproxEqAbs(@as(f32, -30), state.pan_y, 0.01); } +test "canvas wheel zoom scales high-resolution trackpad deltas" { + var state = CanvasState{}; + state.zoomAt(200, 120, 240); + try std.testing.expectApproxEqAbs(@as(f32, 1.21), state.zoom, 0.01); +} + +test "loop card stripe follows loop type rather than lifecycle state" { + try std.testing.expect(loopTypeColor("goalBased") != stateColor("failed", false)); + try std.testing.expectEqual(@as(u32, 0x00C77DFF), loopTypeColor("composite")); +} + +test "dense edge labels move away from already placed labels" { + const first = rect(100, 100, 248, 120); + try std.testing.expect(overlapsPlaced(rect(120, 110, 200, 118), &.{first})); + try std.testing.expect(!overlapsPlaced(rect(260, 110, 340, 118), &.{first})); +} + test "canvas hit testing follows pan and zoom" { var state = CanvasState{}; state.pan_x = 20; diff --git a/graphcode-windows/src/GraphContextMenu.zig b/graphcode-windows/src/GraphContextMenu.zig index 703cc6f8..a6d3f2de 100644 --- a/graphcode-windows/src/GraphContextMenu.zig +++ b/graphcode-windows/src/GraphContextMenu.zig @@ -33,6 +33,7 @@ pub const Target = union(enum) { pub const Action = enum { none, + edit_node, rename_node, stop_node, delete_node, @@ -80,6 +81,7 @@ pub fn canEditEdge(edge_id: []const u8) bool { } const ids = struct { + const edit_node = 5100; const rename_node = 5101; const stop_node = 5102; const delete_node = 5103; @@ -156,8 +158,7 @@ pub fn show( appendEnabled(menu, ids.arm_composite, "Arm Schedule", node.can_arm); separator(menu); } - append(menu, ids.message_node, "Message"); - append(menu, ids.memo_node, "Memo"); + append(menu, ids.edit_node, "Edit Details..."); append(menu, ids.rename_node, "Rename..."); append(menu, ids.stop_node, "Stop"); append(menu, ids.delete_node, "Delete Loop..."); @@ -199,8 +200,7 @@ fn actionForCommand(command: c_int) Action { ids.stop_node => .stop_node, ids.delete_node => .delete_node, ids.open_terminal => .open_terminal, - ids.message_node => .message_node, - ids.memo_node => .memo_node, + ids.edit_node => .edit_node, ids.open_composite => .open_composite, ids.pilot_composite => .pilot_composite, ids.arm_composite => .arm_composite, diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index 696b511d..627b7a37 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -63,27 +63,27 @@ Statuses: | macOS surface | Required visible behavior | Windows evidence | Status | |---|---|---|---| | Cross-project global graph | Every open folder as a lane on one canvas | Windows renders every loaded graph summary as a lane; the real executable was exercised against the protocol stub and multi-project identity/layout has automated coverage | Partial | -| Folder lanes/bands | Project caption, worktree chip, open/close and folder actions | Project-captioned bands and loop cards exist; worktree chips and lane actions remain absent | Partial | +| Folder lanes/bands | Project caption, worktree chip, open/close and folder actions | Overview lanes now render distinct Open and Worktrees actions beside the project caption; click routing selects the project or opens scoped worktree inspection. Focused geometry/input coverage passes, but a live executable walkthrough remains blocked by the local Windows shell toolchain | Partial | | Notebook grid | Grid pans and zooms with canvas | GDI grid pans and zooms with the same transform used by project, overview, and Quick Chats content | Partial | -| Pan and anchored zoom | Pan, pointer-centered wheel/pinch zoom | Mouse pan and pointer-centered wheel zoom exist; no pinch/trackpad gesture evidence | Partial | -| 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. Ctrl+-, Ctrl+0, Ctrl+=, and Ctrl+9 are represented in the View menu, and the live UIA provider exposes invokable controls with bounds. The real Quick Chats canvas was exercised from 100% to 110%; hover help and trackpad pinch evidence remain absent | Partial | +| Pan and anchored zoom | Pan, pointer-centered wheel/pinch zoom | Mouse pan and pointer-centered zoom remain intact; high-resolution wheel/trackpad deltas now scale by notch count and have focused regression coverage. Native WM_POINTER/WM_GESTURE pinch evidence remains absent and the live walkthrough is blocked by the local shell toolchain | Partial | +| 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 | Painted count banner with shortcut text; no click action or oldest age | Partial | +| 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 | | 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 | Always-hit-testable right edge supports drag; no visible hover handles or parent-create affordance | Partial | -| Loop card identity | Loop-type stripe, title, state pill, entry/cycle role | Stripe is state-colored rather than type-colored; title/state text and START label only | Partial | +| 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 | +| 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 | NEEDS YOU label exists; no actionable button or reason-specific presentation | 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 | | 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 now expose separate Reclaim and Keep targets. Reclaim revalidates safety, names the worktree in a fail-closed confirmation, and uses the verified removal path; Keep suppresses the offer for the session. Focused geometry/safety tests and a real resolved-card fixture validate the offer; dedicated UIA descendants remain incomplete | Partial | +| 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 now use kind-specific solid/dotted/dashed styling and color, show condition plus fired count in a visible label, and retain selected-edge emphasis. Focused tests and a real-executable fired-message fixture verify the presentation; full cycle-guard wording and collision-free label layout for dense graphs remain incomplete | Partial | -| Edge creation sheet | Kind/condition/transform/cycle controls with conditional validation | A guided native form now provides title-plus-ID endpoint selectors for generic creation, locked identities for drag/edit flows, kind/condition/transform controls, conditional transform/spawn fields, cycle guards, inline validation, keyboard traversal, and scrolling. Focused tests and a real-executable capture validate the form; teaching polish and macOS visual treatment remain different | Partial | -| Node creation sheet | Loop-type teaching tiles, conditional fields, backend/model/branch pickers, recap, validation reason | A guided native form now hides internal metadata, provides loop-type/backend/model choices, type-specific fields, explanatory copy, descriptive checkbox accessibility, inline validation, keyboard traversal, and scrolling while preserving hidden wire values. Focused tests and a real-executable capture validate the form; teaching tiles, branch picker, recap, and macOS visual treatment remain incomplete | Partial | -| Node update/rename | Dedicated rename prompt and safe typed updates | Rename now uses a dedicated single-title prompt, trims input, rejects empty titles, re-resolves stable node identity after the modal, and sends the authoritative rename command. A purpose-built typed update editor is still absent | Partial | +| 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 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 | | 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 offers Create Edge only; node menu adds non-macOS Message/Memo and omits composite/open-group actions | 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 passes; live menu/UIA evidence remains blocked | Partial | ## Quick Chats