diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 981521a0..b57d400c 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -697,6 +697,39 @@ 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 = $null + $workspaceShowGraph = $null + $workspaceTabs = @() + $workspaceControls = @() + 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" + }) | 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) ` + "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)) ` @@ -963,7 +996,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 20; $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 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" { diff --git a/graphcode-windows/src/AccessibilityProvider.cpp b/graphcode-windows/src/AccessibilityProvider.cpp index 93587e7b..46cabd84 100644 --- a/graphcode-windows/src/AccessibilityProvider.cpp +++ b/graphcode-windows/src/AccessibilityProvider.cpp @@ -785,6 +785,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 21c156b1..92af292b 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -155,6 +155,13 @@ const UiaDynamicTarget = union(enum) { action: GraphCanvas.ReclaimAction, }, 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 { @@ -414,7 +421,15 @@ 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")) { + 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(); @@ -2883,6 +2898,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), @@ -3224,6 +3251,35 @@ pub const App = struct { self.appendAccessibilityElement(&elements, &owned_identities, "keep", key, "Keep", 4, offer.keep, false, 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| { @@ -3415,6 +3471,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 }; + } + } + } + } for (self.model.attention_entries.items, 0..) |entry, index| { const identity = std.fmt.allocPrint(self.allocator, "needs-you-row:{s}:{s}", .{ entry.project_path, entry.node.id }) catch return false; defer self.allocator.free(identity); @@ -3499,6 +3594,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(); @@ -3816,6 +3918,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]; @@ -3829,6 +3939,10 @@ fn onWindowMessage( node.loop_type, node.state, node.activity, + node.backend, + node.created_at, + node.metric_passes, + node.token_usage, isResolvedLoopState(node.state), ); } @@ -4098,7 +4212,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; } @@ -4589,10 +4708,39 @@ 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; }, + 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); diff --git a/graphcode-windows/src/GraphModel.zig b/graphcode-windows/src/GraphModel.zig index a074db2d..7713e9ac 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,10 +20,12 @@ 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 = &.{}, - created_at: i64 = 0, }; pub const ActivityEvent = struct { @@ -1010,6 +1013,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), @@ -1020,10 +1024,12 @@ 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), - .created_at = node.created_at, }; } @@ -1072,6 +1078,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", ""), @@ -1082,7 +1089,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 = @intFromFloat(jsonFloat(scalar_object, "createdAt") orelse 0), + .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"), @@ -1136,6 +1145,36 @@ 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); @@ -1325,6 +1364,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/Sidebar.zig b/graphcode-windows/src/Sidebar.zig index 88ccfcaa..93a1ad56 100644 --- a/graphcode-windows/src/Sidebar.zig +++ b/graphcode-windows/src/Sidebar.zig @@ -219,7 +219,7 @@ pub fn draw( fill(hdc, rect(30 + indent, row.top - 2, 33 + indent, row.top + 17), loopAccent(node.loop_type)); drawText(hdc, allocator, node.title, 39 + indent, row.top, 11, 0x00E6E6E6); drawText(hdc, allocator, compactState(node.state), 150, row.top, 9, stateColor(node.state)); - const elapsed = elapsedText(allocator, node.created_at, std.time.timestamp()) catch null; + const elapsed = elapsedText(allocator, @intCast(node.created_at orelse 0), std.time.timestamp()) catch null; defer if (elapsed) |value| allocator.free(value); if (elapsed) |value| drawText(hdc, allocator, value, 168, row.top, 9, 0x008E8E93); if (row.has_children and hover_y >= row.top and hover_y < row.top + 24) diff --git a/graphcode-windows/src/TerminalSurface.zig b/graphcode-windows/src/TerminalSurface.zig index 3916d882..c02133f4 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 ( @@ -157,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, @@ -604,6 +626,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; @@ -619,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; @@ -636,6 +676,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 +706,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 +746,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 +763,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 +777,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,11 +800,62 @@ 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 { + // 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(); } + /// 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); + } + } + } + + /// 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.collapsed = true; + 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; @@ -1638,6 +1747,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 +1913,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 627b7a37..0ec10af2 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; 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. 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 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; 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