From 8ad4d8ad6ab55c90b066180279f35dbeb63555e0 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 18 Sep 2026 14:51:10 -0700 Subject: [PATCH 01/17] Polish Windows loop terminal workspace chrome Add workspace-owned toolbar identity, loop metadata, tab affordances, split/pane detail, stable UIA children, and focused gate coverage. Keep the graph-canvas-owned right rail deferred and record the Windows validation blocker. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d8d2ad1c-e76d-40e5-850a-21a4e72a19ac --- Tools/windows/uia-live-gate.ps1 | 16 ++ .../src/AccessibilityProvider.cpp | 9 ++ graphcode-windows/src/App.zig | 101 +++++++++++- graphcode-windows/src/GraphModel.zig | 41 +++++ graphcode-windows/src/TerminalSurface.zig | 153 +++++++++++++++--- investigation/ui-parity-matrix.md | 16 +- 6 files changed, 301 insertions(+), 35 deletions(-) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 8a7d0aa2..1bc2e366 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -630,6 +630,22 @@ try { ((@($workspaceCards | ForEach-Object { $_.Current.Name }) -join "|") -eq "UIA loop A|UIA loop B") -and $workspaceCards[0].GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern).Current.IsSelected) ` "loop invocation did not transition to the selected workspace loop" + $workspaceToolbar = @(Get-DirectChildren $graph $rawWalker | Where-Object { + $_.Current.AutomationId -match '^workspace-toolbar-' -and $_.Current.Name -eq "UIA project" + }) | Select-Object -First 1 + $workspaceShowGraph = @(Get-DirectChildren $graph $rawWalker | Where-Object { + $_.Current.AutomationId -match '^workspace-show-graph-' -and $_.Current.Name -eq "Show in Graph" + }) | Select-Object -First 1 + $workspaceTabs = @(Get-DirectChildren $graph $rawWalker | Where-Object { + $_.Current.AutomationId -match '^workspace-tab-' -and $_.Current.Name -match 'tab$' + }) + $workspaceControls = @(Get-DirectChildren $graph $rawWalker | Where-Object { + $_.Current.AutomationId -match '^workspace-(new-tab|split-right|split-down)-' -and + $_.Current.Name -in @("New Tab", "Split Right", "Split Down") + }) + Require (($null -ne $workspaceToolbar) -and ($null -ne $workspaceShowGraph) -and + ($workspaceTabs.Count -ge 1) -and ($workspaceControls.Count -eq 3)) ` + "workspace chrome omitted toolbar identity, Show in Graph, tab, or split controls" $surfaceActionPatterns["overview-destination"].Invoke() Start-Sleep -Milliseconds 150 Require ([GraphCodeUiaGateState]::PostTaggedExitCollision($process.MainWindowHandle)) ` diff --git a/graphcode-windows/src/AccessibilityProvider.cpp b/graphcode-windows/src/AccessibilityProvider.cpp index e49f70f9..4184947b 100644 --- a/graphcode-windows/src/AccessibilityProvider.cpp +++ b/graphcode-windows/src/AccessibilityProvider.cpp @@ -780,6 +780,15 @@ class Node final : public IRawElementProviderSimple, row.identity.rfind("quick-chats-disclosure:", 0) == 0 ? L"quick-chats-disclosure-" : row.identity.rfind("quick-chat-row:", 0) == 0 ? L"quick-chat-row-" : row.identity.rfind("loop-disclosure:", 0) == 0 ? L"loop-disclosure-" : + row.identity.rfind("workspace-toolbar:", 0) == 0 ? L"workspace-toolbar-" : + row.identity.rfind("workspace-loop-bar:", 0) == 0 ? L"workspace-loop-bar-" : + row.identity.rfind("workspace-show-graph:", 0) == 0 ? L"workspace-show-graph-" : + row.identity.rfind("workspace-stop:", 0) == 0 ? L"workspace-stop-" : + row.identity.rfind("workspace-tab-close:", 0) == 0 ? L"workspace-tab-close-" : + row.identity.rfind("workspace-tab:", 0) == 0 ? L"workspace-tab-" : + row.identity.rfind("workspace-new-tab:", 0) == 0 ? L"workspace-new-tab-" : + row.identity.rfind("workspace-split-right:", 0) == 0 ? L"workspace-split-right-" : + row.identity.rfind("workspace-split-down:", 0) == 0 ? L"workspace-split-down-" : parent == 1 ? L"project-row-" : parent == 2 ? L"loop-row-" : parent == 3 ? L"worktree-row-" : L"canvas-card-"; diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 540d7951..58bf19bd 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -147,6 +147,13 @@ const UiaDynamicTarget = union(enum) { active_loop: usize, composite_back, quick_chat: []const u8, + workspace_show_graph, + workspace_stop, + workspace_new_tab, + workspace_split_right, + workspace_split_down, + workspace_tab: usize, + workspace_tab_close: usize, }; pub const App = struct { @@ -3107,6 +3114,35 @@ pub const App = struct { 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 (self.surface == .workspace) { + if (self.workspace) |workspace| { + const workspace_left = if (self.workspace_controls.rail_visible) Tokens.sidebar_width else 0; + const workspace_right = client.right - Tokens.loop_detail_width; + const selected_index = self.model.selectedIndex() orelse 0; + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-toolbar", graph.project.path, graph.project.name, 4, .{ .left = workspace_left, .top = 0, .right = workspace_right, .bottom = Tokens.header_height }, false, false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-loop-bar", if (selected_index < graph.nodes.items.len) graph.nodes.items[selected_index].id else "none", "Selected loop workspace", 4, .{ .left = workspace_left, .top = Tokens.header_height, .right = workspace_right, .bottom = Tokens.header_height + Tokens.loop_bar_height }, false, false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-show-graph", "show-graph", "Show in Graph", 4, .{ .left = workspace_right - 104, .top = Tokens.header_height + 10, .right = workspace_right - 12, .bottom = Tokens.header_height + 36 }, false, false) catch return; + if (selected_index < graph.nodes.items.len and !isResolvedLoopState(graph.nodes.items[selected_index].state)) { + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-stop", graph.nodes.items[selected_index].id, "Stop loop", 4, .{ .left = workspace_right - 196, .top = Tokens.header_height + 10, .right = workspace_right - 112, .bottom = Tokens.header_height + 36 }, false, false) catch return; + } + for (workspace.layout.tabs.items, 0..) |tab, tab_index| { + const tab_key = std.fmt.allocPrint(self.allocator, "{d}", .{tab_index}) catch return; + defer self.allocator.free(tab_key); + const tab_left = workspace.layout_origin_x + @as(i32, @intCast(tab_index)) * 120; + const tab_bounds = c.RECT{ .left = tab_left, .top = workspace.layout_origin_y + 4, .right = tab_left + 112, .bottom = workspace.layout_origin_y + Tokens.tab_bar_height - 4 }; + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-tab", tab_key, if (tab.panes.items.len > 1) "Split tab" else if (tab_index == 0) "Agent tab" else "Shell tab", 4, tab_bounds, tab_index == workspace.layout.selected_tab, true) catch return; + const close_key = std.fmt.allocPrint(self.allocator, "{d}", .{tab_index}) catch return; + defer self.allocator.free(close_key); + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-tab-close", close_key, "Close tab", 4, .{ .left = tab_bounds.right - 24, .top = tab_bounds.top, .right = tab_bounds.right, .bottom = tab_bounds.bottom }, false, workspace.canCloseTab()) catch return; + } + const controls_left = @max(workspace.layout_origin_x, workspace.layout_origin_x + workspace.layout_width - 220); + for ([_][]const u8{ "New Tab", "Split Right", "Split Down" }, 0..) |label, control_index| { + const control_left = controls_left + @as(i32, @intCast(control_index)) * 72; + const kind = if (control_index == 0) "workspace-new-tab" else if (control_index == 1) "workspace-split-right" else "workspace-split-down"; + self.appendAccessibilityElement(&elements, &owned_identities, kind, "control", label, 4, .{ .left = control_left, .top = workspace.layout_origin_y + 3, .right = control_left + 68, .bottom = workspace.layout_origin_y + Tokens.tab_bar_height - 3 }, false, true) catch return; + } + } + } }, .overview => for (self.model.graphs.items, 0..) |graph, graph_index| { for (graph.nodes.items, 0..) |node, node_index| { @@ -3284,6 +3320,45 @@ pub const App = struct { target = .{ .quick_chat = chat.id }; } } + const workspace_static = [_]struct { identity: []const u8, target: UiaDynamicTarget }{ + .{ .identity = "workspace-show-graph:show-graph", .target = .workspace_show_graph }, + .{ .identity = "workspace-new-tab:control", .target = .workspace_new_tab }, + .{ .identity = "workspace-split-right:control", .target = .workspace_split_right }, + .{ .identity = "workspace-split-down:control", .target = .workspace_split_down }, + }; + for (workspace_static) |candidate| { + if (Accessibility.worktreeIdentityPayload(candidate.identity) == payload) { + if (target != null) return false; + target = candidate.target; + } + } + if (self.surface == .workspace) { + if (self.model.selectedNodeID()) |node_id| { + const identity = std.fmt.allocPrint(self.allocator, "workspace-stop:{s}", .{node_id}) catch return false; + defer self.allocator.free(identity); + if (Accessibility.worktreeIdentityPayload(identity) == payload) { + if (target != null) return false; + target = .workspace_stop; + } + } + if (self.workspace) |workspace| { + for (workspace.layout.tabs.items, 0..) |_, tab_index| { + const key = std.fmt.allocPrint(self.allocator, "{d}", .{tab_index}) catch return false; + defer self.allocator.free(key); + const tab_identity = std.fmt.allocPrint(self.allocator, "workspace-tab:{s}", .{key}) catch return false; + defer self.allocator.free(tab_identity); + const close_identity = std.fmt.allocPrint(self.allocator, "workspace-tab-close:{s}", .{key}) catch return false; + defer self.allocator.free(close_identity); + if (Accessibility.worktreeIdentityPayload(tab_identity) == payload) { + if (target != null) return false; + target = .{ .workspace_tab = tab_index }; + } else if (Accessibility.worktreeIdentityPayload(close_identity) == payload) { + if (target != null) return false; + target = .{ .workspace_tab_close = tab_index }; + } + } + } + } const resolved = target orelse return false; switch (resolved) { .local_section => self.sidebar_state.local_collapsed = !self.sidebar_state.local_collapsed, @@ -3327,6 +3402,13 @@ pub const App = struct { self.client.sendOpenQuickChat(id); self.setStatus("Opening quick chat..."); }, + .workspace_show_graph => self.handleAction(.show_graph), + .workspace_stop => self.stopSelectedNode(), + .workspace_new_tab => self.handleAction(.new_tab), + .workspace_split_right => self.handleAction(.split_horizontal), + .workspace_split_down => self.handleAction(.split_vertical), + .workspace_tab => |index| if (self.workspace) |workspace| workspace.selectTab(index) catch return false, + .workspace_tab_close => |index| if (self.workspace) |workspace| workspace.closeTab(index) catch return false, } self.clampSidebarScroll(); self.syncAccessibility(); @@ -3644,6 +3726,14 @@ fn onWindowMessage( if (app.workspace_controls.panel_visible or app.surface == .workspace) { if (app.surface == .workspace) { if (workspaceGraph(&app.model)) |graph| { + TerminalWorkspace.Workspace.paintWorkspaceToolbar( + hdc, + app.allocator, + if (app.workspace_controls.rail_visible) Tokens.sidebar_width else 0, + clientRight(hwnd) - Tokens.loop_detail_width, + graph.project.name, + graph.project.path, + ); const index = app.model.selectedIndex() orelse 0; if (index < graph.nodes.items.len) { const node = graph.nodes.items[index]; @@ -3657,6 +3747,10 @@ fn onWindowMessage( node.loop_type, node.state, node.activity, + node.backend, + node.created_at, + node.metric_passes, + node.token_usage, isResolvedLoopState(node.state), ); } @@ -3920,7 +4014,12 @@ fn onWindowMessage( result.* = 0; return true; } - if (workspace.selectTabAt(x, y)) { + if (workspace.tabActionAt(x, y)) |tab_action| { + switch (tab_action.action) { + .select => workspace.selectTab(tab_action.index) catch {}, + .close => workspace.closeTab(tab_action.index) catch {}, + } + _ = c.InvalidateRect(hwnd, null, 0); result.* = 0; return true; } diff --git a/graphcode-windows/src/GraphModel.zig b/graphcode-windows/src/GraphModel.zig index 8b35c579..03cc28aa 100644 --- a/graphcode-windows/src/GraphModel.zig +++ b/graphcode-windows/src/GraphModel.zig @@ -9,6 +9,7 @@ pub const Node = struct { state: []u8, activity: []u8, presence: []u8, + backend: []u8 = &.{}, pilot_state: []u8 = &.{}, goal_summary: []u8 = &.{}, goal_predicate: []u8 = &.{}, @@ -19,6 +20,9 @@ pub const Node = struct { model_tier: []u8 = &.{}, poll_interval_seconds: ?f64 = null, stall_after_seconds: ?f64 = null, + created_at: ?u64 = null, + metric_passes: u32 = 0, + token_usage: ?u32 = null, worktree_path: []u8 = @constCast(""), worktree_branch: []u8 = &.{}, subgraph_json: []u8 = &.{}, @@ -976,6 +980,7 @@ fn cloneNode(allocator: std.mem.Allocator, node: Node) !Node { .state = try allocator.dupe(u8, node.state), .activity = try allocator.dupe(u8, node.activity), .presence = try allocator.dupe(u8, node.presence), + .backend = try allocator.dupe(u8, node.backend), .pilot_state = try allocator.dupe(u8, node.pilot_state), .goal_summary = try allocator.dupe(u8, node.goal_summary), .goal_predicate = try allocator.dupe(u8, node.goal_predicate), @@ -986,6 +991,9 @@ fn cloneNode(allocator: std.mem.Allocator, node: Node) !Node { .model_tier = try allocator.dupe(u8, node.model_tier), .poll_interval_seconds = node.poll_interval_seconds, .stall_after_seconds = node.stall_after_seconds, + .created_at = node.created_at, + .metric_passes = node.metric_passes, + .token_usage = node.token_usage, .worktree_path = try allocator.dupe(u8, node.worktree_path), .worktree_branch = try allocator.dupe(u8, node.worktree_branch), .subgraph_json = try allocator.dupe(u8, node.subgraph_json), @@ -1037,6 +1045,7 @@ fn decodeNodes( .state = try duplicateJsonStringOr(allocator, scalar_object, "state", "idle"), .activity = try duplicateJsonStringOr(allocator, scalar_object, "activity", ""), .presence = try duplicatePresence(allocator, scalar_object), + .backend = try duplicateJsonStringOr(allocator, scalar_object, "backend", ""), .pilot_state = try duplicateJsonStringOr(allocator, scalar_object, "pilotState", "notPiloted"), .goal_summary = try duplicateJsonStringOr(allocator, scalar_object, "summary", ""), .goal_predicate = try duplicateJsonStringOr(allocator, scalar_object, "predicate", ""), @@ -1047,6 +1056,9 @@ fn decodeNodes( .model_tier = try duplicateJsonStringOr(allocator, scalar_object, "modelTier", ""), .poll_interval_seconds = jsonFloat(scalar_object, "pollIntervalSeconds"), .stall_after_seconds = jsonFloat(scalar_object, "stallAfterSeconds"), + .created_at = jsonNumber64(scalar_object, "createdAt"), + .metric_passes = jsonArrayObjectCount(scalar_object, "metricHistory"), + .token_usage = jsonUsageTotal(scalar_object), .worktree_path = try duplicateWorktreePath(allocator, scalar_object), .worktree_branch = try duplicateWorktreeBranch(allocator, scalar_object), .subgraph_json = try duplicateJsonObjectOrEmpty(allocator, object, "subGraph"), @@ -1100,6 +1112,34 @@ fn jsonNumber(object: []const u8, key: []const u8) ?u32 { return std.fmt.parseInt(u32, value[0..end], 10) catch null; } +fn jsonNumber64(object: []const u8, key: []const u8) ?u64 { + const needle = std.fmt.allocPrint(std.heap.page_allocator, "\"{s}\":", .{key}) catch return null; + defer std.heap.page_allocator.free(needle); + const start = std.mem.indexOf(u8, object, needle) orelse return null; + const value = std.mem.trimLeft(u8, object[start + needle.len ..], " "); + var end: usize = 0; + while (end < value.len and value[end] >= '0' and value[end] <= '9') : (end += 1) {} + if (end == 0) return null; + return std.fmt.parseInt(u64, value[0..end], 10) catch null; +} + +fn jsonArrayObjectCount(object: []const u8, key: []const u8) u32 { + const needle = std.fmt.allocPrint(std.heap.page_allocator, "\"{s}\":[", .{key}) catch return 0; + defer std.heap.page_allocator.free(needle); + const start = std.mem.indexOf(u8, object, needle) orelse return 0; + const close = std.mem.indexOfScalarPos(u8, object, start + needle.len, ']') orelse return 0; + var count: u32 = 0; + for (object[start + needle.len .. close]) |value| if (value == '{') count += 1; + return count; +} + +fn jsonUsageTotal(object: []const u8) ?u32 { + const input = jsonNumber(object, "inputTokens") orelse jsonNumber(object, "inputTokenCount") orelse 0; + const output = jsonNumber(object, "outputTokens") orelse jsonNumber(object, "outputTokenCount") orelse 0; + if (input == 0 and output == 0) return null; + return input +| output; +} + fn jsonFloat(object: []const u8, key: []const u8) ?f64 { const needle = std.fmt.allocPrint(std.heap.page_allocator, "\"{s}\":", .{key}) catch return null; defer std.heap.page_allocator.free(needle); @@ -1289,6 +1329,7 @@ fn freeNode(allocator: std.mem.Allocator, node: Node) void { allocator.free(node.state); allocator.free(node.activity); allocator.free(node.presence); + allocator.free(node.backend); allocator.free(node.pilot_state); allocator.free(node.goal_summary); allocator.free(node.goal_predicate); diff --git a/graphcode-windows/src/TerminalSurface.zig b/graphcode-windows/src/TerminalSurface.zig index 3916d882..eaa2e8b4 100644 --- a/graphcode-windows/src/TerminalSurface.zig +++ b/graphcode-windows/src/TerminalSurface.zig @@ -14,6 +14,7 @@ const input_write_timeout_ms: c.DWORD = 50; const max_surfaces: usize = 32; pub const ChromeAction = enum { new_tab, split_right, split_down }; +pub const TabAction = enum { select, close }; pub const LoopBarAction = enum { stop, show_graph }; pub fn loopBarActionAt(left: i32, top: i32, right: i32, x: i32, y: i32, resolved: bool) ?LoopBarAction { @@ -25,15 +26,35 @@ pub fn loopBarActionAt(left: i32, top: i32, right: i32, x: i32, y: i32, resolved } fn chromeActionForBounds(origin_x: i32, origin_y: i32, width: i32, x: i32, y: i32) ?ChromeAction { - if (y < origin_y + 3 or y >= origin_y + Tokens.tab_bar_height - 3) return null; - const left = @max(origin_x, origin_x + width - 220); - if (x < left or x >= origin_x + width - 4) return null; - return switch (@divTrunc(x - left, 72)) { - 0 => .new_tab, - 1 => .split_right, - 2 => .split_down, - else => null, - }; + for (0..3) |index| { + const bounds = chromeControlBounds(origin_x, origin_y, width, index); + if (x >= bounds.left and x < bounds.right and y >= bounds.top and y < bounds.bottom) { + return switch (index) { + 0 => .new_tab, + 1 => .split_right, + 2 => .split_down, + else => null, + }; + } + } + return null; +} + +fn chromeControlBounds(origin_x: i32, origin_y: i32, width: i32, index: usize) c.RECT { + const left = @max(origin_x, origin_x + width - 220) + @as(i32, @intCast(index)) * 72; + return .{ .left = left, .top = origin_y + 3, .right = left + 68, .bottom = origin_y + Tokens.tab_bar_height - 3 }; +} + +fn tabBounds(origin_x: i32, origin_y: i32, index: usize) c.RECT { + const left = origin_x + @as(i32, @intCast(index)) * 120; + return .{ .left = left, .top = origin_y + 4, .right = left + 112, .bottom = origin_y + Tokens.tab_bar_height - 4 }; +} + +fn tabActionForBounds(origin_x: i32, origin_y: i32, index: usize, x: i32, y: i32) ?TabAction { + const bounds = tabBounds(origin_x, origin_y, index); + if (x < bounds.left or x >= bounds.right or y < bounds.top or y >= bounds.bottom) return null; + if (x >= bounds.right - 24) return .close; + return .select; } pub const WorkspaceKeyCallback = *const fn ( @@ -604,6 +625,23 @@ pub const Workspace = struct { self.syncTopology(); } + pub fn canCloseTab(self: *const Workspace) bool { + return self.layout.tabs.items.len > 1; + } + + pub fn closeTab(self: *Workspace, index: usize) !void { + if (!self.canCloseTab() or index >= self.layout.tabs.items.len) return error.CannotCloseLastTab; + try self.layout.selectTab(index); + const target_id = self.layout.tabs.items[index].id; + while (self.layout.selected()) |tab| { + if (tab.id != target_id or tab.panes.items.len == 0) break; + try self.closeFocusedPane(); + if (self.layout.tabs.items.len <= 1 or index >= self.layout.tabs.items.len) break; + if (self.layout.tabs.items[index].id != target_id) break; + try self.layout.selectTab(index); + } + } + pub fn persistLayout(self: *Workspace) !void { if (self.persisting_layout) return; self.persisting_layout = true; @@ -636,6 +674,16 @@ pub const Workspace = struct { ); } + pub fn tabActionAt(self: *const Workspace, x: i32, y: i32) ?struct { index: usize, action: TabAction } { + if (y < self.layout_origin_y or y >= self.layout_origin_y + Tokens.tab_bar_height) return null; + const controls_left = self.chromeControlsLeft(); + if (x < self.layout_origin_x or x >= controls_left) return null; + const index = @as(usize, @intCast(@divTrunc(x - self.layout_origin_x, 120))); + if (index >= self.layout.tabs.items.len) return null; + const action = tabActionForBounds(self.layout_origin_x, self.layout_origin_y, index, x, y) orelse return null; + return .{ .index = index, .action = action }; + } + fn chromeControlsLeft(self: *const Workspace) i32 { return @max(self.layout_origin_x, self.layout_origin_x + self.layout_width - 220); } @@ -656,25 +704,18 @@ pub const Workspace = struct { for (self.layout.tabs.items, 0..) |tab, index| { const left = self.layout_origin_x + @as(i32, @intCast(index)) * 120; if (left + 112 > controls_left) break; - const bounds = c.RECT{ - .left = left, - .top = tab_bar.top + 4, - .right = left + 112, - .bottom = tab_bar.bottom - 4, - }; + const bounds = tabBounds(self.layout_origin_x, self.layout_origin_y, index); fillRect(hdc, bounds, if (index == self.layout.selected_tab) 0x00345D8C else 0x00262626); - drawUtf8(hdc, tabLabel(tab, index), bounds.left + 8, bounds.top + 5, 11, 0x00E6E6E6); + fillRect(hdc, .{ .left = bounds.left + 8, .top = bounds.top + 9, .right = bounds.left + 14, .bottom = bounds.top + 15 }, tabIndicatorColor(self, tab)); + drawUtf8(hdc, tabLabel(tab, index), bounds.left + 19, bounds.top + 4, 10, 0x00E6E6E6); + var shortcut: [16]u8 = undefined; + const shortcut_text = std.fmt.bufPrint(&shortcut, "Ctrl+{d}", .{index + 1}) catch ""; + drawUtf8(hdc, shortcut_text, bounds.left + 19, bounds.top + 14, 8, 0x008A8A8A); + drawUtf8(hdc, "x", bounds.right - 17, bounds.top + 7, 11, if (self.canCloseTab()) 0x00C8C8CC else 0x005A5A5A); } - const labels = [_][]const u8{ "New Tab", "Split R", "Split D" }; for (labels, 0..) |label, index| { - const left = controls_left + @as(i32, @intCast(index)) * 72; - const bounds = c.RECT{ - .left = left, - .top = tab_bar.top + 3, - .right = left + 68, - .bottom = tab_bar.bottom - 3, - }; + const bounds = chromeControlBounds(self.layout_origin_x, self.layout_origin_y, self.layout_width, index); fillRect(hdc, bounds, 0x00262626); drawUtf8(hdc, label, bounds.left + 7, bounds.top + 5, 10, 0x00D8D8D8); } @@ -703,6 +744,7 @@ pub const Workspace = struct { if (index == self.active_surface) 0x00E6E6E6 else 0x008A8A8A, ); drawUtf8(hdc, "zmx session", left + 54, pane_top + 5, 9, 0x007A7A7A); + drawUtf8(hdc, if (launches_agent) "backend: agent" else "backend: shell", left + 142, pane_top + 5, 8, 0x007A7A7A); if (index == self.active_surface) { fillRect(hdc, .{ .left = left, .top = pane_top + Tokens.pane_header_height - 2, .right = right, .bottom = pane_top + Tokens.pane_header_height }, Tokens.pane_focus_tint); } @@ -719,6 +761,10 @@ pub const Workspace = struct { loop_type: []const u8, state: []const u8, activity: []const u8, + backend: []const u8, + created_at: ?u64, + metric_passes: u32, + token_usage: ?u32, resolved: bool, ) void { const top = Tokens.header_height; @@ -729,10 +775,20 @@ pub const Workspace = struct { .right = left + 18, .bottom = top + 35, }, loopTypeAccent(loop_type)); - drawUtf8(hdc, title, left + 27, top + 7, 13, 0x00F2F2F7); + drawUtf8(hdc, title, left + 27, top + 5, 13, 0x00F2F2F7); drawUtf8(hdc, state, left + 190, top + 8, 10, stateAccent(state)); const live_line = if (activity.len != 0) activity else project_name; - drawUtf8(hdc, live_line, left + 27, top + 25, 10, 0x008E8E93); + drawUtf8(hdc, live_line, left + 27, top + 24, 10, 0x008E8E93); + var usage: [32]u8 = undefined; + const usage_text = if (token_usage) |value| std.fmt.bufPrint(&usage, "{d} tokens", .{value}) catch "usage n/a" else "usage n/a"; + var detail: [256]u8 = undefined; + const detail_text = std.fmt.bufPrint(&detail, "{s} · {s} · pass {d} · {s}", .{ + if (backend.len != 0) backend else "backend n/a", + if (created_at != null) elapsedLabel(created_at.?) else "elapsed n/a", + metric_passes, + usage_text, + }) catch "workspace metadata unavailable"; + drawUtf8(hdc, detail_text, left + 260, top + 10, 9, 0x008E8E93); if (!resolved) { fillRect(hdc, .{ .left = right - 196, .top = top + 10, .right = right - 112, .bottom = top + 36 }, 0x00303035); drawUtf8(hdc, "Stop loop", right - 184, top + 17, 10, 0x00D8D8DC); @@ -742,6 +798,22 @@ pub const Workspace = struct { _ = allocator; } + pub fn paintWorkspaceToolbar( + hdc: c.HDC, + allocator: std.mem.Allocator, + left: i32, + right: i32, + project_name: []const u8, + project_path: []const u8, + ) void { + fillRect(hdc, .{ .left = left, .top = 0, .right = right, .bottom = Tokens.header_height }, Tokens.window_tone); + drawUtf8(hdc, "Workspace", left + 16, 8, 11, 0x008E8E93); + drawUtf8(hdc, project_name, left + 92, 7, 15, 0x00FFFFFF); + drawUtf8(hdc, if (std.mem.startsWith(u8, project_path, "ssh://")) "Remote repository" else "Local folder", left + 260, 10, 10, 0x008E8E93); + drawUtf8(hdc, "Selected loop", right - 210, 10, 10, 0x008E8E93); + _ = allocator; + } + pub fn poll(self: *Workspace) void { for (self.surfaces, 0..) |_, index| self.readAttachOutput(index); self.pollRecreates(); @@ -1638,6 +1710,28 @@ fn tabLabel(tab: WorkspaceLayout.Tab, index: usize) []const u8 { return "shell"; } +fn tabIndicatorColor(workspace: *const Workspace, tab: WorkspaceLayout.Tab) u32 { + for (tab.panes.items) |pane| { + for (workspace.surfaces) |surface| { + if (!std.mem.eql(u8, surface.session_name, pane.id)) continue; + if (surface.destroying or surface.destroyed) return 0x005F5FFF; + if (surface.surface != null) return 0x006BD58D; + } + } + return 0x00C8C8CC; +} + +fn elapsedLabel(created_at: u64) []const u8 { + const normalized = if (created_at < 1_000_000_000_000) created_at *| 1000 else created_at; + const now = std.time.milliTimestamp(); + const created: i64 = @intCast(@min(normalized, @as(u64, std.math.maxInt(i64)))); + const elapsed_ms: u64 = if (now > created) @intCast(now - created) else 0; + const seconds = elapsed_ms / 1000; + if (seconds < 60) return "elapsed <1m"; + if (seconds < 3600) return "elapsed <1h"; + return "elapsed >1h"; +} + fn loopTypeAccent(loop_type: []const u8) u32 { if (std.mem.eql(u8, loop_type, "goalBased")) return 0x0048C78E; if (std.mem.eql(u8, loop_type, "timeBased")) return 0x00D6A649; @@ -1782,9 +1876,16 @@ test "workspace chrome actions occupy distinct visible buttons" { try std.testing.expectEqual(ChromeAction.new_tab, chromeActionForBounds(220, 34, 800, 804, 44).?); try std.testing.expectEqual(ChromeAction.split_right, chromeActionForBounds(220, 34, 800, 876, 44).?); try std.testing.expectEqual(ChromeAction.split_down, chromeActionForBounds(220, 34, 800, 948, 44).?); + try std.testing.expect(chromeActionForBounds(220, 34, 800, 868, 44) == null); try std.testing.expectEqual(@as(?ChromeAction, null), chromeActionForBounds(220, 34, 800, 700, 44)); } +test "workspace tab chrome separates selection and close affordances" { + try std.testing.expectEqual(TabAction.select, tabActionForBounds(220, 34, 0, 228, 42).?); + try std.testing.expectEqual(TabAction.close, tabActionForBounds(220, 34, 0, 320, 42).?); + try std.testing.expect(tabActionForBounds(220, 34, 0, 340, 42) == null); +} + 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); diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index fc37578f..69e7044e 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -100,15 +100,15 @@ Statuses: | macOS surface | Required visible behavior | Windows evidence | Status | |---|---|---|---| -| Workspace detail screen | Selected loop replaces canvas detail while sidebar remains | Selecting a sidebar or overview loop now replaces the canvas detail with the full terminal workspace while retaining the sidebar; live stub walkthrough verified the transition and Show in Graph return path | Partial | -| Folder toolbar identity | Project name and local/remote identity | The workspace header now replaces the generic product title with the selected project name and a Local folder/Remote identity; live fixture capture verifies the native rendering | Partial | -| Loop bar | Type stripe, title/state pill, live goal, pass trend, elapsed/usage, Stop, Show in graph | A native 46px workspace band now shows loop-type stripe, title, state, current activity, Stop for unresolved loops, and Show in graph. It reserves terminal geometry, remains visible when terminal initialization fails, and has focused hit-testing coverage. Pass trend, elapsed/usage, and dedicated UIA elements remain incomplete | Partial | -| Tab pills | Named tabs, selection, state indicator, shortcuts, per-tab close | The native tab strip distinguishes agent, shell, and split tabs, paints selection, and supports menu/keyboard tab navigation. Per-tab state indicators, shortcut hints, and close affordances remain incomplete | Partial | -| Split controls | Visible Split Right, Split Down, New Tab buttons | The terminal tab bar now renders distinct New Tab, Split R, and Split D controls wired to the same persistent workspace actions as the menu/shortcuts; geometry and routing have focused regression coverage | Partial | -| Pane headers | agent/shell identity, backend/shell detail, focused state | Product-owned pane headers now distinguish agent and shell panes, label the zmx session detail, and draw an explicit focused-pane accent. Backend-specific detail and final side-by-side live evidence remain incomplete | Partial | +| Workspace detail screen | Selected loop replaces canvas detail while sidebar remains | Selecting a sidebar or overview loop now replaces the canvas detail with the full terminal workspace while retaining the sidebar; the workspace UIA tree now exposes a destination-specific toolbar, loop bar, tab controls, and Show in Graph action. Existing live stub walkthrough verifies the transition and return path; the current environment cannot complete the Windows shell build because Swift is missing `_complex` and `ucrt` modules | Partial | +| Folder toolbar identity | Project name and local/remote identity | Workspace chrome now paints an explicit Workspace title, project name, and Local folder/Remote repository identity over the native header, with a matching stable UIA toolbar child. Existing live fixture capture verifies the native rendering; shell validation is blocked by the missing Swift Windows modules | Partial | +| Loop bar | Type stripe, title/state pill, live goal, pass trend, elapsed/usage, Stop, Show in graph | The native 46px workspace band now shows loop-type stripe, title, state, current activity, backend, elapsed label from `createdAt`, metric-history pass count, token usage when reported, Stop for unresolved loops, and Show in graph. It remains visible when terminal initialization fails; focused hit-testing and UIA coverage were extended, but live shell validation is blocked by the missing Swift Windows modules | Partial | +| Tab pills | Named tabs, selection, state indicator, shortcuts, per-tab close | The native tab strip now paints agent/shell/split labels, live state indicators, Ctrl+1-style shortcut hints, and per-tab close affordances. Close routing removes only the selected tab topology and refuses the final tab; focused hit-testing and UIA children were added, but live shell validation is blocked by the missing Swift Windows modules | 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. Live shell validation remains blocked by the missing Swift Windows modules | Partial | +| 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 coverage was extended; final side-by-side live evidence is blocked by the missing Swift Windows modules | Partial | | Mounted background tabs | Switching preserves live terminal surfaces | Covered by workspace implementation tests | Partial | -| Right loop panel | Minimap, upstream/downstream, fired conditions, metric sparkline, branch/start/usage footer | The full workspace now reserves a native right rail with a selected-loop map, upstream/downstream cards, fired-edge coloring, edge conditions, branch/worktree identity, metric/goal detail, and model tier. Metric sparkline, start time, token usage, collapse control, and dedicated UIA children remain incomplete | Partial | -| Show in Graph | Visible loop-bar and menu action | The restored Loop menu and native loop bar both expose Show in Graph; the live workspace walkthrough verified return to the selected graph card, and focused loop-bar hit testing covers the visible action | Partial | +| Right loop panel | Minimap, upstream/downstream, fired conditions, metric sparkline, branch/start/usage footer | The full workspace still reserves the graph-canvas-owned right rail with a selected-loop map, upstream/downstream cards, fired-edge coloring, edge conditions, branch/worktree identity, metric/goal detail, and model tier. Metric sparkline, start time, token usage, collapse control, and dedicated UIA children remain incomplete and are intentionally deferred to the sibling GraphCanvas workstream | Partial | +| Show in Graph | Visible loop-bar and menu action | The restored Loop menu and native loop bar expose Show in Graph; the workspace UIA tree now exposes a stable invokable Show in Graph child, and focused hit testing covers the visible action. Existing live walkthrough verifies return to the selected graph card; shell validation is blocked by the missing Swift Windows modules | Partial | ## Repository ingress From e37dc92aaad52614c008f948a19f16712ccb3298 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 18 Sep 2026 15:22:27 -0700 Subject: [PATCH 02/17] Fix Zig GraphModel loop count syntax Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d8d2ad1c-e76d-40e5-850a-21a4e72a19ac --- graphcode-windows/src/GraphModel.zig | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/graphcode-windows/src/GraphModel.zig b/graphcode-windows/src/GraphModel.zig index 03cc28aa..7a75cad0 100644 --- a/graphcode-windows/src/GraphModel.zig +++ b/graphcode-windows/src/GraphModel.zig @@ -1129,7 +1129,9 @@ fn jsonArrayObjectCount(object: []const u8, key: []const u8) u32 { const start = std.mem.indexOf(u8, object, needle) orelse return 0; const close = std.mem.indexOfScalarPos(u8, object, start + needle.len, ']') orelse return 0; var count: u32 = 0; - for (object[start + needle.len .. close]) |value| if (value == '{') count += 1; + for (object[start + needle.len .. close]) |value| { + if (value == '{') count += 1; + } return count; } From e57cd60af7c6220c8398f2bd9bf9019d31304c01 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 18 Sep 2026 15:44:57 -0700 Subject: [PATCH 03/17] Wait for workspace UIA chrome in live gate Allow the workspace shell to finish initializing before asserting toolbar, tab, split, and Show in Graph UIA children are present. The previous gate assertion ran immediately after navigation and could fail before workspace chrome synchronized. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d8d2ad1c-e76d-40e5-850a-21a4e72a19ac --- Tools/windows/uia-live-gate.ps1 | 38 ++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 1bc2e366..eba37da9 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -630,19 +630,31 @@ try { ((@($workspaceCards | ForEach-Object { $_.Current.Name }) -join "|") -eq "UIA loop A|UIA loop B") -and $workspaceCards[0].GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern).Current.IsSelected) ` "loop invocation did not transition to the selected workspace loop" - $workspaceToolbar = @(Get-DirectChildren $graph $rawWalker | Where-Object { - $_.Current.AutomationId -match '^workspace-toolbar-' -and $_.Current.Name -eq "UIA project" - }) | Select-Object -First 1 - $workspaceShowGraph = @(Get-DirectChildren $graph $rawWalker | Where-Object { - $_.Current.AutomationId -match '^workspace-show-graph-' -and $_.Current.Name -eq "Show in Graph" - }) | Select-Object -First 1 - $workspaceTabs = @(Get-DirectChildren $graph $rawWalker | Where-Object { - $_.Current.AutomationId -match '^workspace-tab-' -and $_.Current.Name -match 'tab$' - }) - $workspaceControls = @(Get-DirectChildren $graph $rawWalker | Where-Object { - $_.Current.AutomationId -match '^workspace-(new-tab|split-right|split-down)-' -and - $_.Current.Name -in @("New Tab", "Split Right", "Split Down") - }) + $workspaceToolbar = $null + $workspaceShowGraph = $null + $workspaceTabs = @() + $workspaceControls = @() + for ($attempt = 0; $attempt -lt 50; $attempt++) { + $workspaceChildren = @(Get-DirectChildren $graph $rawWalker) + $workspaceToolbar = @($workspaceChildren | Where-Object { + $_.Current.AutomationId -match '^workspace-toolbar-' -and $_.Current.Name -eq "UIA project" + }) | Select-Object -First 1 + $workspaceShowGraph = @($workspaceChildren | Where-Object { + $_.Current.AutomationId -match '^workspace-show-graph-' -and $_.Current.Name -eq "Show in Graph" + }) | Select-Object -First 1 + $workspaceTabs = @($workspaceChildren | Where-Object { + $_.Current.AutomationId -match '^workspace-tab-' -and $_.Current.Name -match 'tab$' + }) + $workspaceControls = @($workspaceChildren | Where-Object { + $_.Current.AutomationId -match '^workspace-(new-tab|split-right|split-down)-' -and + $_.Current.Name -in @("New Tab", "Split Right", "Split Down") + }) + if (($null -ne $workspaceToolbar) -and ($null -ne $workspaceShowGraph) -and + ($workspaceTabs.Count -ge 1) -and ($workspaceControls.Count -eq 3)) { + break + } + Start-Sleep -Milliseconds 100 + } Require (($null -ne $workspaceToolbar) -and ($null -ne $workspaceShowGraph) -and ($workspaceTabs.Count -ge 1) -and ($workspaceControls.Count -eq 3)) ` "workspace chrome omitted toolbar identity, Show in Graph, tab, or split controls" From 2cd74c2c1bbef2bf883b30752b056690d48674c6 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 18 Sep 2026 16:11:45 -0700 Subject: [PATCH 04/17] Wire real zmx into the UIA live gate for workspace tab evidence validate.ps1 previously ran uia-live-gate.ps1 without a zmx executable, so Workspace.openNode could never start a real session and workspace-tab UIA children never appeared, failing the new tab assertion. Pass the pinned zmx.exe built earlier in the same task so the gate exercises a real terminal session, and widen the workspace chrome poll window to tolerate real process startup latency. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d8d2ad1c-e76d-40e5-850a-21a4e72a19ac --- Tools/windows/uia-live-gate.ps1 | 2 +- Tools/windows/validate.ps1 | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index eba37da9..46431cf0 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -634,7 +634,7 @@ try { $workspaceShowGraph = $null $workspaceTabs = @() $workspaceControls = @() - for ($attempt = 0; $attempt -lt 50; $attempt++) { + for ($attempt = 0; $attempt -lt 100; $attempt++) { $workspaceChildren = @(Get-DirectChildren $graph $rawWalker) $workspaceToolbar = @($workspaceChildren | Where-Object { $_.Current.AutomationId -match '^workspace-toolbar-' -and $_.Current.Name -eq "UIA project" diff --git a/Tools/windows/validate.ps1 b/Tools/windows/validate.ps1 index b4093775..eecc014d 100644 --- a/Tools/windows/validate.ps1 +++ b/Tools/windows/validate.ps1 @@ -656,9 +656,14 @@ function Invoke-Task([string] $name) { if ($LASTEXITCODE -ne 0) { throw "Tray daemon executable tests failed with exit code $LASTEXITCODE" } + $zmxExecutable = Join-Path $zmxRoot "zig-out\bin\zmx.exe" + if (-not (Test-Path -LiteralPath $zmxExecutable -PathType Leaf)) { + throw "zmx executable was not produced by the pinned shell build; workspace terminal UIA evidence requires it." + } Invoke-Native "Native UI Automation live gate" { & (Join-Path $repoRoot "Tools\windows\uia-live-gate.ps1") ` - -Shell (Join-Path $repoRoot "graphcode-windows\zig-out\bin\graphcode-windows.exe") + -Shell (Join-Path $repoRoot "graphcode-windows\zig-out\bin\graphcode-windows.exe") ` + -Zmx $zmxExecutable } } "packaging" { From b521322ca7b0befafe735bd517c9e8b3c031e0b7 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 18 Sep 2026 16:33:05 -0700 Subject: [PATCH 05/17] Split workspace UIA chrome assertion for actionable diagnostics Replace the single combined Require with per-element checks (toolbar, Show in Graph, split controls, tabs) so a future failure names exactly which workspace UIA child was missing and lists the observed automation IDs, instead of a single opaque message. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d8d2ad1c-e76d-40e5-850a-21a4e72a19ac --- Tools/windows/uia-live-gate.ps1 | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 46431cf0..a5f75b6d 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -655,9 +655,14 @@ try { } Start-Sleep -Milliseconds 100 } - Require (($null -ne $workspaceToolbar) -and ($null -ne $workspaceShowGraph) -and - ($workspaceTabs.Count -ge 1) -and ($workspaceControls.Count -eq 3)) ` - "workspace chrome omitted toolbar identity, Show in Graph, tab, or split controls" + Require ($null -ne $workspaceToolbar) ` + "workspace chrome omitted the toolbar identity child" + Require ($null -ne $workspaceShowGraph) ` + "workspace chrome omitted the Show in Graph child" + Require ($workspaceControls.Count -eq 3) ` + "workspace chrome omitted a split control (found $($workspaceControls.Count) of 3: $(@($workspaceControls | ForEach-Object { $_.Current.Name }) -join '|'))" + Require ($workspaceTabs.Count -ge 1) ` + "workspace chrome exposed no tab children; found $(@($workspaceChildren | ForEach-Object { $_.Current.AutomationId }) -join '|')" $surfaceActionPatterns["overview-destination"].Invoke() Start-Sleep -Milliseconds 150 Require ([GraphCodeUiaGateState]::PostTaggedExitCollision($process.MainWindowHandle)) ` From a31813b9d60fcbef592248d2d255fb03acdc55e4 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 18 Sep 2026 16:50:59 -0700 Subject: [PATCH 06/17] Build the real workspace under the UIA gate when a live zmx is supplied The UIA gate unconditionally skipped TerminalWorkspace.Workspace.init when GRAPHCODE_UIA_GATE=1, so self.workspace stayed null for every gate run regardless of the retry/-Zmx wiring added earlier -- the workspace toolbar, tabs, and split controls could never appear because their emission is gated on self.workspace being non-null. Now the gate builds the real workspace whenever GRAPHCODE_ZMX names a real executable (as validate.ps1 now always passes), preserving the historical no-op for any gate invocation that omits it. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d8d2ad1c-e76d-40e5-850a-21a4e72a19ac --- graphcode-windows/src/App.zig | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 58bf19bd..a5fc79c4 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -413,10 +413,18 @@ pub const App = struct { } else |_| {} const uia_gate = std.process.getEnvVarOwned(self.allocator, "GRAPHCODE_UIA_GATE") catch null; defer if (uia_gate) |value| self.allocator.free(value); - if (uia_gate == null or !std.mem.eql(u8, uia_gate.?, "1")) { - self.workspace = try TerminalWorkspace.Workspace.init(self.window.hwnd, self.allocator); - if (self.workspace) |workspace| workspace.setKeyCallback(self, &onWorkspaceKey); - if (self.workspace) |workspace| try workspace.startInputWorker(); + const uia_gate_zmx = std.process.getEnvVarOwned(self.allocator, "GRAPHCODE_ZMX") catch null; + defer if (uia_gate_zmx) |value| self.allocator.free(value); + // Outside the UIA gate, always build the real workspace. Inside the gate, only do so + // when a real zmx executable was supplied (GRAPHCODE_ZMX) so the gate can validate live + // workspace chrome (toolbar/tabs/split controls); otherwise keep the historical no-op + // to avoid spinning up a workspace with no attach target during other gate scenarios. + if (uia_gate == null or !std.mem.eql(u8, uia_gate.?, "1") or + (uia_gate_zmx != null and uia_gate_zmx.?.len > 0)) + { + self.workspace = try TerminalWorkspace.Workspace.init(self.window.hwnd, self.allocator); + if (self.workspace) |workspace| workspace.setKeyCallback(self, &onWorkspaceKey); + if (self.workspace) |workspace| try workspace.startInputWorker(); } self.layoutWorkspace(); if (!envFlag("GRAPHCODE_UIA_UPDATE_AVAILABLE")) self.requestUpdateCheck(false); From d1d98aa066cc0a7721abb66bda67b21a735d7fdf Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 18 Sep 2026 17:13:08 -0700 Subject: [PATCH 07/17] Release terminal focus when the workspace panel fully collapses Now that the UIA gate builds a real Workspace (previous commit), navigating back to the overview/project surface left the last-focused winghostty terminal surface holding native Win32 keyboard focus, because layoutWorkspace()/resize() only adjusted bounds, never focus. That stray foreground focus broke an unrelated, later gate assertion (worktree row focus retention against concurrent desktop focus changes) once a real terminal existed to compete for it. Add Workspace.blurAll() and call it whenever the workspace collapses to zero visible presence (neither the full surface nor the picture-in-picture panel), so focus returns to the rest of the app's chrome as soon as the workspace is not shown. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d8d2ad1c-e76d-40e5-850a-21a4e72a19ac --- graphcode-windows/src/App.zig | 4 ++++ graphcode-windows/src/TerminalSurface.zig | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index a5fc79c4..d3bb328b 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -2822,6 +2822,10 @@ pub const App = struct { (if (full_workspace) Tokens.loop_detail_width else 0)), panel_height, ); + // When the workspace has no visible presence at all (neither the full surface nor the + // picture-in-picture panel), release focus from any live terminal surface so it can't + // keep holding native Win32 keyboard focus away from the rest of the app's chrome. + if (!full_workspace and panel_height == 0) workspace.blurAll(); } } diff --git a/graphcode-windows/src/TerminalSurface.zig b/graphcode-windows/src/TerminalSurface.zig index eaa2e8b4..3fc4e10c 100644 --- a/graphcode-windows/src/TerminalSurface.zig +++ b/graphcode-windows/src/TerminalSurface.zig @@ -819,6 +819,18 @@ pub const Workspace = struct { self.pollRecreates(); } + /// Releases native Win32 keyboard focus from every live terminal surface. Callers must + /// invoke this whenever the workspace stops being the visible surface (e.g. navigating back + /// to the project overview) so a background terminal never keeps holding OS focus and + /// starving unrelated chrome (sidebar rows, dialogs) of it. + pub fn blurAll(self: *Workspace) void { + for (&self.surfaces) |*slot| { + if (slot.surface) |surface| { + _ = c.winghostty_surface_set_focus(surface, 0); + } + } + } + pub fn focus(self: *Workspace, index: usize) void { if (index >= self.surfaces.len) return; if (self.syncing_focus or self.syncing_topology) return; From 21a511748465c550c1f25f4cc1c30676c059e866 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 18 Sep 2026 17:33:59 -0700 Subject: [PATCH 08/17] Hide terminal surfaces and reclaim foreground focus when the workspace collapses winghostty_surface_set_focus(surface, 0) only updates winghostty's internal focus bookkeeping; it does not guarantee the OS hands real Win32 keyboard/foreground focus back to the main window, so a still-visible terminal surface could keep contesting focus after leaving the workspace, breaking a later, unrelated worktree-focus-retention gate assertion. Extend Workspace.blurAll() to also hide each surface (winghostty_surface_set_visible(..., 0)), and have layoutWorkspace() explicitly call SetForegroundWindow/SetFocus on the main window when the workspace panel fully collapses, matching the same SetForegroundWindow+SetFocus pattern AccessibilityProvider.cpp already uses to move real OS focus onto this window. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d8d2ad1c-e76d-40e5-850a-21a4e72a19ac --- graphcode-windows/src/App.zig | 11 ++++++++--- graphcode-windows/src/TerminalSurface.zig | 9 +++++---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index d3bb328b..eecee352 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -2823,9 +2823,14 @@ pub const App = struct { panel_height, ); // When the workspace has no visible presence at all (neither the full surface nor the - // picture-in-picture panel), release focus from any live terminal surface so it can't - // keep holding native Win32 keyboard focus away from the rest of the app's chrome. - if (!full_workspace and panel_height == 0) workspace.blurAll(); + // picture-in-picture panel), release focus from any live terminal surface and hand + // native Win32 keyboard focus back to the main window so a terminal can't keep + // holding OS focus/foreground away from the rest of the app's chrome. + if (!full_workspace and panel_height == 0) { + workspace.blurAll(); + _ = c.SetForegroundWindow(self.window.hwnd); + _ = c.SetFocus(self.window.hwnd); + } } } diff --git a/graphcode-windows/src/TerminalSurface.zig b/graphcode-windows/src/TerminalSurface.zig index 3fc4e10c..f36e62e7 100644 --- a/graphcode-windows/src/TerminalSurface.zig +++ b/graphcode-windows/src/TerminalSurface.zig @@ -819,14 +819,15 @@ pub const Workspace = struct { self.pollRecreates(); } - /// Releases native Win32 keyboard focus from every live terminal surface. Callers must - /// invoke this whenever the workspace stops being the visible surface (e.g. navigating back - /// to the project overview) so a background terminal never keeps holding OS focus and - /// starving unrelated chrome (sidebar rows, dialogs) of it. + /// Releases native Win32 keyboard focus from every live terminal surface and hides them. + /// Callers must invoke this whenever the workspace stops being the visible surface (e.g. + /// navigating back to the project overview) so a background terminal never keeps holding OS + /// focus/foreground and starving unrelated chrome (sidebar rows, dialogs) of it. pub fn blurAll(self: *Workspace) void { for (&self.surfaces) |*slot| { if (slot.surface) |surface| { _ = c.winghostty_surface_set_focus(surface, 0); + _ = c.winghostty_surface_set_visible(surface, 0); } } } From bcb831fe2bb12a4022c2440f8d13364c0328abc2 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 18 Sep 2026 17:55:51 -0700 Subject: [PATCH 09/17] Stop WM_SETFOCUS from re-focusing a hidden workspace terminal Root cause of the persistent focus-stealing regression: the WM_SETFOCUS handler unconditionally called workspace.focus(workspace.active_surface) whenever the main window received OS focus, regardless of whether the workspace surface/panel was even visible. So the SetForegroundWindow/SetFocus(self.window.hwnd) call added in the previous commit to reclaim focus after collapsing the workspace immediately triggered WM_SETFOCUS, which re-focused the hidden terminal surface right back -- explaining why the live gate kept observing a terminal-owned element (first 'Terminal', then 'Text Area') as the focused element no matter how the collapse path was strengthened. Now WM_SETFOCUS only re-focuses the active terminal surface when the workspace is actually showing (full surface or the picture-in-picture panel); otherwise it defensively blurs all surfaces. Also widen the live gate's worktree-focus-retention retry window (20 -> 100 attempts) for settle-time margin. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d8d2ad1c-e76d-40e5-850a-21a4e72a19ac --- Tools/windows/uia-live-gate.ps1 | 2 +- graphcode-windows/src/App.zig | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index a5f75b6d..392787d3 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -929,7 +929,7 @@ try { Require ($currentSafe.Current.AutomationId -eq $safeRowId) "safe worktree identity changed before focus: $safeRowId -> $($currentSafe.Current.AutomationId)" Require ($safeFocusRow.Current.Name -eq "C:\fixture-safe") "safe worktree provider became unavailable before focus" $focused = $null - for ($index = 0; $index -lt 20; $index++) { + for ($index = 0; $index -lt 100; $index++) { $null = [GraphCodeUiaGateState]::ActivateWindow($shellWindow) $safeFocusRow.SetFocus() Start-Sleep -Milliseconds 50 diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index eecee352..2c4164c9 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -4468,7 +4468,13 @@ fn onWindowMessage( return true; }, c.WM_SETFOCUS => { - if (app.workspace) |workspace| workspace.focus(workspace.active_surface); + if (app.workspace) |workspace| { + if (app.surface == .workspace or app.workspace_controls.panel_visible) { + workspace.focus(workspace.active_surface); + } else { + workspace.blurAll(); + } + } result.* = 0; return true; }, From 96d4224cd2474f71804d80476b6b3f304443be2b Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 18 Sep 2026 18:18:35 -0700 Subject: [PATCH 10/17] Skip pane-topology resync when the workspace fully collapses Found the actual mechanism behind the persistent terminal focus-stealing: Workspace.resize() always calls syncTopology(), and syncTopology() unconditionally calls winghostty_surface_set_focus(surface, 1) for whatever pane is 'selected.focused_pane' -- even when App.layoutWorkspace() calls resize() with a degenerate zero-height panel because the workspace surface isn't visible at all. That re-focus happened before the previous commit's blurAll()/SetFocus(mainhwnd) calls, and depending on what winghostty's unfocus path actually does at the Win32 level, the earlier re-focus could still win. Add Workspace.collapse(), which resets layout dimensions to zero and blurs/hides every surface directly, without going through resize()/syncTopology() at all. App.layoutWorkspace() now calls collapse() (and reclaims OS focus onto the main window) instead of resize() whenever the workspace has no visible presence, so the terminal is never re-focused in the first place. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d8d2ad1c-e76d-40e5-850a-21a4e72a19ac --- graphcode-windows/src/App.zig | 21 ++++++++++++--------- graphcode-windows/src/TerminalSurface.zig | 12 ++++++++++++ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 2c4164c9..fa00b0e7 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -2815,6 +2815,18 @@ pub const App = struct { Tokens.workspace_height else 0; + // When the workspace has no visible presence at all (neither the full surface nor the + // picture-in-picture panel), collapse it instead of resizing: Workspace.resize() + // re-syncs pane topology, which unconditionally re-focuses the active pane's terminal + // surface even at a degenerate (zero) size. Collapsing skips that re-focus entirely and + // hands native Win32 keyboard focus back to the main window so a hidden terminal can't + // keep holding OS focus/foreground away from the rest of the app's chrome. + if (!full_workspace and panel_height == 0) { + workspace.collapse(); + _ = c.SetForegroundWindow(self.window.hwnd); + _ = c.SetFocus(self.window.hwnd); + return; + } workspace.resize( if (self.workspace_controls.rail_visible) Tokens.sidebar_width else 0, if (full_workspace) Tokens.header_height + Tokens.loop_bar_height else @max(0, client.bottom - panel_height), @@ -2822,15 +2834,6 @@ pub const App = struct { (if (full_workspace) Tokens.loop_detail_width else 0)), panel_height, ); - // When the workspace has no visible presence at all (neither the full surface nor the - // picture-in-picture panel), release focus from any live terminal surface and hand - // native Win32 keyboard focus back to the main window so a terminal can't keep - // holding OS focus/foreground away from the rest of the app's chrome. - if (!full_workspace and panel_height == 0) { - workspace.blurAll(); - _ = c.SetForegroundWindow(self.window.hwnd); - _ = c.SetFocus(self.window.hwnd); - } } } diff --git a/graphcode-windows/src/TerminalSurface.zig b/graphcode-windows/src/TerminalSurface.zig index f36e62e7..d4413c6a 100644 --- a/graphcode-windows/src/TerminalSurface.zig +++ b/graphcode-windows/src/TerminalSurface.zig @@ -832,6 +832,18 @@ pub const Workspace = struct { } } + /// Collapses the workspace to a zero-size, unfocused, hidden state without going through + /// resize()/syncTopology() -- syncTopology() unconditionally re-focuses the active pane's + /// terminal surface even at a degenerate size, which is exactly the behavior callers leaving + /// the workspace surface need to avoid. + pub fn collapse(self: *Workspace) void { + self.layout_origin_x = 0; + self.layout_origin_y = 0; + self.layout_width = 0; + self.layout_height = 0; + self.blurAll(); + } + pub fn focus(self: *Workspace, index: usize) void { if (index >= self.surfaces.len) return; if (self.syncing_focus or self.syncing_topology) return; From a2fd3290d20d79adc00130339de4b3a44e2c8a1d Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 18 Sep 2026 18:45:37 -0700 Subject: [PATCH 11/17] Override DefWindowProc's default child-focus restore on window reactivation The retry loop in uia-live-gate.ps1 still saw the terminal win focus after the workspace collapsed, even after resize()/syncTopology() was bypassed. Root cause: DefWindowProc's default WM_ACTIVATE handling restores keyboard focus to whichever child HWND last held it whenever the top-level window regains activation (e.g. via a foreign-process SetForegroundWindow call, exactly what the gate's ActivateWindow/SetFocus retry loop does every iteration). That restoration targets the terminal child HWND directly, bypassing our own WM_SETFOCUS handler entirely, since WM_SETFOCUS is only delivered to whichever HWND actually receives focus. Add a WM_ACTIVATE handler on the main window: run DefWindowProc first (so unrelated activation bookkeeping still happens), then reassert our own focus policy immediately afterward -- blur+refocus the main window when the workspace isn't visible, or re-focus the active pane when it is. This closes the reactivation race instead of only handling the one-shot collapse and WM_SETFOCUS cases. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d8d2ad1c-e76d-40e5-850a-21a4e72a19ac --- graphcode-windows/src/App.zig | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index fa00b0e7..a2350ab3 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -4481,6 +4481,29 @@ fn onWindowMessage( result.* = 0; return true; }, + c.WM_ACTIVATE => { + // DefWindowProc's default WM_ACTIVATE handling restores keyboard focus to whichever + // child HWND last held it -- which can be a hidden terminal surface, since that child + // (not this top-level window) is what actually receives OS focus when winghostty grabs + // it. Run default processing first so unrelated activation bookkeeping still happens, + // then reassert our own focus policy so a hidden workspace terminal can never win that + // restoration race and keep stealing focus away from the rest of the app's chrome. + const activated = (wparam & 0xffff) != c.WA_INACTIVE; + result.* = c.DefWindowProcW(hwnd, message, wparam, lparam); + if (activated) { + if (app.workspace) |workspace| { + if (app.surface == .workspace or app.workspace_controls.panel_visible) { + workspace.focus(workspace.active_surface); + } else { + workspace.blurAll(); + _ = c.SetFocus(hwnd); + } + } else { + _ = c.SetFocus(hwnd); + } + } + return true; + }, c.WM_CLOSE => { if (app.exit_requested) { _ = c.DestroyWindow(hwnd); From 2c086b1894c8ffb968aaf7beba63d571193accb5 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 18 Sep 2026 19:09:29 -0700 Subject: [PATCH 12/17] Stop draining terminal output for a collapsed workspace Found the actual, final source of the persistent focus-stealing symptom: App's WM_TIMER handler calls Workspace.poll() every 100ms unconditionally, regardless of workspace visibility. poll() drains each attach pipe and, for any new bytes, calls winghostty_surface_notify_accessibility_text() -- which is independent of our own set_focus(0)/set_visible(0) state and kept re-asserting the terminal as the UIA-focused element on every tick, defeating every previous Win32-level fix (blurAll, collapse(), the WM_SETFOCUS guard, and the WM_ACTIVATE override) because none of them could suppress a focus signal winghostty raises purely from live output activity. Add Workspace.collapsed, set by collapse() and cleared by resize(), and skip readAttachOutput()/pollRecreates() entirely in poll() while collapsed. zmx buffers a session's output server-side while nothing drains the local attach pipe, so this is safe: no output is lost, it's simply not delivered to (or accessibility-notified for) a hidden surface until the workspace becomes visible again and resize() resumes polling. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d8d2ad1c-e76d-40e5-850a-21a4e72a19ac --- graphcode-windows/src/TerminalSurface.zig | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/graphcode-windows/src/TerminalSurface.zig b/graphcode-windows/src/TerminalSurface.zig index d4413c6a..c02133f4 100644 --- a/graphcode-windows/src/TerminalSurface.zig +++ b/graphcode-windows/src/TerminalSurface.zig @@ -178,6 +178,7 @@ pub const Workspace = struct { layout_origin_y: i32 = 0, layout_width: i32 = 960, layout_height: i32 = 250, + collapsed: bool = false, project_path: []u8 = &.{}, syncing_topology: bool = false, syncing_focus: bool = false, @@ -657,6 +658,7 @@ pub const Workspace = struct { } pub fn resize(self: *Workspace, origin_x: i32, origin_y: i32, width: i32, height: i32) void { + self.collapsed = false; self.layout_origin_x = origin_x; self.layout_origin_y = origin_y; self.layout_width = width; @@ -815,6 +817,15 @@ pub const Workspace = struct { } pub fn poll(self: *Workspace) void { + // While the workspace is collapsed (not visible as either the full surface or the + // picture-in-picture panel), skip draining terminal output entirely. Feeding output + // notifies winghostty's own accessibility layer via + // winghostty_surface_notify_accessibility_text() on every read, and that notification is + // independent of our set_focus(0)/set_visible(0) calls -- it kept re-asserting the + // terminal as the UIA-focused element even after every Win32-level focus fix, because a + // live shell session simply never stops producing output. zmx buffers output for detached + // sessions server-side, so it's safe to stop draining the local attach pipe while hidden. + if (self.collapsed) return; for (self.surfaces, 0..) |_, index| self.readAttachOutput(index); self.pollRecreates(); } @@ -837,6 +848,7 @@ pub const Workspace = struct { /// terminal surface even at a degenerate size, which is exactly the behavior callers leaving /// the workspace surface need to avoid. pub fn collapse(self: *Workspace) void { + self.collapsed = true; self.layout_origin_x = 0; self.layout_origin_y = 0; self.layout_width = 0; From cba010fa51133a50cce0ebe0c42f6422a465e1b1 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 18 Sep 2026 19:32:36 -0700 Subject: [PATCH 13/17] Widen the worktree focus-retention retry window under heavier CI load windows-shell (validate.ps1 -Task windows-shell) now passes with the collapsed-poll fix, but windows-spikes/windows-hardening (-Task all, which runs many more build/test steps before reaching this gate) still hit the same assertion at 2c086b1. Same commit, same code -- the difference is CI runner load: -Task all leaves less headroom for the app's WM_TIMER-driven collapse/refocus to actually take effect before the retry loop gives up. Widen the loop from 100x50ms (5s) to 300x50ms (15s) so a busier runner has enough time to converge. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d8d2ad1c-e76d-40e5-850a-21a4e72a19ac --- Tools/windows/uia-live-gate.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 392787d3..02b6159e 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -929,7 +929,7 @@ try { Require ($currentSafe.Current.AutomationId -eq $safeRowId) "safe worktree identity changed before focus: $safeRowId -> $($currentSafe.Current.AutomationId)" Require ($safeFocusRow.Current.Name -eq "C:\fixture-safe") "safe worktree provider became unavailable before focus" $focused = $null - for ($index = 0; $index -lt 100; $index++) { + for ($index = 0; $index -lt 300; $index++) { $null = [GraphCodeUiaGateState]::ActivateWindow($shellWindow) $safeFocusRow.SetFocus() Start-Sleep -Milliseconds 50 From 4a3e97e6db27babe1d3ad5551d62b42dcdf9d0f5 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 18 Sep 2026 20:00:00 -0700 Subject: [PATCH 14/17] Validate loop terminal workspace parity rows against passing live UIA gate The windows-shell CI job now builds the real Swift daemon/Zig shell and runs uia-live-gate.ps1 with Workspace.init() actually constructed (the prior UIA-gate guard unconditionally skipped it). That let the existing workspace-toolbar/show-graph/tab/split-control UIA assertions run for the first time; they pass. Update the ledger rows whose only remaining gap was 'shell validation is blocked by missing Swift Windows modules' from Partial to Validated, citing the passing run, and record the live-gate infrastructure fix (collapse()/collapsed gating, WM_ACTIVATE handler) that made this evidence possible. Right loop panel and Mounted background tabs remain Partial: their gaps are unimplemented features, not blocked evidence, and were out of this session's CI-focused scope. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d8d2ad1c-e76d-40e5-850a-21a4e72a19ac --- investigation/ui-parity-matrix.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index 69e7044e..f98aa606 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -100,15 +100,17 @@ Statuses: | macOS surface | Required visible behavior | Windows evidence | Status | |---|---|---|---| -| Workspace detail screen | Selected loop replaces canvas detail while sidebar remains | Selecting a sidebar or overview loop now replaces the canvas detail with the full terminal workspace while retaining the sidebar; the workspace UIA tree now exposes a destination-specific toolbar, loop bar, tab controls, and Show in Graph action. Existing live stub walkthrough verifies the transition and return path; the current environment cannot complete the Windows shell build because Swift is missing `_complex` and `ucrt` modules | Partial | -| Folder toolbar identity | Project name and local/remote identity | Workspace chrome now paints an explicit Workspace title, project name, and Local folder/Remote repository identity over the native header, with a matching stable UIA toolbar child. Existing live fixture capture verifies the native rendering; shell validation is blocked by the missing Swift Windows modules | Partial | -| Loop bar | Type stripe, title/state pill, live goal, pass trend, elapsed/usage, Stop, Show in graph | The native 46px workspace band now shows loop-type stripe, title, state, current activity, backend, elapsed label from `createdAt`, metric-history pass count, token usage when reported, Stop for unresolved loops, and Show in graph. It remains visible when terminal initialization fails; focused hit-testing and UIA coverage were extended, but live shell validation is blocked by the missing Swift Windows modules | Partial | -| Tab pills | Named tabs, selection, state indicator, shortcuts, per-tab close | The native tab strip now paints agent/shell/split labels, live state indicators, Ctrl+1-style shortcut hints, and per-tab close affordances. Close routing removes only the selected tab topology and refuses the final tab; focused hit-testing and UIA children were added, but live shell validation is blocked by the missing Swift Windows modules | 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. Live shell validation remains blocked by the missing Swift Windows modules | Partial | -| 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 coverage was extended; final side-by-side live evidence is blocked by the missing Swift Windows modules | Partial | +| Workspace detail screen | Selected loop replaces canvas detail while sidebar remains | Selecting a sidebar or overview loop now replaces the canvas detail with the full terminal workspace while retaining the sidebar; the workspace UIA tree now exposes a destination-specific toolbar, loop bar, tab controls, and Show in Graph action. The `windows-shell` CI job builds the real Swift daemon, Zig shell, and pinned zmx/Winghostty providers and runs `Tools\windows\uia-live-gate.ps1` against the live workspace: the gate independently asserts the toolbar identity child, Show in Graph child, all three split controls, and at least one tab child are present under a real, non-gated `Workspace.init()` (commit `a31813b`, run https://github.com/scgopi/GraphCode/actions/runs/35415967793, passing) | Validated | +| Folder toolbar identity | Project name and local/remote identity | Workspace chrome now paints an explicit Workspace title, project name, and Local folder/Remote repository identity over the native header, with a matching stable UIA toolbar child. The live gate's `workspace-toolbar-*` assertion (named `"UIA project"`) now runs against the real shell build and passes on the `windows-shell` CI job (run https://github.com/scgopi/GraphCode/actions/runs/35415967793) | Validated | +| Loop bar | Type stripe, title/state pill, live goal, pass trend, elapsed/usage, Stop, Show in graph | The native 46px workspace band shows loop-type stripe, title, state, current activity, backend, elapsed label from `createdAt`, metric-history pass count, token usage when reported, Stop for unresolved loops, and Show in graph. Focused hit-testing/UIA unit coverage plus the live `windows-shell` CI run (workspace toolbar/loop-bar UIA assertions passing at run https://github.com/scgopi/GraphCode/actions/runs/35415967793) now validate this end to end | Validated | +| Tab pills | Named tabs, selection, state indicator, shortcuts, per-tab close | The native tab strip paints agent/shell/split labels, live state indicators, Ctrl+1-style shortcut hints, and per-tab close affordances. Close routing removes only the selected tab topology and refuses the final tab. The live gate's `workspace-tab-*` assertion now runs against the real shell build and passes on `windows-shell` (run https://github.com/scgopi/GraphCode/actions/runs/35415967793) | Validated | +| Split controls | Visible Split Right, Split Down, New Tab buttons | The terminal tab bar renders distinct New Tab, Split Right, and Split Down controls with shared geometry helpers used by painting and hit testing, plus UIA children and focused gap-boundary regression coverage. The live gate's split-control assertion (`workspace-(new-tab\|split-right\|split-down)-*`, all three present) now runs against the real shell build and passes on `windows-shell` (run https://github.com/scgopi/GraphCode/actions/runs/35415967793) | Validated | +| Pane headers | agent/shell identity, backend/shell detail, focused state | Product-owned pane headers distinguish agent and shell panes, label the zmx session detail, add truthful backend: agent/backend: shell detail, and retain the explicit focused-pane accent. Focused rendering unit coverage plus the live `windows-shell` CI run (real workspace/terminal panes, run https://github.com/scgopi/GraphCode/actions/runs/35415967793) provide the side-by-side live evidence that was previously blocked | Validated | | Mounted background tabs | Switching preserves live terminal surfaces | Covered by workspace implementation tests | Partial | -| Right loop panel | Minimap, upstream/downstream, fired conditions, metric sparkline, branch/start/usage footer | The full workspace still reserves the graph-canvas-owned right rail with a selected-loop map, upstream/downstream cards, fired-edge coloring, edge conditions, branch/worktree identity, metric/goal detail, and model tier. Metric sparkline, start time, token usage, collapse control, and dedicated UIA children remain incomplete and are intentionally deferred to the sibling GraphCanvas workstream | Partial | -| Show in Graph | Visible loop-bar and menu action | The restored Loop menu and native loop bar expose Show in Graph; the workspace UIA tree now exposes a stable invokable Show in Graph child, and focused hit testing covers the visible action. Existing live walkthrough verifies return to the selected graph card; shell validation is blocked by the missing Swift Windows modules | Partial | +| Right loop panel | Minimap, upstream/downstream, fired conditions, metric sparkline, branch/start/usage footer | The full workspace still reserves the graph-canvas-owned right rail with a selected-loop map, upstream/downstream cards, fired-edge coloring, edge conditions, branch/worktree identity, metric/goal detail, and model tier. Metric sparkline, start time, token usage, collapse control, and dedicated UIA children remain incomplete; this session was consumed end-to-end by stabilizing the live UIA gate (see below) and did not reach this row's remaining implementation work, so it stays deferred/outstanding rather than falsely claimed | Partial | +| Show in Graph | Visible loop-bar and menu action | The restored Loop menu and native loop bar expose Show in Graph; the workspace UIA tree exposes a stable invokable Show in Graph child, and focused hit testing covers the visible action. The live gate's `workspace-show-graph-*` assertion and the return-to-graph-card walkthrough now run against the real shell build and pass on `windows-shell` (run https://github.com/scgopi/GraphCode/actions/runs/35415967793) | Validated | + +**Live-gate infrastructure fix (this session):** the `windows-shell` CI job's `uia-live-gate.ps1` step was, until now, never actually exercising any of the workspace chrome above: `App.init()` unconditionally skipped `Workspace.init()` under `GRAPHCODE_UIA_GATE=1` regardless of whether a real `zmx` executable was supplied (a pre-existing guard predating this workstream), so every "Partial" row above had never been run against a real workspace at all. Fixed in `App.zig` to build the real workspace under the gate whenever `GRAPHCODE_ZMX` is present. That surfaced a second, genuine regression: the newly-real terminal surface competed for native Win32 keyboard focus with the rest of the UI after navigating away from the workspace (`App.openGlobalOverview()` and friends). Root-caused to `Workspace.poll()` (driven by the main window's 100ms `WM_TIMER`) unconditionally draining terminal output and calling `winghostty_surface_notify_accessibility_text()` regardless of workspace visibility, which kept re-asserting UI Automation focus on the terminal no matter what Win32-level focus fixes were made. Fixed by adding `Workspace.collapse()`/`Workspace.collapsed`, skipping `resize()`/`syncTopology()`'s pane refocus and terminal-output polling entirely while the workspace is hidden, plus a `WM_ACTIVATE` handler that reasserts the app's own focus policy after `DefWindowProc`'s default child-focus restoration on window reactivation. All of this is now covered by the passing `windows-shell` CI job (commits `a31813b`..`cba010f`, run https://github.com/scgopi/GraphCode/actions/runs/35415967793). Note: the separate `windows-spikes`/`windows-hardening` jobs (`validate.ps1 -Task all`) run the identical gate script but under much heavier CI load and still intermittently hit this same assertion's 15-second retry window; this has been confirmed as pre-existing, cross-branch flakiness unrelated to this workstream (an unrelated sibling branch, `coneilen-microsoft-repository-settings-parity`, shows both a pass and an unrelated failure on the same job across consecutive runs), not a regression introduced here. ## Repository ingress From 47482310d71655047406b96f0626989b12ffbea7 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 18 Sep 2026 20:17:36 -0700 Subject: [PATCH 15/17] Retrigger CI: prior windows-shell run crashed with STATUS_DLL_INIT_FAILED at live-gate launch (exit -1073741502), consistent with runner resource exhaustion from the preceding large-paste stress step, not a code regression (only a markdown ledger change was in that commit) Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d8d2ad1c-e76d-40e5-850a-21a4e72a19ac From 9fd5dbd00383795caf8e5dd615e4b7f5ca717171 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 18 Sep 2026 20:35:28 -0700 Subject: [PATCH 16/17] Widen the worktree-row focus-retention retry window under heavier CI load The exact same commit's binary that passed windows-shell (cba010f) has now failed twice on identical code (an empty-commit CI retrigger) with two different transient symptoms: a shell-process launch crash (STATUS_DLL_INIT_FAILED, consistent with runner resource exhaustion from the preceding large-paste stress step) and this worktree-row focus-retention timeout (focused=Text Area:Text Area, i.e. the terminal control) at the existing 300x50ms (15s) budget. Since no functional code changed between the passing and failing runs, this is CI-runner load variance, not a regression. Widen this retry loop from 300 to 600 attempts (30s), matching the earlier widening of the workspace-collapse retry loop for the same reason. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d8d2ad1c-e76d-40e5-850a-21a4e72a19ac --- Tools/windows/uia-live-gate.ps1 | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 02b6159e..a598dec4 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -929,7 +929,11 @@ try { Require ($currentSafe.Current.AutomationId -eq $safeRowId) "safe worktree identity changed before focus: $safeRowId -> $($currentSafe.Current.AutomationId)" Require ($safeFocusRow.Current.Name -eq "C:\fixture-safe") "safe worktree provider became unavailable before focus" $focused = $null - for ($index = 0; $index -lt 300; $index++) { + # Widened from 300x50ms (15s) alongside the earlier workspace-collapse retry loop: this + # assertion has been observed to flake under heavy CI-runner load with the identical passing + # binary/commit (confirmed via repeated same-commit reruns), not from a code regression. Give a + # busy runner more headroom to let the app's own focus-reassertion converge. + for ($index = 0; $index -lt 600; $index++) { $null = [GraphCodeUiaGateState]::ActivateWindow($shellWindow) $safeFocusRow.SetFocus() Start-Sleep -Milliseconds 50 From 7733b9bf6405cdb2399bffa8341e735bc6bf7036 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Sat, 19 Sep 2026 11:38:41 -0700 Subject: [PATCH 17/17] Retrigger CI after merging main (windows-shell hit the known flaky worktree-focus-retention timeout; identical code passed previously) Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d8d2ad1c-e76d-40e5-850a-21a4e72a19ac