From 2ea0d8b013739e6f184494683d218efc10cc8013 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Wed, 7 Oct 2026 23:03:07 -0700 Subject: [PATCH 1/2] Keep Show in Graph and the loop bar on the open Windows loop With a stopped loop waiting in Needs you, the graph's "loops need you" rail stayed hit-testable in a loop workspace, where the loop bar is painted over the same strip. Its bounds cover the bar's Show in Graph button, so a click there ran Review attention instead: the graph stayed hidden and the bar moved to the Needs-you loop while the pane kept the open loop's session (Dev Box beta16 qualification, Section 6B, LoopActivation). The rail is now drawn and hit-tested only on graph surfaces. With a loop workspace open, Review attention (Ctrl+Tab and the Loop menu), the sidebar Needs-you row (pointer and UIA), and Next/Previous Loop open their target through the activation path instead of only re-selecting, so the bar and the pane always name the same loop. Graph surfaces keep select-only behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- graphcode-windows/src/App.zig | 337 +++++++++++++++++++++++++- graphcode-windows/src/GraphCanvas.zig | 9 +- investigation/ui-parity-matrix.md | 2 +- 3 files changed, 333 insertions(+), 15 deletions(-) diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index e0324515..655c4893 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -2452,6 +2452,43 @@ pub const App = struct { if (self.model.selected_index) |index| _ = self.selectNodeIndex(index); } + // A loop workspace's bar names the selected loop. So with one open, a route that + // moves the selection to another loop must open that loop through `activateLoop`, or + // the bar and the visible pane name different loops. Graph surfaces only select. + + fn openNextAttention(self: *App) void { + if (self.model.attention_entries.items.len == 0) return; + const open_node_id = self.allocator.dupe(u8, self.selected_node_id) catch return; + defer self.allocator.free(open_node_id); + self.selectNextAttention(); + if (std.mem.eql(u8, open_node_id, self.selected_node_id)) return; + self.openSelectedNode(); + } + + fn stepOpenLoop(self: *App, forward: bool) void { + const graph = self.model.graph orelse return; + const count = graph.nodes.items.len; + if (count == 0) return; + const target = if (self.model.selected_index) |current| + (if (forward) (current + 1) % count else (current + count - 1) % count) + else if (forward) 0 else count - 1; + if (self.model.selected_index == target) return; + _ = self.activateLoop(graph.project.path, graph.nodes.items[target].id); + } + + /// The sidebar's Needs-you row, by pointer or UIA. Returns whether it only selected. + fn chooseAttentionEntry(self: *App, index: usize) bool { + if (index >= self.model.attention_entries.items.len) return false; + const entry = self.model.attention_entries.items[index]; + if (self.surface == .workspace) { + _ = self.activateLoop(entry.project_path, entry.node.id); + return false; + } + if (!self.selectProject(entry.project_path)) return false; + _ = self.model.setSelectedID(entry.node.id); + return true; + } + fn stopAttentionEntry(self: *App, entry: GraphModel.AttentionEntry) void { self.client.sendNodeAction(entry.project_path, entry.node.id, "stopNode", null); const project_name = if (self.model.graphFor(entry.project_path)) |graph| graph.project.name else entry.project_path; @@ -5922,7 +5959,7 @@ pub const App = struct { self.applyOnboardingBackend(backend); }, .cycle_attention => { - self.selectNextAttention(); + if (self.surface == .workspace) self.openNextAttention() else self.selectNextAttention(); self.syncAccessibility(); _ = c.InvalidateRect(self.window.hwnd, null, 0); }, @@ -5936,10 +5973,12 @@ pub const App = struct { .focus_terminal_a => if (self.workspace) |workspace| workspace.focus(0), .focus_terminal_b => if (self.workspace) |workspace| workspace.focus(1), .select_next => { + if (self.surface == .workspace) return self.stepOpenLoop(true); self.selectNextNode(); _ = c.InvalidateRect(self.window.hwnd, null, 0); }, .select_previous => { + if (self.surface == .workspace) return self.stepOpenLoop(false); const graph = self.model.graph orelse return; if (graph.nodes.items.len == 0) return; const current = self.model.selected_index orelse 0; @@ -7389,8 +7428,7 @@ pub const App = struct { .needs_you_header => {}, .needs_you => |index| { if (index >= self.model.attention_entries.items.len) return false; - const entry = self.model.attention_entries.items[index]; - if (self.selectProject(entry.project_path)) _ = self.model.setSelectedID(entry.node.id); + _ = self.chooseAttentionEntry(index); }, .needs_you_stop => |index| { if (index >= self.model.attention_entries.items.len) return false; @@ -8541,7 +8579,7 @@ fn onWindowMessage( return true; } if (app.header_focus != null) app.leaveHeader(false); - if (app.model.attentionCount() != 0 and GraphCanvas.hitTestAttentionRail(x, y, client.right)) { + if (GraphCanvas.attentionRailShown(&app.model, app.surface) and GraphCanvas.hitTestAttentionRail(x, y, client.right)) { app.handleAction(.cycle_attention); _ = c.InvalidateRect(hwnd, null, 0); result.* = 0; @@ -8757,15 +8795,9 @@ fn onWindowMessage( &app.sidebar_state, app.sidebar_scroll, )) |attention_index| { - if (attention_index < app.model.attention_entries.items.len) { - const entry = app.model.attention_entries.items[attention_index]; - if (app.selectProject(entry.project_path)) { - _ = app.model.setSelectedID(entry.node.id); - app.setStatus("Needs-you loop selected"); - app.syncAccessibility(); - _ = c.InvalidateRect(hwnd, null, 0); - } - } + if (app.chooseAttentionEntry(attention_index)) app.setStatus("Needs-you loop selected"); + app.syncAccessibility(); + _ = c.InvalidateRect(hwnd, null, 0); result.* = 0; return true; } @@ -10714,6 +10746,285 @@ test "graph refresh re-observes only the open loop's detached pane in the loop s try std.testing.expectEqual(@as(usize, 1), workspace.tabCount()); } +/// Reads the loop workspace's UIA surface the way the Dev Box harness does: the +/// `workspace-loop-bar` identity names the loop the bar shows, and the published +/// `workspace-show-graph` bounds are where a native click on the button lands. +const LoopBarProbe = struct { + var bar_loop: [64]u8 = undefined; + var bar_loop_len: usize = 0; + var has_bar = false; + var show_graph: ?c.RECT = null; + var sink: @This() = .{}; + + fn publish(app: *App) void { + has_bar = false; + bar_loop_len = 0; + show_graph = null; + app.syncAccessibilityTo(&sink, logicalClientRect(app.window.hwnd, app.dpi)); + } + fn barLoop() []const u8 { + return bar_loop[0..bar_loop_len]; + } + fn syncCanvasBounds(_: *@This(), _: c.RECT) void {} + fn syncElements(_: *@This(), _: []const u8, elements: []const Accessibility.DynamicElement, _: WorktreeStatus.Policy, _: Accessibility.WorktreeCapabilities) void { + const bar_prefix = "workspace-loop-bar:"; + for (elements) |element| { + if (std.mem.startsWith(u8, element.identity, bar_prefix)) { + const id = element.identity[bar_prefix.len..]; + bar_loop_len = @min(id.len, bar_loop.len); + @memcpy(bar_loop[0..bar_loop_len], id[0..bar_loop_len]); + 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 }; + } + } + } +}; + +fn showGraphFixtureFrame(app: *App, sequence: usize, a_state: []const u8, a_presence: []const u8) ![]u8 { + return std.fmt.allocPrint( + app.allocator, + "{{\"version\":2,\"kind\":\"event\",\"sequence\":{d},\"event\":{{\"graphChanged\":{{\"project\":{{\"path\":\"C:\\\\reopen-fixture\",\"name\":\"Reopen fixture\"}},\"nodes\":[" ++ + "{{\"id\":\"loop-a\",\"title\":\"Loop A\",\"loopType\":\"turnBased\",\"state\":{{\"{s}\":{{}}}},\"presence\":{{\"presence\":\"{s}\",\"confidence\":\"reported\"}}}}," ++ + "{{\"id\":\"loop-b\",\"title\":\"Loop B\",\"loopType\":\"turnBased\",\"state\":{{\"idle\":{{}}}}}}," ++ + "{{\"id\":\"loop-c\",\"title\":\"Loop C\",\"loopType\":\"turnBased\",\"state\":{{\"idle\":{{}}}}}}" ++ + "],\"edges\":[]}}}}}}", + .{ sequence, a_state, a_presence }, + ); +} + +fn deliverShowGraphFrame(app: *App, sequence: usize, a_state: []const u8, a_presence: []const u8) !void { + const frame = try showGraphFixtureFrame(app, sequence, a_state, a_presence); + defer app.allocator.free(frame); + app.onFrameWithAccessibilityPublish(frame, LoopBarProbe.publish); +} + +fn nativeClick(app: *App, x: i32, y: i32) !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)); + _ = onWindowMessage(app, app.window.hwnd, c.WM_LBUTTONUP, 0, lparam, &result); +} + +/// A short native drag on a canvas card: it moves the card and leaves it graph-selected +/// without opening it. +fn dragCanvasCard(app: *App, index: usize) !void { + const card = GraphCanvas.nodeBounds(index, &app.canvas); + var result: c.LRESULT = 0; + const start_x: u32 = @intCast(card.left + 20); + const start_y: u32 = @intCast(card.top + 20); + const end_x: u32 = start_x + 40; + const end_y: u32 = start_y + 30; + try std.testing.expect(onWindowMessage(app, app.window.hwnd, c.WM_LBUTTONDOWN, 0, @intCast(start_x | (start_y << 16)), &result)); + try std.testing.expect(app.canvas.node_dragging); + _ = onWindowMessage(app, app.window.hwnd, c.WM_MOUSEMOVE, c.MK_LBUTTON, @intCast(end_x | (end_y << 16)), &result); + try std.testing.expect(app.canvas.node_drag_started); + _ = onWindowMessage(app, app.window.hwnd, c.WM_LBUTTONUP, 0, @intCast(end_x | (end_y << 16)), &result); +} + +/// The loop bar (UIA identity and the selection its title is drawn from) and the visible +/// pane both name `node_id`. +fn expectLoopBarAndPane(app: *App, workspace: *TerminalWorkspace.Workspace, node_id: []const u8) !void { + try std.testing.expectEqual(GraphCanvas.Surface.workspace, app.surface); + LoopBarProbe.publish(app); + try std.testing.expect(LoopBarProbe.has_bar); + try std.testing.expectEqualStrings(node_id, LoopBarProbe.barLoop()); + const graph = workspaceGraph(&app.model) orelse return error.TestExpectedGraph; + const index = app.model.selectedIndex() orelse return error.TestExpectedSelection; + try std.testing.expectEqualStrings(node_id, graph.nodes.items[index].id); + try std.testing.expectEqualStrings(node_id, app.selected_node_id); + try expectPaneBoundTo(workspace, node_id); +} + +fn expectPaneBoundTo(workspace: *TerminalWorkspace.Workspace, node_id: []const u8) !void { + const tab = workspace.layout.selectedConst() orelse return error.TestExpectedTab; + try std.testing.expectEqualStrings(node_id, tab.panes.items[tab.focused_pane].id); + try std.testing.expect(workspace.isAwaitingLaunch(0)); + try std.testing.expectEqualStrings(node_id, workspace.launch_waits[0].session); +} + +fn needsYouRowY(app: *App) !i32 { + var y: i32 = Tokens.header_height; + while (y < 700) : (y += 1) { + const index = Sidebar.attentionRowAt(y, &app.model, app.currentWorktreeInspection(), &app.sidebar_state, app.sidebar_scroll) orelse continue; + if (index != 0) continue; + if (Sidebar.needsYouStopAt(80, y, &app.model, app.currentWorktreeInspection(), &app.sidebar_state, app.sidebar_scroll) != null) continue; + return y + 4; + } + return error.TestExpectedNeedsYouRow; +} + +fn needsYouStopPoint(app: *App) !c.POINT { + var y: i32 = Tokens.header_height; + while (y < 700) : (y += 1) { + var x: i32 = 0; + while (x < Tokens.sidebar_width) : (x += 2) { + const index = Sidebar.needsYouStopAt(x, y, &app.model, app.currentWorktreeInspection(), &app.sidebar_state, app.sidebar_scroll) orelse continue; + if (index == 0) return .{ .x = x + 2, .y = y + 2 }; + } + } + return error.TestExpectedNeedsYouStop; +} + +const ShowGraphFixture = struct { + workspace: TerminalWorkspace.Workspace, + app: App, + directory: []u8, + layout_path: []u8, + tmp: std.testing.TmpDir, + sequence: usize = 2, + + /// Core project with loops A, B, C; A stopped after spending a turn, so it is the + /// one "Needs you" entry, and a short canvas drag left A graph-selected. + fn init(self: *ShowGraphFixture) !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.base_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)); + try std.testing.expect(app.selectNodeIndex(1)); + 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(1200, app.dpi), physicalCoordinate(700, app.dpi), 0) != 0); + + try deliverShowGraphFrame(app, self.sequence, "stopped", "awaitingInput"); + self.sequence += 1; + try std.testing.expectEqual(@as(usize, 1), app.model.attentionCount()); + try std.testing.expectEqualStrings("loop-a", app.model.attention_entries.items[0].node.id); + + const before = app.client.outbound_count; + try dragCanvasCard(app, 0); + try std.testing.expectEqual(GraphCanvas.Surface.project, app.surface); + try std.testing.expectEqualStrings("loop-a", app.model.selected().?.id); + try std.testing.expectEqual(before, app.client.outbound_count); + } + + fn deinit(self: *ShowGraphFixture) void { + self.app.workspace = null; + deinitOverviewTestApp(&self.app); + self.workspace.deinit(); + std.testing.allocator.free(self.layout_path); + std.testing.allocator.free(self.directory); + self.tmp.cleanup(); + } + + 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); + try expectOneResumePerClick(&self.app, before, node_id); + try expectLoopBarAndPane(&self.app, &self.workspace, node_id); + } +}; + +test "Show in Graph from an open loop shows the graph and never moves the loop bar to the Needs-you loop" { + var fixture: ShowGraphFixture = undefined; + try fixture.init(); + defer fixture.deinit(); + const app = &fixture.app; + + for ([_][]const u8{ "loop-b", "loop-c" }) |node_id| { + try fixture.openFromSidebar(node_id); + try std.testing.expect(app.workspace_controls.panel_visible); + const button = LoopBarProbe.show_graph orelse return error.TestExpectedShowGraphButton; + // The Dev Box geometry: the button sits inside the graph's hidden Needs-you rail. + try std.testing.expect(GraphCanvas.hitTestAttentionRail( + @divTrunc(button.left + button.right, 2), + @divTrunc(button.top + button.bottom, 2), + logicalClientRect(app.window.hwnd, app.dpi).right, + )); + const before = app.client.outbound_count; + try nativeClick(app, @divTrunc(button.left + button.right, 2), @divTrunc(button.top + button.bottom, 2)); + + // Whatever surface the click leaves, the bar must never name another loop. + if (app.surface == .workspace) try expectLoopBarAndPane(app, &fixture.workspace, node_id); + try std.testing.expectEqual(GraphCanvas.Surface.project, app.surface); + try std.testing.expect(!app.workspace_controls.panel_visible); + try std.testing.expectEqualStrings(node_id, app.model.selected().?.id); + try std.testing.expectEqualStrings(node_id, app.selected_node_id); + try std.testing.expectEqual(before, app.client.outbound_count); + try expectPaneBoundTo(&fixture.workspace, node_id); + LoopBarProbe.publish(app); + try std.testing.expect(!LoopBarProbe.has_bar); + } +} + +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(); + defer fixture.deinit(); + try fixture.openFromSidebar("loop-b"); + fixture.app.handleWorkspaceKeyRoute(App.dispatchWorkspaceKey(c.VK_TAB, true, false, true)); + try expectLoopBarAndPane(&fixture.app, &fixture.workspace, "loop-a"); +} + +test "selection routes with a loop workspace open: Review What Needs You menu moves the loop bar and pane together" { + var fixture: ShowGraphFixture = undefined; + try fixture.init(); + defer fixture.deinit(); + const app = &fixture.app; + try fixture.openFromSidebar("loop-b"); + var result: c.LRESULT = 0; + try std.testing.expect(onWindowMessage(app, app.window.hwnd, c.WM_COMMAND, @intFromEnum(MainWindow.Command.review_attention), 0, &result)); + try expectLoopBarAndPane(app, &fixture.workspace, "loop-a"); +} + +test "selection routes with a loop workspace open: Needs-you row click moves the loop bar and pane together" { + var fixture: ShowGraphFixture = undefined; + try fixture.init(); + defer fixture.deinit(); + const app = &fixture.app; + try fixture.openFromSidebar("loop-b"); + try nativeClick(app, 80, try needsYouRowY(app)); + try expectLoopBarAndPane(app, &fixture.workspace, "loop-a"); +} + +test "selection routes with a loop workspace open: Needs-you row UIA invoke moves the loop bar and pane together" { + var fixture: ShowGraphFixture = undefined; + try fixture.init(); + defer fixture.deinit(); + const app = &fixture.app; + try fixture.openFromSidebar("loop-b"); + try std.testing.expect(app.applyUiaDynamicInvoke(Accessibility.worktreeIdentityPayload("needs-you-row:C:\\reopen-fixture:loop-a"))); + try expectLoopBarAndPane(app, &fixture.workspace, "loop-a"); +} + +test "selection routes with a loop workspace open: Next and Previous Loop move the loop bar and pane together" { + var fixture: ShowGraphFixture = undefined; + try fixture.init(); + defer fixture.deinit(); + const app = &fixture.app; + try fixture.openFromSidebar("loop-b"); + var result: c.LRESULT = 0; + try std.testing.expect(onWindowMessage(app, app.window.hwnd, c.WM_COMMAND, @intFromEnum(MainWindow.Command.next_loop), 0, &result)); + try expectLoopBarAndPane(app, &fixture.workspace, "loop-c"); + try std.testing.expect(onWindowMessage(app, app.window.hwnd, c.WM_COMMAND, @intFromEnum(MainWindow.Command.previous_loop), 0, &result)); + try expectLoopBarAndPane(app, &fixture.workspace, "loop-b"); +} + +test "selection routes with a loop workspace open: Needs-you Stop leaves the open loop's bar and pane alone" { + var fixture: ShowGraphFixture = undefined; + try fixture.init(); + defer fixture.deinit(); + const app = &fixture.app; + try fixture.openFromSidebar("loop-b"); + const stop = try needsYouStopPoint(app); + const before = app.client.outbound_count; + try nativeClick(app, stop.x, stop.y); + try std.testing.expectEqual(before + 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-a") != null); + try expectLoopBarAndPane(app, &fixture.workspace, "loop-b"); +} + test "gesture routing requires a graph-capable surface, not only the canvas rectangle" { // This is the exact helper the real WM_GESTURE handler calls -- proving // routing here is proving the production path, not a parallel reimplementation. diff --git a/graphcode-windows/src/GraphCanvas.zig b/graphcode-windows/src/GraphCanvas.zig index 8bf26bc6..83575d5c 100644 --- a/graphcode-windows/src/GraphCanvas.zig +++ b/graphcode-windows/src/GraphCanvas.zig @@ -589,6 +589,13 @@ pub fn hitTestAttentionRail(x: i32, y: i32, width: i32) bool { return insideGraph(x, y, attentionRailBounds(width)); } +/// The rail belongs to the graph surfaces. A loop workspace paints its loop bar over the +/// same strip, so there it is neither drawn nor clickable: a click on the bar's Show in +/// Graph button must never land on the rail's Review action. +pub fn attentionRailShown(model: *const GraphModel.Model, surface: Surface) bool { + return surface != .workspace and model.attentionCount() != 0; +} + 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); } @@ -734,7 +741,7 @@ pub fn paint( if (surface != .workspace) drawZoomControls(hdc, allocator, graph_bounds, state); _ = c.RestoreDC(hdc, saved); - attentionRail(hdc, allocator, model, client.right); + if (attentionRailShown(model, surface)) attentionRail(hdc, allocator, model, client.right); if (controls.activity_enabled) { const workspace_height: i32 = if (surface == .workspace) 0 else if (controls.panel_visible) Tokens.workspace_height else 0; activityStrip( diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index b697be46..f8a89665 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -83,7 +83,7 @@ process dump and child-process inventory before shared product changes. | macOS surface | Required visible behavior | Windows evidence | Status | |---|---|---|---| -| Main split view | Persistent sidebar; detail switches among welcome, global graph, project canvas, Quick Chats canvas, and loop workspace | Explicit destinations exist; the shared header follows destination identity, distinguishes a Quick Chat workspace from a selected project, and keeps detail-panel toggling inside the loop workspace. Production-state and layout tests cover destinations, narrow widths, and sidebar/panel independence. Native-linked App regressions now cover a scrolled, Worktrees-populated sidebar with Activity present while a stopped loop workspace is open: the visible row, canvas card, command action, and UIA identity all select the same exact project/node and queue one resume request, while unrelated stopped loops remain unchanged. This is unit/native-message evidence, not a replacement for a new installed Dev Box attended-launch walkthrough. The beta15 Dev Box rerun then found that reopening a stopped loop after switching to another left the other loop's session in a stray, selected tab: every graph change bound the graph's first two loops to terminal slots by graph order. That binding is now limited to the smoke gate; a graph change re-observes only the open loop's own detached pane. An App regression drives native sidebar clicks A → B → A (A stopped) with graph changes arriving and asserts one resume per click, one tab whose pane is the reopened loop, no other loop bound beside it, and UIA tab count equal to drawn tabs. A local, uncommitted real winghostty/zmx run of the same click path reproduced the stray tab before the fix and showed only A's session, in the single visible pane, after it. Neither replaces the installed Dev Box rerun. The green Windows shell CI run 36489223062 acquired `graphcode-root` from a visible background shell, then drove Graph → project canvas via the painted lane Open action → Graph, opened Worktrees from the lane, opened Quick Chats and a Quick Chat terminal workspace, opened a selected-loop workspace, invoked Show in Graph, and returned through the same stable sidebar loop identity to the same project/workspace identities. This is live UIA plus real click/invocation evidence for destination and row identity, not a visual walkthrough. The macOS evidence observed welcome, global graph, Quick Chats, project, nested graph, and persistent sidebar, but did not reach terminal detail; Windows CI did not cover welcome/onboarding, narrow-width rendering, real provider content, or a visual sidebar/layout comparison. The full cross-platform row therefore remains unverified | Partial | +| Main split view | Persistent sidebar; detail switches among welcome, global graph, project canvas, Quick Chats canvas, and loop workspace | Explicit destinations exist; the shared header follows destination identity, distinguishes a Quick Chat workspace from a selected project, and keeps detail-panel toggling inside the loop workspace. Production-state and layout tests cover destinations, narrow widths, and sidebar/panel independence. Native-linked App regressions now cover a scrolled, Worktrees-populated sidebar with Activity present while a stopped loop workspace is open: the visible row, canvas card, command action, and UIA identity all select the same exact project/node and queue one resume request, while unrelated stopped loops remain unchanged. This is unit/native-message evidence, not a replacement for a new installed Dev Box attended-launch walkthrough. The beta15 Dev Box rerun then found that reopening a stopped loop after switching to another left the other loop's session in a stray, selected tab: every graph change bound the graph's first two loops to terminal slots by graph order. That binding is now limited to the smoke gate; a graph change re-observes only the open loop's own detached pane. An App regression drives native sidebar clicks A → B → A (A stopped) with graph changes arriving and asserts one resume per click, one tab whose pane is the reopened loop, no other loop bound beside it, and UIA tab count equal to drawn tabs. A local, uncommitted real winghostty/zmx run of the same click path reproduced the stray tab before the fix and showed only A's session, in the single visible pane, after it. Neither replaces the installed Dev Box rerun. The beta16 Dev Box rerun then found that, with a stopped loop in Needs you, Show in Graph from another loop's workspace left the graph hidden and moved the loop bar to the Needs-you loop while the pane stayed on the open loop: the graph's hidden "loops need you" rail overlaps the loop bar's Show in Graph button and was hit-tested first, so the click ran Review attention, which only re-selected. The rail is now neither drawn nor clickable in a loop workspace, and with a workspace open the Review attention key and menu, the Needs-you row (pointer and UIA), and Next/Previous Loop open their target through the activation path instead of only selecting. App regressions click the UIA-published Show in Graph bounds from B and C after a canvas drag on the Needs-you loop and assert the graph is shown with the open loop still selected, and drive each of those routes asserting the loop bar's UIA identity, the drawn selection, and the visible pane name the same loop; a Needs-you Stop leaves the open loop alone. This is unit/native-message evidence, not an installed Dev Box rerun. The green Windows shell CI run 36489223062 acquired `graphcode-root` from a visible background shell, then drove Graph → project canvas via the painted lane Open action → Graph, opened Worktrees from the lane, opened Quick Chats and a Quick Chat terminal workspace, opened a selected-loop workspace, invoked Show in Graph, and returned through the same stable sidebar loop identity to the same project/workspace identities. This is live UIA plus real click/invocation evidence for destination and row identity, not a visual walkthrough. The macOS evidence observed welcome, global graph, Quick Chats, project, nested graph, and persistent sidebar, but did not reach terminal detail; Windows CI did not cover welcome/onboarding, narrow-width rendering, real provider content, or a visual sidebar/layout comparison. The full cross-platform row therefore remains unverified | Partial | | Window toolbar | Needs-you chip, worktree notice, jump field, contextual loop-panel toggle | Render, hit-test, UIA exposure, and focus order share header visibility/layout. Needs-you opens the selected attention target; cached local worktree notices honor the owning project and count/size threshold. Jump opens the existing palette even with no projects. The detail toggle is limited to loop workspaces with supported edge/metric content; workspace/project identity is no longer overpainted. F6 entry/exit, scoped traversal/activation, focus rendering, and marshaled UIA button focus are implemented, while ordinary Tab/Shift+Tab/Ctrl+Tab contracts remain. The header paints inside the buffered logical pass; literal 96/144/192-DPI bounds and hidden native client tests cover logical layout/input and exactly-once physical UIA conversion without rescaling terminal tabs. The green Windows shell CI run 36489223062 provided live evidence for workspace toolbar identity, Jump filtering and Return navigation, and the contextual loop-panel Collapse/Expand control with non-empty bounds; it also exercised real foreground focus elsewhere in the shell. The macOS runtime evidence observed the named title, Jump, and the 8-versus-7 worktree-notice boundary, but not Needs You or the loop panel. Windows CI did not exercise the notice boundary or a populated Needs You chip, and neither side supplied a complete rendered-toolbar comparison or provider-backed panel-content walkthrough. Those are user-visible parts of this row, so it remains `Partial`; the separately owned Worktree notice chip row carries its own evidence | Partial | | Jump palette | Search field, ranked cross-project results, type/state/project context, mouse and keyboard selection | Ctrl+P and Ctrl+J open a native modal palette with live exact-ID, exact-title, title-prefix, and substring ranking across projects. Results visibly include project, loop type, and state; Up/Down, Return, Escape, and mouse double-click are supported. The deterministic UIA gate verifies a visible search field, contextual cross-project results, and keyboard navigation changing the selected loop. | Validated | | File/Loop/Terminal menus | Discoverable project, worktree, navigation, workspace, update, settings, and help commands with state-aware enablement | Startup menu replacement and UTF-16 corruption are fixed. Reclaim/Reveal require a selected row; Save requires the Worktrees dialog open. Worktrees inspection and reclaim now run on owned background workers; while provider work is active the native File menu, project/background menus, and fixed UIA action patterns derive a busy capability instead of advertising a second invocation. The fixed UIA action nodes are offscreen with empty bounds rather than sharing the visible Graph rectangle. Loop commands use distinct actionable-target thresholds, and Terminal tab/pane commands require multiple tabs or panes. Hidden-window tests call `MainWindow.updateMenu` and verify the real native HMENU `MF_GRAYED` bits in unavailable and available states. The green Windows shell CI run 36489223062 inspected the live File > Add Folder and Recent Folders HMENU labels/order, invoked a real Recent Folders command without terminating the shell, and exercised live Help/update enablement transitions; its workspace walkthrough also created and switched mounted tabs. The new Worktrees busy/UIA behavior has App/native-linked unit coverage but no installed Dev Box walkthrough yet. Windows CI did not read the complete live Loop/Terminal menu states in equivalent graph and terminal contexts or exercise their keyboard commands. Unit HMENU coverage and adjacent workspace actions cannot substitute for that missing user-visible walkthrough, so the row remains `Partial` | Partial | From f1a66dff5d135d64627a1161231981aceecc9bff Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Wed, 7 Oct 2026 23:18:37 -0700 Subject: [PATCH 2/2] Prove Show in Graph over the hidden attention rail in the live UIA gate The gate's attention rail step selected loop A's card, which opens A's loop workspace, and then clicked the rail strip. In a workspace that strip is under the loop bar, so the step was asserting the beta16 defect: the click cycled the selection onto the NEEDS YOU card while A's pane stayed open. The step now clicks the workspace's own Show in Graph button (whose bounds lie inside the rail rect) and requires the loop bar to go away with A still selected, then performs the existing rail click on the graph surface, where the rail is visible, and requires it to cycle onto the NEEDS YOU card. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/uia-live-gate.ps1 | 50 +++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 3ee6c382..f8dd9440 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -5753,6 +5753,56 @@ try { Start-Sleep -Milliseconds 150 Require ($attentionSelection0.Current.IsSelected -and (-not $attentionSelection1.Current.IsSelected)) ` "could not establish a deterministic starting selection before the attention rail check" + + # Selecting a card opens its loop workspace, whose loop bar is painted over the rail's + # strip; the bar's Show in Graph button lies inside the rail rect. A real click on that + # button must show the graph with the open loop still selected - never act as the + # hidden rail and cycle onto the NEEDS YOU card while the pane stays put (Dev Box + # beta16, Section 6B, LoopActivation). + $railWorkspace = Wait-ForGraphChildren $root $rawWalker ` + { $_.Current.AutomationId -match '^workspace-(loop-bar|show-graph)-' } ` + { param($items) @($items | Where-Object { $_.Current.AutomationId -match '^workspace-show-graph-' }).Count -eq 1 } + $railShowGraph = @($railWorkspace.Items | Where-Object { + $_.Current.AutomationId -match '^workspace-show-graph-' -and $_.Current.Name -eq "Show in Graph" + }) | Select-Object -First 1 + Require ($null -ne $railShowGraph) "selecting a loop card did not open its workspace before the attention rail check" + $railShowGraphBounds = $railShowGraph.Current.BoundingRectangle + Require (($railShowGraphBounds.Width -gt 0) -and ($railShowGraphBounds.Height -gt 0)) ` + "workspace Show in Graph had empty bounds before the attention rail check" + $process.Refresh() + $shellWindow = $process.MainWindowHandle + $showGraphClientX = 0 + $showGraphClientY = 0 + Require ([GraphCodeUiaGateState]::ScreenToClientPoint( + $shellWindow, + [int](($railShowGraphBounds.Left + $railShowGraphBounds.Right) / 2), + [int](($railShowGraphBounds.Top + $railShowGraphBounds.Bottom) / 2), + [ref]$showGraphClientX, [ref]$showGraphClientY + )) "could not map workspace Show in Graph to client coordinates" + $null = Ensure-ShellForeground $shellWindow "before-show-in-graph-over-attention-rail" + Require ([GraphCodeUiaGateState]::PostMouseButtonAt($shellWindow, 0x0201, $showGraphClientX, $showGraphClientY)) ` + "workspace Show in Graph click was rejected" + [GraphCodeUiaGateState]::PostMouseButtonAt($shellWindow, 0x0202, $showGraphClientX, $showGraphClientY) | Out-Null + $railGraphProbe = Wait-ForGraphChildren $root $rawWalker ` + { $_.Current.AutomationId -match '^(workspace-loop-bar-|canvas-card-)' } ` + { param($items) + (@($items | Where-Object { $_.Current.AutomationId -match '^workspace-loop-bar-' }).Count -eq 0) -and + (@($items | Where-Object { $_.Current.AutomationId -match '^canvas-card-' -and $_.Current.Name -in @("UIA loop A", "UIA loop B") }).Count -eq 2) } + Require (@($railGraphProbe.Items | Where-Object { $_.Current.AutomationId -match '^workspace-loop-bar-' }).Count -eq 0) ` + "Show in Graph over the hidden attention rail did not leave the loop workspace" + $graph = $railGraphProbe.Graph + $projectCards = @($railGraphProbe.Items | Where-Object { + $_.Current.AutomationId -match '^canvas-card-' -and $_.Current.Name -in @("UIA loop A", "UIA loop B") + }) + Require (($projectCards.Count -eq 2) -and + ((@($projectCards | ForEach-Object { $_.Current.Name }) -join "|") -eq "UIA loop A|UIA loop B")) ` + "Graph did not re-expose the project cards after Show in Graph" + $attentionSelection0 = $projectCards[0].GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern) + $attentionSelection1 = $projectCards[1].GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern) + Require ($attentionSelection0.Current.IsSelected -and (-not $attentionSelection1.Current.IsSelected)) ` + "Show in Graph over the hidden attention rail moved the selection onto the NEEDS YOU card" + + # On the graph surface the rail is visible and owns its strip. $railScreenX = [int](($graph.Current.BoundingRectangle.Left + $graph.Current.BoundingRectangle.Right) / 2) $railScreenY = [int]$graph.Current.BoundingRectangle.Top + 27 $railClientX = 0