From 994f5444f59f0e16cae8db4bdde0a0ed52684b41 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Wed, 7 Oct 2026 23:51:13 -0700 Subject: [PATCH 1/2] Keep Show in Graph clear of the collapsed loop panel's expand control With the loop detail panel collapsed, the "Loop panel" expand control was painted over the loop bar's Show in graph button: both occupied the bar's trailing ~92px. Pointer clicks there could not reach Show in graph, and in fact no pointer click on the bar, the toggle, or the workspace tabs reached the workspace at all, because collapsed loop workspaces routed input as if the bottom workspace panel were hidden and fell through to the hidden graph. The bar's hit layout also always assumed the panel was expanded, so Stop and Show in graph were hit-tested 272px left of where they were drawn. The collapsed expand control now owns the loop bar's trailing slot, with Show in graph and Stop moved left of it. One App helper computes the bar layout for painting, pointer hit-testing, and UIA, and loop workspaces route pointer input from the header down regardless of panel state. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- graphcode-windows/src/App.zig | 153 +++++++++++++++++++--- graphcode-windows/src/GraphCanvas.zig | 6 +- graphcode-windows/src/LoopBarLayout.zig | 72 ++++++++-- graphcode-windows/src/TerminalSurface.zig | 22 ++-- investigation/ui-parity-matrix.md | 2 +- 5 files changed, 216 insertions(+), 39 deletions(-) diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 655c4893..73d58874 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -1107,6 +1107,19 @@ fn loopBarRect(rect: LoopBarLayout.Rect) c.RECT { return .{ .left = rect.left, .top = rect.top, .right = rect.right, .bottom = rect.bottom }; } +/// A loop workspace's bar in logical pixels. Painting, pointer hit-testing, and UIA all +/// read it, so the drawn buttons and their actionable bounds agree with the panel +/// expanded (the bar stops at the panel) or collapsed (the bar spans the client and its +/// trailing slot holds the panel's expand control). +fn workspaceLoopBar(controls: WorkspaceControls.State, client_right: i32, resolved: bool) LoopBarLayout.Layout { + return TerminalWorkspace.loopBarLayout( + if (controls.rail_visible) Tokens.sidebar_width else 0, + client_right - (if (controls.panel_visible) Tokens.loop_detail_width else 0), + resolved, + !controls.panel_visible, + ); +} + fn workspaceGraph(model: *const GraphModel.Model) ?*const GraphModel.GraphSummary { if (model.currentGraph()) |graph| if (graph.nodes.items.len != 0) return graph; if (model.selected_project_path) |path| { @@ -6995,12 +7008,10 @@ 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 - (if (self.workspace_controls.panel_visible) Tokens.loop_detail_width else 0); const selected_index = self.model.selectedIndex() orelse 0; - const loop_bar = TerminalWorkspace.loopBarLayout( - workspace_left, - workspace_right, + const loop_bar = workspaceLoopBar( + self.workspace_controls, + client.right, selected_index >= graph.nodes.items.len or isResolvedLoopState(graph.nodes.items[selected_index].state), ); if (!self.workspace_is_quick_chat) { @@ -7012,10 +7023,10 @@ pub const App = struct { if (loop_bar.stop) |stop| { self.appendAccessibilityElement(&elements, &owned_identities, "workspace-stop", graph.nodes.items[selected_index].id, "Stop loop", 4, .{ .logical = loopBarRect(stop) }, false, false) catch return; } - const panel_toggle = if (self.workspace_controls.panel_visible) - GraphCanvas.loopDetailCollapseBounds(client.right) + const panel_toggle = if (loop_bar.panel_toggle) |expand| + loopBarRect(expand) else - GraphCanvas.loopDetailExpandBounds(client.right); + GraphCanvas.loopDetailCollapseBounds(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, .{ .logical = 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; @@ -8267,6 +8278,7 @@ fn onWindowMessage( node.metric_passes, node.token_usage, isResolvedLoopState(node.state), + !app.workspace_controls.panel_visible, ); } if (!app.workspace_controls.panel_visible) GraphCanvas.paintLoopDetailExpandControl(hdc, app.allocator, logical_right); @@ -8586,7 +8598,9 @@ fn onWindowMessage( 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) + // A loop workspace fills everything under the header whether or not its loop + // detail panel is collapsed, so its bar, panel toggle, and tabs route from there. + const workspace_top = if (app.surface == .workspace) Tokens.header_height else routing.workspace_top; @@ -8620,14 +8634,7 @@ fn onWindowMessage( const index = app.model.selectedIndex() orelse graph.nodes.items.len; if (index < graph.nodes.items.len) { const node = graph.nodes.items[index]; - if (TerminalWorkspace.loopBarActionAt( - rail_left, - Tokens.header_height, - client.right - Tokens.loop_detail_width, - x, - y, - isResolvedLoopState(node.state), - )) |action| { + if (workspaceLoopBar(app.workspace_controls, client.right, isResolvedLoopState(node.state)).actionAt(x, y)) |action| { switch (action) { .stop => app.stopSelectedNode(), .show_graph => app.handleAction(.show_graph), @@ -10754,12 +10761,16 @@ const LoopBarProbe = struct { var bar_loop_len: usize = 0; var has_bar = false; var show_graph: ?c.RECT = null; + var stop: ?c.RECT = null; + var panel_toggle: ?c.RECT = null; var sink: @This() = .{}; fn publish(app: *App) void { has_bar = false; bar_loop_len = 0; show_graph = null; + stop = null; + panel_toggle = null; app.syncAccessibilityTo(&sink, logicalClientRect(app.window.hwnd, app.dpi)); } fn barLoop() []const u8 { @@ -10776,6 +10787,10 @@ const LoopBarProbe = struct { has_bar = true; } else if (std.mem.eql(u8, element.identity, "workspace-show-graph:show-graph")) { show_graph = .{ .left = element.left, .top = element.top, .right = element.right, .bottom = element.bottom }; + } else if (std.mem.startsWith(u8, element.identity, "workspace-stop:")) { + stop = .{ .left = element.left, .top = element.top, .right = element.right, .bottom = element.bottom }; + } else if (std.mem.eql(u8, element.identity, "workspace-toggle-panel:control")) { + panel_toggle = .{ .left = element.left, .top = element.top, .right = element.right, .bottom = element.bottom }; } } } @@ -10916,6 +10931,28 @@ const ShowGraphFixture = struct { self.tmp.cleanup(); } + /// Loops A, B, C all idle (so the loop bar shows Stop) in a window `logical_width` + /// logical pixels wide at `dpi`, on the project graph with nothing open yet. + fn initSized(self: *ShowGraphFixture, dpi: u32, logical_width: i32) !void { + const allocator = std.testing.allocator; + self.sequence = 2; + self.tmp = std.testing.tmpDir(.{}); + self.directory = try self.tmp.dir.realpathAlloc(allocator, "."); + self.layout_path = try std.fs.path.join(allocator, &.{ self.directory, "layout.json" }); + self.workspace = try reopenTestWorkspace(allocator, self.layout_path); + self.app = try overviewTestApp(dpi); + const app = &self.app; + const initial = try showGraphFixtureFrame(app, 1, "idle", "idle"); + defer allocator.free(initial); + _ = try app.model.updateFromFrame(initial); + try std.testing.expect(app.selectProject(reopen_fixture_path)); + app.surface = .project; + app.workspace_controls = .{ .rail_visible = true, .panel_visible = false, .activity_enabled = false }; + app.workspace = &self.workspace; + try std.testing.expect(c.MoveWindow(app.window.hwnd, 0, 0, physicalCoordinate(logical_width, dpi), physicalCoordinate(700, dpi), 0) != 0); + try std.testing.expectEqual(logical_width, logicalClientRect(app.window.hwnd, dpi).right); + } + fn openFromSidebar(self: *ShowGraphFixture, node_id: []const u8) !void { const before = self.app.client.outbound_count; try clickSidebarLoopRow(&self.app, reopen_fixture_path, node_id); @@ -10956,6 +10993,87 @@ test "Show in Graph from an open loop shows the graph and never moves the loop b } } +const LoopBarButtons = struct { show_graph: c.RECT, stop: c.RECT, panel_toggle: c.RECT }; + +fn physicalRectsOverlap(a: c.RECT, b: c.RECT) bool { + return a.left < b.right and b.left < a.right and a.top < b.bottom and b.top < a.bottom; +} + +/// Show in Graph, Stop, and the loop panel toggle as UIA publishes them (physical +/// pixels): each present, non-empty, inside the client area, and pairwise disjoint. +fn expectLoopBarButtonsDisjoint(app: *App) !LoopBarButtons { + LoopBarProbe.publish(app); + const buttons = LoopBarButtons{ + .show_graph = LoopBarProbe.show_graph orelse return error.TestExpectedShowGraphButton, + .stop = LoopBarProbe.stop orelse return error.TestExpectedStopButton, + .panel_toggle = LoopBarProbe.panel_toggle orelse return error.TestExpectedPanelToggle, + }; + var client: c.RECT = undefined; + try std.testing.expect(c.GetClientRect(app.window.hwnd, &client) != 0); + for ([_]c.RECT{ buttons.show_graph, buttons.stop, buttons.panel_toggle }) |bounds| { + try std.testing.expect(bounds.left < bounds.right and bounds.top < bounds.bottom); + try std.testing.expect(bounds.left >= client.left and bounds.right <= client.right); + try std.testing.expect(bounds.top >= client.top and bounds.bottom <= client.bottom); + } + try std.testing.expect(!physicalRectsOverlap(buttons.show_graph, buttons.stop)); + try std.testing.expect(!physicalRectsOverlap(buttons.show_graph, buttons.panel_toggle)); + try std.testing.expect(!physicalRectsOverlap(buttons.stop, buttons.panel_toggle)); + return buttons; +} + +fn nativeClickCenter(app: *App, bounds: c.RECT) !void { + try nativeClick(app, @divTrunc(bounds.left + bounds.right, 2), @divTrunc(bounds.top + bounds.bottom, 2)); +} + +test "loop panel toggle never covers Show in Graph or Stop, collapsed or expanded, at 1200 and 960px and 100% and 150% DPI" { + for ([_]u32{ Dpi.base_dpi, Dpi.base_dpi * 3 / 2 }) |dpi| { + for ([_]i32{ 1200, 960 }) |width| { + var fixture: ShowGraphFixture = undefined; + try fixture.initSized(dpi, width); + defer fixture.deinit(); + const app = &fixture.app; + try std.testing.expect(app.activateLoop(reopen_fixture_path, "loop-b")); + try expectLoopBarAndPane(app, &fixture.workspace, "loop-b"); + try std.testing.expect(app.workspace_controls.panel_visible); + + // Expanded: the panel's Collapse button is clear of the bar's buttons and collapses it. + const expanded = try expectLoopBarButtonsDisjoint(app); + try nativeClickCenter(app, expanded.panel_toggle); + try std.testing.expect(!app.workspace_controls.panel_visible); + try expectLoopBarAndPane(app, &fixture.workspace, "loop-b"); + + // Collapsed: a native click at the published centre of each button runs only it. + const collapsed = try expectLoopBarButtonsDisjoint(app); + const before_stop = app.client.outbound_count; + try nativeClickCenter(app, collapsed.stop); + try std.testing.expectEqual(before_stop + 1, app.client.outbound_count); + const newest = app.client.outbound[(app.client.outbound_head + app.client.outbound_count - 1) % app.client.outbound.len]; + try std.testing.expect(std.mem.indexOf(u8, newest, "stopNode") != null); + try std.testing.expect(std.mem.indexOf(u8, newest, "loop-b") != null); + try std.testing.expect(!app.workspace_controls.panel_visible); + try expectLoopBarAndPane(app, &fixture.workspace, "loop-b"); + + const before_show = app.client.outbound_count; + try nativeClickCenter(app, collapsed.show_graph); + try std.testing.expectEqual(GraphCanvas.Surface.project, app.surface); + try std.testing.expect(!app.workspace_controls.panel_visible); + try std.testing.expectEqualStrings("loop-b", app.model.selected().?.id); + try std.testing.expectEqual(before_show, app.client.outbound_count); + LoopBarProbe.publish(app); + try std.testing.expect(!LoopBarProbe.has_bar); + + // Reopened collapsed, the expand control still expands the panel. + try std.testing.expect(app.activateLoop(reopen_fixture_path, "loop-b")); + try std.testing.expectEqual(GraphCanvas.Surface.workspace, app.surface); + if (app.workspace_controls.panel_visible) app.toggleWorkspaceDetailPanel(); + const reopened = try expectLoopBarButtonsDisjoint(app); + try nativeClickCenter(app, reopened.panel_toggle); + try std.testing.expect(app.workspace_controls.panel_visible); + try expectLoopBarAndPane(app, &fixture.workspace, "loop-b"); + } + } +} + test "selection routes with a loop workspace open: Ctrl+Tab review moves the loop bar and pane together" { var fixture: ShowGraphFixture = undefined; try fixture.init(); @@ -11982,6 +12100,7 @@ test "open workspace applies production stopped state and removes Stop control" Tokens.sidebar_width, 1200 - Tokens.loop_detail_width, isResolvedLoopState(graph.nodes.items[selected_index].state), + false, ); try std.testing.expect(loop_bar.stop == null); try std.testing.expectEqual(@as(usize, 1), F.publications); diff --git a/graphcode-windows/src/GraphCanvas.zig b/graphcode-windows/src/GraphCanvas.zig index 83575d5c..190345e6 100644 --- a/graphcode-windows/src/GraphCanvas.zig +++ b/graphcode-windows/src/GraphCanvas.zig @@ -4,6 +4,7 @@ const Tokens = @import("DesignTokens.zig"); const Sidebar = @import("Sidebar.zig"); const WorktreeStatus = @import("WorktreeStatus.zig"); const WorkspaceControls = @import("WorkspaceControls.zig"); +const LoopBarLayout = @import("LoopBarLayout.zig"); const c = @import("Win32.zig").c; const AppFont = @import("AppFont.zig"); const GdiplusAA = @import("GdiplusAA.zig"); @@ -600,8 +601,11 @@ 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); } +/// The collapsed panel's "Loop panel" control is the loop bar's trailing button, so its +/// bounds come from the bar's layout, which keeps Show in graph and Stop clear of it. pub fn loopDetailExpandBounds(client_right: i32) c.RECT { - return rect(client_right - 104, Tokens.header_height + 8, client_right - 14, Tokens.header_height + 30); + const bounds = LoopBarLayout.panelToggleRect(Tokens.header_height, client_right); + return rect(bounds.left, bounds.top, bounds.right, bounds.bottom); } pub fn hitTestLoopDetailCollapse(x: i32, y: i32, client_right: i32, visible: bool) bool { diff --git a/graphcode-windows/src/LoopBarLayout.zig b/graphcode-windows/src/LoopBarLayout.zig index 4fdcb758..50614e13 100644 --- a/graphcode-windows/src/LoopBarLayout.zig +++ b/graphcode-windows/src/LoopBarLayout.zig @@ -32,6 +32,16 @@ pub const natural_text_width: i32 = 220; pub const button_gap: i32 = 8; // Narrower than this a run would show little more than an ellipsis, so it is dropped. pub const min_text_width: i32 = 24; +// The collapsed loop detail panel's "Loop panel" expand control, the bar's trailing button. +pub const panel_toggle_width: i32 = 90; +const trailing_inset: i32 = 12; +const show_graph_width: i32 = 92; +const stop_width: i32 = 84; + +/// Where the collapsed loop panel's expand control sits in a bar ending at `right`. +pub fn panelToggleRect(top: i32, right: i32) Rect { + return .{ .left = right - trailing_inset - panel_toggle_width, .top = top + 10, .right = right - trailing_inset, .bottom = top + 36 }; +} pub const Layout = struct { bar: Rect, @@ -42,6 +52,9 @@ pub const Layout = struct { detail: ?Rect, stop: ?Rect, show_graph: Rect, + // Present when the loop detail panel is collapsed: its expand control owns the + // bar's trailing slot, so Show in graph and Stop move left of it. + panel_toggle: ?Rect, // Buttons accept clicks across the bar's full height, using their drawn columns. pub fn actionAt(self: Layout, x: i32, y: i32) ?Action { @@ -58,9 +71,12 @@ fn textRect(x: i32, y: i32, size: i32, limit: i32) ?Rect { return .{ .left = x, .top = y, .right = right, .bottom = y + size + 8 }; } -pub fn compute(left: i32, top: i32, right: i32, resolved: bool) Layout { - const show_graph = Rect{ .left = right - 104, .top = top + 10, .right = right - 12, .bottom = top + 36 }; - const stop: ?Rect = if (resolved) null else .{ .left = right - 196, .top = top + 10, .right = right - 112, .bottom = top + 36 }; +pub fn compute(left: i32, top: i32, right: i32, resolved: bool, panel_toggle: bool) Layout { + const toggle: ?Rect = if (panel_toggle) panelToggleRect(top, right) else null; + const show_graph_right = if (toggle) |value| value.left - button_gap else right - trailing_inset; + const show_graph = Rect{ .left = show_graph_right - show_graph_width, .top = top + 10, .right = show_graph_right, .bottom = top + 36 }; + const stop_right = show_graph.left - button_gap; + const stop: ?Rect = if (resolved) null else .{ .left = stop_right - stop_width, .top = top + 10, .right = stop_right, .bottom = top + 36 }; const limit = (if (stop) |value| value.left else show_graph.left) - button_gap; const stripe = Rect{ .left = left + 14, .top = top + 11, .right = left + 18, .bottom = top + 35 }; return .{ @@ -72,6 +88,7 @@ pub fn compute(left: i32, top: i32, right: i32, resolved: bool) Layout { .detail = textRect(left + 260, top + 10, 9, limit), .stop = stop, .show_graph = show_graph, + .panel_toggle = toggle, }; } @@ -83,14 +100,47 @@ fn expectClearOfButtons(layout: Layout) !void { try std.testing.expect(run.left >= layout.bar.left); try std.testing.expect(!run.intersects(layout.show_graph)); if (layout.stop) |stop| try std.testing.expect(!run.intersects(stop)); + if (layout.panel_toggle) |toggle| try std.testing.expect(!run.intersects(toggle)); + } + if (layout.panel_toggle) |toggle| { + try std.testing.expect(!toggle.intersects(layout.show_graph)); + if (layout.stop) |stop| try std.testing.expect(!toggle.intersects(stop)); + try std.testing.expect(toggle.right <= layout.bar.right); + } + if (layout.stop) |stop| try std.testing.expect(!stop.intersects(layout.show_graph)); +} + +test "collapsed loop panel's expand control takes the bar's trailing slot and pushes Show in graph and Stop left" { + const top = Tokens.header_height; + for ([_]i32{ 1280, 1200, 960 }) |width| { + for ([_]bool{ false, true }) |resolved| { + const left = Tokens.sidebar_width; + const layout = compute(left, top, width, resolved, true); + try expectClearOfButtons(layout); + const toggle = layout.panel_toggle.?; + try std.testing.expectEqual(panelToggleRect(top, width), toggle); + try std.testing.expectEqual(Rect{ .left = width - 102, .top = top + 10, .right = width - 12, .bottom = top + 36 }, toggle); + try std.testing.expectEqual(toggle.left - button_gap, layout.show_graph.right); + try std.testing.expectEqual(@as(i32, 92), layout.show_graph.width()); + if (layout.stop) |stop| try std.testing.expectEqual(layout.show_graph.left - button_gap, stop.right); + // The expand control's column is never a Stop or Show in graph hit. + try std.testing.expect(layout.actionAt(toggle.left + 4, top + 20) == null); + try std.testing.expect(layout.actionAt(toggle.right - 1, top + 20) == null); + try std.testing.expectEqual(Action.show_graph, layout.actionAt(layout.show_graph.left + 4, top + 20).?); + } } } +test "expanded loop panel leaves the bar without a trailing toggle slot" { + const layout = compute(Tokens.sidebar_width, Tokens.header_height, 960 - Tokens.loop_detail_width, false, false); + try std.testing.expect(layout.panel_toggle == null); +} + test "loop bar metadata stays clear of Stop and Show in graph at 960px with rail and detail panel" { // beta10 Dev Box qualification: 960px shell, 220px sidebar rail, 272px loop detail panel. const left = Tokens.sidebar_width; const right = 960 - Tokens.loop_detail_width; - const layout = compute(left, Tokens.header_height, right, false); + const layout = compute(left, Tokens.header_height, right, false, false); try expectClearOfButtons(layout); // Only 4px remain between the metadata origin and Stop, so the run is dropped. try std.testing.expect(layout.detail == null); @@ -100,7 +150,7 @@ test "loop bar metadata stays clear of Stop and Show in graph at 960px with rail test "loop bar ellipsizes metadata into the space left of Stop at 1056px with rail and detail panel" { const left = Tokens.sidebar_width; const right = 1056 - Tokens.loop_detail_width; - const layout = compute(left, Tokens.header_height, right, false); + const layout = compute(left, Tokens.header_height, right, false, false); try expectClearOfButtons(layout); const detail = layout.detail.?; try std.testing.expectEqual(left + 260, detail.left); @@ -112,7 +162,7 @@ test "loop bar keeps the wide 1280px geometry unchanged" { const left = Tokens.sidebar_width; const right = 1280 - Tokens.loop_detail_width; const top = Tokens.header_height; - const layout = compute(left, top, right, false); + const layout = compute(left, top, right, false, false); try expectClearOfButtons(layout); try std.testing.expectEqual(Rect{ .left = left + 14, .top = top + 11, .right = left + 18, .bottom = top + 35 }, layout.stripe.?); try std.testing.expectEqual(Rect{ .left = left + 27, .top = top + 5, .right = left + 247, .bottom = top + 26 }, layout.title.?); @@ -126,11 +176,11 @@ test "loop bar keeps the wide 1280px geometry unchanged" { test "loop bar drops text that cannot fit beside the buttons on an extremely narrow bar" { const left = Tokens.sidebar_width; const right = left + 240; - const layout = compute(left, Tokens.header_height, right, false); + const layout = compute(left, Tokens.header_height, right, false, false); try expectClearOfButtons(layout); try std.testing.expect(layout.title == null and layout.state == null and layout.activity == null and layout.detail == null); try std.testing.expectEqual(right - 12, layout.show_graph.right); - const sliver = compute(left, Tokens.header_height, left + 100, false); + const sliver = compute(left, Tokens.header_height, left + 100, false, false); try expectClearOfButtons(sliver); try std.testing.expect(sliver.stripe == null); } @@ -138,19 +188,19 @@ test "loop bar drops text that cannot fit beside the buttons on an extremely nar test "loop bar text reclaims the Stop slot once the loop is resolved" { const left = Tokens.sidebar_width; const right = 960 - Tokens.loop_detail_width; - const resolved = compute(left, Tokens.header_height, right, true); + const resolved = compute(left, Tokens.header_height, right, true, false); try std.testing.expect(resolved.stop == null); try expectClearOfButtons(resolved); try std.testing.expectEqual(resolved.show_graph.left - button_gap, resolved.detail.?.right); } test "loop bar actions hit exactly the button rects" { - const layout = compute(220, 34, 1200, false); + const layout = compute(220, 34, 1200, false, false); try std.testing.expectEqual(Action.stop, layout.actionAt(1010, 50).?); try std.testing.expectEqual(Action.show_graph, layout.actionAt(1120, 50).?); try std.testing.expectEqual(Action.show_graph, layout.actionAt(1120, 35).?); try std.testing.expect(layout.actionAt(1120, 80) == null); try std.testing.expect(layout.actionAt(1120, 90) == null); try std.testing.expect(layout.actionAt(layout.stop.?.right, 50) == null); - try std.testing.expect(compute(220, 34, 1200, true).actionAt(1010, 50) == null); + try std.testing.expect(compute(220, 34, 1200, true, false).actionAt(1010, 50) == null); } diff --git a/graphcode-windows/src/TerminalSurface.zig b/graphcode-windows/src/TerminalSurface.zig index f983fd43..4a0cd128 100644 --- a/graphcode-windows/src/TerminalSurface.zig +++ b/graphcode-windows/src/TerminalSurface.zig @@ -163,12 +163,12 @@ pub const ChromeAction = enum { new_tab, split_right, split_down }; pub const TabAction = enum { select, close }; pub const LoopBarAction = LoopBarLayout.Action; -pub fn loopBarLayout(left: i32, right: i32, resolved: bool) LoopBarLayout.Layout { - return LoopBarLayout.compute(left, Tokens.header_height, right, resolved); +pub fn loopBarLayout(left: i32, right: i32, resolved: bool, panel_toggle: bool) LoopBarLayout.Layout { + return LoopBarLayout.compute(left, Tokens.header_height, right, resolved, panel_toggle); } -pub fn loopBarActionAt(left: i32, top: i32, right: i32, x: i32, y: i32, resolved: bool) ?LoopBarAction { - return LoopBarLayout.compute(left, top, right, resolved).actionAt(x, y); +pub fn loopBarActionAt(left: i32, top: i32, right: i32, x: i32, y: i32, resolved: bool, panel_toggle: bool) ?LoopBarAction { + return LoopBarLayout.compute(left, top, right, resolved, panel_toggle).actionAt(x, y); } fn chromeActionForBounds(origin_x: i32, origin_y: i32, width: i32, x: i32, y: i32) ?ChromeAction { @@ -1296,9 +1296,10 @@ pub const Workspace = struct { metric_passes: u32, token_usage: ?u32, resolved: bool, + panel_toggle: bool, ) void { const top = Tokens.header_height; - const layout = loopBarLayout(left, right, resolved); + const layout = loopBarLayout(left, right, resolved, panel_toggle); // Theme.loopBar: lit like the tab strip, one step lighter. GdiGradient.fillVertical(hdc, .{ .left = left, .top = top, .right = right, .bottom = top + Tokens.loop_bar_height }, Tokens.loop_bar_top, Tokens.loop_bar_bottom); if (layout.stripe) |stripe| { @@ -4856,10 +4857,13 @@ test "workspace tab chrome separates selection and close affordances" { } test "loop bar actions expose stop only for active loops" { - try std.testing.expectEqual(LoopBarAction.stop, loopBarActionAt(220, 34, 1200, 1010, 50, false).?); - try std.testing.expect(loopBarActionAt(220, 34, 1200, 1010, 50, true) == null); - try std.testing.expectEqual(LoopBarAction.show_graph, loopBarActionAt(220, 34, 1200, 1120, 50, false).?); - try std.testing.expect(loopBarActionAt(220, 34, 1200, 1120, 90, false) == null); + try std.testing.expectEqual(LoopBarAction.stop, loopBarActionAt(220, 34, 1200, 1010, 50, false, false).?); + try std.testing.expect(loopBarActionAt(220, 34, 1200, 1010, 50, true, false) == null); + try std.testing.expectEqual(LoopBarAction.show_graph, loopBarActionAt(220, 34, 1200, 1120, 50, false, false).?); + try std.testing.expect(loopBarActionAt(220, 34, 1200, 1120, 90, false, false) == null); + // With the panel collapsed its expand control owns the trailing slot. + try std.testing.expect(loopBarActionAt(220, 34, 1200, 1120, 50, false, true) == null); + try std.testing.expectEqual(LoopBarAction.show_graph, loopBarActionAt(220, 34, 1200, 1050, 50, false, true).?); } const AttachOutputProbe = struct { diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index f8a89665..a6aab6b6 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -185,7 +185,7 @@ native keyboard/accelerator/window proof; the workspace row remains Partial. | 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 | Workspace implementation tests cover topology. The live UIA gate creates a second tab and attempts a switch/identity round trip, but its former `^workspace-tab-` selectors also matched `workspace-tab-close-*` controls (whose name is `Close tab`), so an indexed invocation could close rather than select a tab. All seven actual-tab predicates now require numeric tab IDs; an AST-extracted source regression checks interleaved, close-first, reversed, no-close, close-only, empty, and split-tab child lists for exact selection and order. This source contract does not demonstrate backend continuity or a new live UIA round trip with the corrected gate | Partial | -| Right loop panel | Minimap, upstream/downstream, fired conditions, metric sparkline, branch/start/usage footer | The full workspace right rail includes the selected-loop map, upstream/downstream cards, fired-edge coloring, edge conditions, branch/worktree identity, metric/goal detail, model tier, a metric-history sparkline from decoded samples, start-time/usage/backend footer text, a collapse/expand control that no longer reserves rail width while hidden, and dedicated UIA children for sparkline/start/usage/toggle (`workspace-detail-sparkline-*`, start, usage, and toggle automation IDs). The live UIA gate asserts those children and toggles collapse/expand, and that gate passed on `windows-shell` (run https://github.com/scgopi/GraphCode/actions/runs/35638849754, merged as PR #399) | Validated | +| Right loop panel | Minimap, upstream/downstream, fired conditions, metric sparkline, branch/start/usage footer | The full workspace right rail includes the selected-loop map, upstream/downstream cards, fired-edge coloring, edge conditions, branch/worktree identity, metric/goal detail, model tier, a metric-history sparkline from decoded samples, start-time/usage/backend footer text, a collapse/expand control that no longer reserves rail width while hidden, and dedicated UIA children for sparkline/start/usage/toggle (`workspace-detail-sparkline-*`, start, usage, and toggle automation IDs). The live UIA gate asserts those children and toggles collapse/expand, and that gate passed on `windows-shell` (run https://github.com/scgopi/GraphCode/actions/runs/35638849754, merged as PR #399). That gate toggled through UIA invoke only; with the panel collapsed, the "Loop panel" expand control was painted over the loop bar's Show in graph button, and pointer clicks on the bar, the toggle, or the workspace tabs were routed to the hidden graph. The collapsed expand control now takes the loop bar's trailing slot (Show in graph and Stop move left of it), the bar's paint, pointer hit-testing, and UIA bounds share one layout, and collapsed loop workspaces route pointer input from the header down. A native-linked App test drives WM_LBUTTONDOWN/UP at the published centers of Collapse, Stop, Show in graph, and Expand at 1200 and 960 logical px and 100%/150% DPI, and asserts the three controls are disjoint. That is unit evidence only: the collapsed geometry has not had a live gate or Dev Box walkthrough, so this row is `Partial` until it does | Partial | | Show in Graph | Visible loop-bar and menu action | The live gate invokes the real `workspace-show-graph-*` loop-bar action through `InvokePattern`, requires the exact selected `UIA loop A` `canvas-card-*` identity and selected state with strict workspace-chrome absence, then returns through the exact supported `loop-row-*` identity and requires the same project toolbar, selected-loop bar, and Show in Graph identities. A focused isolated pair at `.graphcode-evidence/show-in-graph-20260924-151455` used source gate SHA-256 `AE4A2C71...`, focused harness `78E6821E...`, and shell `8E0AA9E7...`: GREEN invoked the action and completed the exact round trip (stdout `E94DCA9D...`); RED disabled only that invocation and failed the unchanged chrome-absence assertion (stderr `3198914D...`). Historical #449 integrated CI runs 36074665943 (`windows-shell`) and 36074665944 (`windows-port`) passed on head `068335d1`, but precede this selector correction and do not prove its live behavior or a complete native menu/roundtrip walkthrough. The focused pair confirms the route without a product fix; graph cards remain intentionally non-invokable. Its early return did not exercise later downstream-provider rebinding. A new full integrated gate pass and live native Loop-menu invocation remain unverified. | Partial | **Live-gate infrastructure fix (this session):** the `windows-shell` CI job's `uia-live-gate.ps1` step was, until now, never actually exercising any of the workspace chrome above: `App.init()` unconditionally skipped `Workspace.init()` under `GRAPHCODE_UIA_GATE=1` regardless of whether a real `zmx` executable was supplied (a pre-existing guard predating this workstream), so every "Partial" row above had never been run against a real workspace at all. Fixed in `App.zig` to build the real workspace under the gate whenever `GRAPHCODE_ZMX` is present. That surfaced a second, genuine regression: the newly-real terminal surface competed for native Win32 keyboard focus with the rest of the UI after navigating away from the workspace (`App.openGlobalOverview()` and friends). Root-caused to `Workspace.poll()` (driven by the main window's 100ms `WM_TIMER`) unconditionally draining terminal output and calling `winghostty_surface_notify_accessibility_text()` regardless of workspace visibility, which kept re-asserting UI Automation focus on the terminal no matter what Win32-level focus fixes were made. Fixed by adding `Workspace.collapse()`/`Workspace.collapsed`, skipping `resize()`/`syncTopology()`'s pane refocus and terminal-output polling entirely while the workspace is hidden, plus a `WM_ACTIVATE` handler that reasserts the app's own focus policy after `DefWindowProc`'s default child-focus restoration on window reactivation. All of this is now covered by the passing `windows-shell` CI job (commits `a31813b`..`cba010f`, run https://github.com/scgopi/GraphCode/actions/runs/35415967793). Note: the separate `windows-spikes`/`windows-hardening` jobs (`validate.ps1 -Task all`) run the identical gate script but under much heavier CI load and still intermittently hit this same assertion's 15-second retry window; this has been confirmed as pre-existing, cross-branch flakiness unrelated to this workstream (an unrelated sibling branch, `coneilen-microsoft-repository-settings-parity`, shows both a pass and an unrelated failure on the same job across consecutive runs), not a regression introduced here. From 2d33df35fb0f38879ca6d706b385c56eedf562c8 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Thu, 8 Oct 2026 00:16:04 -0700 Subject: [PATCH 2/2] Prove collapsed loop workspaces route tab and loop-bar clicks to the workspace With the loop detail panel collapsed, WM_LBUTTONDOWN used to take the graph surface's workspace_top (the client bottom), so presses on the workspace tab strip and the loop bar fell through to the hidden graph and started a canvas pan. This adds a native-message App test at 1200 and 960 px and 96 and 144 DPI that presses a workspace tab and the loop bar's Stop with the panel collapsed, checks between DOWN and UP that no graph pan, node drag, or edge drag began, and requires the tab selection to change and Stop to send one stopNode for the open loop. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- graphcode-windows/src/App.zig | 59 +++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 73d58874..c83e7db6 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -11074,6 +11074,65 @@ test "loop panel toggle never covers Show in Graph or Stop, collapsed or expande } } +/// One native press at a physical point, checked between DOWN and UP so a hidden-graph +/// pan that UP would end is still observable. +fn expectWorkspaceOwnsPress(app: *App, x: i32, y: i32, node_id: []const u8) !void { + var result: c.LRESULT = 0; + const lparam: c.LPARAM = @intCast(@as(u32, @intCast(x)) | (@as(u32, @intCast(y)) << 16)); + try std.testing.expect(onWindowMessage(app, app.window.hwnd, c.WM_LBUTTONDOWN, 0, lparam, &result)); + try std.testing.expect(!app.canvas.dragging); + try std.testing.expect(!app.canvas.node_dragging); + try std.testing.expect(app.edge_drag_source_id.len == 0); + _ = onWindowMessage(app, app.window.hwnd, c.WM_LBUTTONUP, 0, lparam, &result); + try std.testing.expectEqual(GraphCanvas.Surface.workspace, app.surface); + try std.testing.expect(!app.workspace_controls.panel_visible); + try std.testing.expectEqualStrings(node_id, app.model.selected().?.id); +} + +test "collapsed loop panel routes native tab and loop-bar clicks to the workspace, not the hidden graph" { + for ([_]u32{ Dpi.base_dpi, Dpi.base_dpi * 3 / 2 }) |dpi| { + for ([_]i32{ 1200, 960 }) |width| { + var fixture: ShowGraphFixture = undefined; + try fixture.initSized(dpi, width); + defer fixture.deinit(); + const app = &fixture.app; + const workspace = &fixture.workspace; + try std.testing.expect(app.activateLoop(reopen_fixture_path, "loop-b")); + try std.testing.expect(app.workspace_controls.panel_visible); + app.toggleWorkspaceDetailPanel(); + try std.testing.expect(!app.workspace_controls.panel_visible); + try expectLoopBarAndPane(app, workspace, "loop-b"); + + // A second tab, selected, so a click on the loop's tab has a visible effect. + try workspace.layout.addTab("shell-extra", false); + try std.testing.expectEqual(@as(usize, 1), workspace.layout.selected_tab); + const tab = TerminalWorkspace.tabBounds(workspace.layout_origin_x, workspace.layout_origin_y, 0); + const tab_x = tab.left + 24; + const tab_y = @divTrunc(tab.top + tab.bottom, 2); + // The tab strip lies below the header and the loop bar, inside the client area. + try std.testing.expect(logicalCoordinate(tab_y, dpi) >= Tokens.header_height + Tokens.loop_bar_height); + try std.testing.expectEqual(@import("TerminalSurface.zig").TabAction.select, workspace.tabActionAt(tab_x, tab_y).?.action); + + const before_tab = app.client.outbound_count; + try expectWorkspaceOwnsPress(app, tab_x, tab_y, "loop-b"); + try std.testing.expectEqual(@as(usize, 0), workspace.layout.selected_tab); + try std.testing.expectEqual(before_tab, app.client.outbound_count); + try expectLoopBarAndPane(app, workspace, "loop-b"); + + // A loop-bar control below the header reaches the bar, not the graph. + LoopBarProbe.publish(app); + const stop = LoopBarProbe.stop orelse return error.TestExpectedStopButton; + try std.testing.expect(logicalCoordinate(stop.top, dpi) > Tokens.header_height); + const before_stop = app.client.outbound_count; + try expectWorkspaceOwnsPress(app, @divTrunc(stop.left + stop.right, 2), @divTrunc(stop.top + stop.bottom, 2), "loop-b"); + try std.testing.expectEqual(before_stop + 1, app.client.outbound_count); + const newest = app.client.outbound[(app.client.outbound_head + app.client.outbound_count - 1) % app.client.outbound.len]; + try std.testing.expect(std.mem.indexOf(u8, newest, "stopNode") != null); + try std.testing.expect(std.mem.indexOf(u8, newest, "loop-b") != null); + } + } +} + test "selection routes with a loop workspace open: Ctrl+Tab review moves the loop bar and pane together" { var fixture: ShowGraphFixture = undefined; try fixture.init();