From 18dc7ccb1d7019e2e41db16774d5a37feee463f5 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 18 Sep 2026 14:47:33 -0700 Subject: [PATCH 1/4] Advance Windows sidebar parity Add elapsed and attention metadata, stable UIA rows, Recycle Bin project action, and transactional root-order state while documenting remaining parity gaps. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5116ea46-45e4-48b6-8baf-cd368f667e97 --- Tools/windows/uia-live-gate.ps1 | 55 ++++++++ .../src/AccessibilityProvider.cpp | 8 ++ graphcode-windows/src/App.zig | 130 ++++++++++++++++++ graphcode-windows/src/GraphContextMenu.zig | 15 +- graphcode-windows/src/GraphModel.zig | 31 ++++- graphcode-windows/src/Sidebar.zig | 91 +++++++++++- investigation/ui-parity-matrix.md | 14 +- 7 files changed, 332 insertions(+), 12 deletions(-) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 8a7d0aa2..0f8a8293 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -384,6 +384,56 @@ try { Require (($projectRow.Current.BoundingRectangle.Width -gt 0) -and ($projectRow.Current.BoundingRectangle.Height -gt 0)) "dynamic project row has empty bounds" } + $needsYouRows = @(Get-DirectChildren $projects $rawWalker | Where-Object { + $_.Current.AutomationId -match '^needs-you-row-' + }) + $activityRows = @(Get-DirectChildren $projects $rawWalker | Where-Object { + $_.Current.AutomationId -match '^activity-row-' + }) + $activityControls = @(Get-DirectChildren $projects $rawWalker | Where-Object { + $_.Current.AutomationId -match '^activity-control-' + }) + $needsYouHeaders = @(Get-DirectChildren $projects $rawWalker | Where-Object { + $_.Current.AutomationId -match '^needs-you-header-' + }) + $activityHeaders = @(Get-DirectChildren $projects $rawWalker | Where-Object { + $_.Current.AutomationId -match '^activity-header-' + }) + if ($needsYouRows.Count -gt 0 -or $needsYouHeaders.Count -gt 0) { + Require ($needsYouHeaders.Count -eq 1) "Needs-you rows omitted their stable header" + Require ($needsYouHeaders[0].Current.Name -eq "Needs you") "Needs-you header name changed" + } + if ($activityRows.Count -gt 0 -or $activityControls.Count -gt 0 -or $activityHeaders.Count -gt 0) { + Require ($activityHeaders.Count -eq 1) "Activity controls omitted their stable header" + Require ($activityHeaders[0].Current.Name -eq "Activity") "Activity header name changed" + } + if ($needsYouRows.Count -gt 0) { + Require ($needsYouRows.Count -le 4) "Needs-you exposed more than four sidebar rows" + $needsYouIds = @($needsYouRows | ForEach-Object { $_.Current.AutomationId }) + Require (($needsYouIds | Where-Object { $_ -notmatch '^needs-you-row-[0-9]+$' }).Count -eq 0) ` + "Needs-you rows did not use stable dynamic IDs" + foreach ($row in $needsYouRows) { + Require ($row.Current.Name.Length -gt 0) "Needs-you row omitted its name" + Require (($row.Current.BoundingRectangle.Width -gt 0) -and + ($row.Current.BoundingRectangle.Height -gt 0)) "Needs-you row has empty bounds" + } + } + if ($activityRows.Count -gt 0) { + Require ($activityRows.Count -le 4) "Activity exposed more than four sidebar rows" + $activityIds = @($activityRows | ForEach-Object { $_.Current.AutomationId }) + Require (($activityIds | Where-Object { $_ -notmatch '^activity-row-[0-9]+$' }).Count -eq 0) ` + "Activity rows did not use stable dynamic IDs" + foreach ($row in $activityRows) { + Require ($row.Current.Name.Length -gt 0) "Activity row omitted its name" + Require (($row.Current.BoundingRectangle.Width -gt 0) -and + ($row.Current.BoundingRectangle.Height -gt 0)) "Activity row has empty bounds" + } + } + foreach ($control in $activityControls) { + Require (($control.Current.BoundingRectangle.Width -gt 0) -and + ($control.Current.BoundingRectangle.Height -gt 0)) "Activity control has empty bounds" + $null = $control.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern) + } $null = $projects.GetCurrentPattern([System.Windows.Automation.SelectionPattern]::Pattern) $null = $projectRows[0].GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern) $projectRowInvoke = $projectRows[0].GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern) @@ -1653,6 +1703,11 @@ try { actionPatterns = @($actions.Keys | Sort-Object) surfaceActionPatterns = @($surfaceActionPatterns.Keys | Sort-Object) dynamicProjectRows = $projectRowIds + needsYouRows = @($needsYouRows | ForEach-Object { $_.Current.AutomationId }) + needsYouHeader = @($needsYouHeaders | ForEach-Object { $_.Current.AutomationId }) + activityRows = @($activityRows | ForEach-Object { $_.Current.AutomationId }) + activityHeader = @($activityHeaders | ForEach-Object { $_.Current.AutomationId }) + activityControls = @($activityControls | ForEach-Object { $_.Current.AutomationId }) dynamicLoopRows = $loopIds dynamicProjectCards = $projectCardIds dynamicQuickChatCards = $quickChatCardIds diff --git a/graphcode-windows/src/AccessibilityProvider.cpp b/graphcode-windows/src/AccessibilityProvider.cpp index e49f70f9..93587e7b 100644 --- a/graphcode-windows/src/AccessibilityProvider.cpp +++ b/graphcode-windows/src/AccessibilityProvider.cpp @@ -773,6 +773,11 @@ class Node final : public IRawElementProviderSimple, const int parent = row.parent; const wchar_t *prefix = row.identity.rfind("sidebar-section:", 0) == 0 ? L"sidebar-section-" : + row.identity.rfind("needs-you-header:", 0) == 0 ? L"needs-you-header-" : + row.identity.rfind("needs-you-row:", 0) == 0 ? L"needs-you-row-" : + row.identity.rfind("activity-header:", 0) == 0 ? L"activity-header-" : + row.identity.rfind("activity-row:", 0) == 0 ? L"activity-row-" : + row.identity.rfind("activity-control:", 0) == 0 ? L"activity-control-" : row.identity.rfind("project-new-loop:", 0) == 0 ? L"project-new-loop-" : row.identity.rfind("project-disclosure:", 0) == 0 ? L"project-disclosure-" : row.identity.rfind("quick-chats-header:", 0) == 0 ? L"quick-chats-header-" : @@ -801,6 +806,9 @@ class Node final : public IRawElementProviderSimple, const Row &row = state_->rows.at(id_); const bool action = row.identity.rfind("sidebar-section:", 0) == 0 || + row.identity.rfind("needs-you-header:", 0) == 0 || + row.identity.rfind("activity-header:", 0) == 0 || + row.identity.rfind("activity-control:", 0) == 0 || row.identity.rfind("project-new-loop:", 0) == 0 || row.identity.rfind("project-disclosure:", 0) == 0 || row.identity.rfind("quick-chats-header:", 0) == 0 || diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 540d7951..a1de6693 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -132,6 +132,10 @@ const UiaDynamicTarget = union(enum) { quick_chats_header, quick_chats_disclosure, new_quick_chat, + needs_you_header, + needs_you: usize, + activity_header, + activity: usize, recent_project: []const u8, open_project: []const u8, project_new_loop: []const u8, @@ -1746,6 +1750,15 @@ pub const App = struct { self.client.sendForgetProject(stable.path); self.setStatus("Removing project from GraphCode..."); }, + .move_project => self.revealProjectPath(stable.path), + .trash_project => { + if (!GraphContextMenu.confirm( + self.window.hwnd, + "Move Project to Recycle Bin", + "Move this project folder to the Windows Recycle Bin?\n\nThe project will also be removed from GraphCode.", + )) return; + self.trashProjectPath(stable.path); + }, .delete_project_loops => { self.deleteProjectLoops(stable.path); }, @@ -1873,6 +1886,35 @@ pub const App = struct { self.setStatus(if (@intFromPtr(result) <= 32) "Unable to open Explorer" else "Opened project in Explorer"); } + fn trashProjectPath(self: *App, path: []const u8) void { + const raw = std.unicode.utf8ToUtf16LeAlloc(self.allocator, path) catch { + self.setStatus("Unable to encode project path"); + return; + }; + defer self.allocator.free(raw); + const from = self.allocator.alloc(u16, raw.len + 2) catch return; + defer self.allocator.free(from); + @memcpy(from[0..raw.len], raw); + from[raw.len] = 0; + from[raw.len + 1] = 0; + var operation: c.SHFILEOPSTRUCTW = .{ + .hwnd = self.window.hwnd, + .wFunc = c.FO_DELETE, + .pFrom = from.ptr, + .pTo = null, + .fFlags = c.FOF_ALLOWUNDO | c.FOF_NOCONFIRMATION | c.FOF_SILENT, + .fAnyOperationsAborted = 0, + .hNameMappings = null, + .lpszProgressTitle = null, + }; + if (c.SHFileOperationW(&operation) != 0 or operation.fAnyOperationsAborted != 0) { + self.setStatus("Project was not moved to the Recycle Bin"); + return; + } + self.client.sendForgetProject(path); + self.setStatus("Project moved to the Recycle Bin"); + } + fn showRemoteProjectInfo(self: *App, path: []const u8) void { const message = std.fmt.allocPrint( self.allocator, @@ -3081,6 +3123,36 @@ pub const App = struct { else => {}, } } + if (self.model.attention_entries.items.len != 0) { + const section = Sidebar.sidebarSectionBottom(&self.model, if (self.worktree_inspection) |*value| value else null, &self.sidebar_state); + self.appendAccessibilityElement(&elements, &owned_identities, "needs-you-header", "needs-you", "Needs you", 1, + .{ .left = 12, .top = section + 4, .right = 232, .bottom = section + 28 }, false, true) catch return; + for (self.model.attention_entries.items[0..@min(self.model.attention_entries.items.len, 4)], 0..) |entry, index| { + const identity = std.fmt.allocPrint(self.allocator, "{s}:{s}", .{ entry.project_path, entry.node.id }) catch return; + defer self.allocator.free(identity); + const name = std.fmt.allocPrint(self.allocator, "{s} - {s}", .{ entry.node.title, Sidebar.attentionReason(entry.node) }) catch return; + defer self.allocator.free(name); + self.appendAccessibilityElement(&elements, &owned_identities, "needs-you-row", identity, name, 1, + .{ .left = 18, .top = section + 30 + @as(i32, @intCast(index * 34)), .right = 232, .bottom = section + 60 + @as(i32, @intCast(index * 34)) }, + self.model.selected_node_id != null and std.mem.eql(u8, self.model.selected_node_id.?, entry.node.id), true) catch return; + } + } + if (self.model.activity.items.len != 0) { + const section = Sidebar.sidebarSectionBottom(&self.model, if (self.worktree_inspection) |*value| value else null, &self.sidebar_state); + const activity_top = section + 30 + @as(i32, @intCast(@min(self.model.attentionCount(), 4) * 34)) + 18; + self.appendAccessibilityElement(&elements, &owned_identities, "activity-header", "activity", "Activity", 1, + .{ .left = 12, .top = activity_top, .right = 232, .bottom = activity_top + 24 }, false, true) catch return; + for (self.model.activity.items[0..@min(self.model.activity.items.len, 4)], 0..) |event, index| { + const identity = std.fmt.allocPrint(self.allocator, "{s}:{s}", .{ event.project_path, event.node_id }) catch return; + defer self.allocator.free(identity); + self.appendAccessibilityElement(&elements, &owned_identities, "activity-row", identity, event.title, 1, + .{ .left = 18 + @as(i32, @intCast(index * 116)), .top = activity_top + 24, .right = 130 + @as(i32, @intCast(index * 116)), .bottom = activity_top + 58 }, false, true) catch return; + } + self.appendAccessibilityElement(&elements, &owned_identities, "activity-control", "scroll-left", "Scroll activity left", 1, + .{ .left = 184, .top = activity_top, .right = 206, .bottom = activity_top + 24 }, false, true) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "activity-control", "scroll-right", "Scroll activity right", 1, + .{ .left = 208, .top = activity_top, .right = 230, .bottom = activity_top + 24 }, false, true) catch return; + } switch (self.surface) { .project, .workspace => if (self.model.graph) |graph| { if (self.model.open_composite_id) |parent_id| { @@ -3195,6 +3267,8 @@ pub const App = struct { .{ .identity = "quick-chats-header:quick-chats", .target = .quick_chats_header }, .{ .identity = "quick-chats-disclosure:quick-chats", .target = .quick_chats_disclosure }, .{ .identity = "quick-chat-new:quick-chats", .target = .new_quick_chat }, + .{ .identity = "needs-you-header:needs-you", .target = .needs_you_header }, + .{ .identity = "activity-header:activity", .target = .activity_header }, }; for (static_targets) |candidate| { if (Accessibility.worktreeIdentityPayload(candidate.identity) == payload) target = candidate.target; @@ -3284,6 +3358,30 @@ pub const App = struct { target = .{ .quick_chat = chat.id }; } } + 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); + if (Accessibility.worktreeIdentityPayload(identity) == payload) { + if (target != null) return false; + target = .{ .needs_you = index }; + } + } + for (self.model.activity.items, 0..) |event, index| { + const identity = std.fmt.allocPrint(self.allocator, "activity-row:{s}:{s}", .{ event.project_path, event.node_id }) catch return false; + defer self.allocator.free(identity); + if (Accessibility.worktreeIdentityPayload(identity) == payload) { + if (target != null) return false; + target = .{ .activity = index }; + } + } + for ([_][]const u8{ "scroll-left", "scroll-right" }) |control| { + const identity = std.fmt.allocPrint(self.allocator, "activity-control:{s}", .{control}) catch return false; + defer self.allocator.free(identity); + if (Accessibility.worktreeIdentityPayload(identity) == payload) { + if (target != null) return false; + target = .{ .activity = if (std.mem.eql(u8, control, "scroll-left")) 0 else 1 }; + } + } const resolved = target orelse return false; switch (resolved) { .local_section => self.sidebar_state.local_collapsed = !self.sidebar_state.local_collapsed, @@ -3296,6 +3394,19 @@ pub const App = struct { }, .quick_chats_disclosure => self.sidebar_state.chats_collapsed = !self.sidebar_state.chats_collapsed, .new_quick_chat => self.createQuickChat(), + .needs_you_header => {}, + .needs_you => |index| { + if (index >= self.model.attention_entries.items.len) return false; + const entry = self.model.attention_entries.items[index]; + if (self.selectProject(entry.project_path)) _ = self.model.setSelectedID(entry.node.id); + }, + .activity_header => {}, + .activity => |index| { + if (index < self.model.activity.items.len) { + const event = self.model.activity.items[index]; + if (self.selectProject(event.project_path)) _ = self.model.setSelectedID(event.node_id); + } + }, .recent_project => |path| self.openProject(path), .open_project => |path| { if (self.selectProject(path)) { @@ -4036,6 +4147,25 @@ fn onWindowMessage( result.* = 0; return true; } + if (Sidebar.attentionRowAt( + y, + &app.model, + if (app.worktree_inspection) |*value| value else null, + &app.sidebar_state, + app.sidebar_scroll, + )) |attention_index| { + if (attention_index < app.model.attention_entries.items.len) { + const entry = app.model.attention_entries.items[attention_index]; + if (app.selectProject(entry.project_path)) { + _ = app.model.setSelectedID(entry.node.id); + app.setStatus("Needs-you loop selected"); + app.syncAccessibility(); + _ = c.InvalidateRect(hwnd, null, 0); + } + } + result.* = 0; + return true; + } if (Sidebar.rowAt( x, y, &app.model, if (app.worktree_inspection) |*value| value else null, app.sidebar_scroll, workspace_top, &app.sidebar_state, diff --git a/graphcode-windows/src/GraphContextMenu.zig b/graphcode-windows/src/GraphContextMenu.zig index abd136cb..703cc6f8 100644 --- a/graphcode-windows/src/GraphContextMenu.zig +++ b/graphcode-windows/src/GraphContextMenu.zig @@ -58,6 +58,8 @@ pub const Action = enum { remote_project_info, close_project, remove_project, + move_project, + trash_project, delete_project_loops, new_quick_chat, }; @@ -66,7 +68,7 @@ pub const Callback = *const fn (?*anyopaque, Action, Target) void; pub fn requiresConfirmation(action: Action) bool { return action == .delete_node or action == .delete_edge or action == .delete_quick_chat or - action == .remove_project or action == .delete_project_loops; + action == .remove_project or action == .trash_project or action == .delete_project_loops; } pub fn shouldApply(action: Action, confirmed: bool) bool { @@ -103,6 +105,8 @@ const ids = struct { const remote_project_info = 5145; const close_project = 5146; const remove_project = 5147; + const move_project = 5149; + const trash_project = 5151; const delete_project_loops = 5148; const new_quick_chat = 5150; }; @@ -132,6 +136,10 @@ pub fn show( append(menu, ids.reveal_project, "Show in Explorer"); separator(menu); append(menu, ids.close_project, "Close Project"); + if (!project.remote) { + append(menu, ids.move_project, "Move Project..."); + append(menu, ids.trash_project, "Move to Recycle Bin..."); + } append(menu, ids.remove_project, "Remove from GraphCode..."); append(menu, ids.delete_project_loops, "Delete All Loops..."); }, @@ -212,6 +220,8 @@ fn actionForCommand(command: c_int) Action { ids.remote_project_info => .remote_project_info, ids.close_project => .close_project, ids.remove_project => .remove_project, + ids.move_project => .move_project, + ids.trash_project => .trash_project, ids.delete_project_loops => .delete_project_loops, ids.new_quick_chat => .new_quick_chat, else => .none, @@ -264,6 +274,7 @@ test "destructive context actions cannot bypass a cancelled confirmation" { try std.testing.expect(!shouldApply(.delete_edge, false)); try std.testing.expect(!shouldApply(.delete_quick_chat, false)); try std.testing.expect(!shouldApply(.remove_project, false)); + try std.testing.expect(!shouldApply(.trash_project, false)); try std.testing.expect(!shouldApply(.delete_project_loops, false)); try std.testing.expect(shouldApply(.rename_node, false)); } @@ -295,5 +306,7 @@ test "project context commands expose ingress management and safe destructive ac try std.testing.expectEqual(Action.open_project, actionForCommand(ids.open_project)); try std.testing.expectEqual(Action.project_settings, actionForCommand(ids.project_settings)); try std.testing.expectEqual(Action.remove_project, actionForCommand(ids.remove_project)); + try std.testing.expectEqual(Action.move_project, actionForCommand(ids.move_project)); + try std.testing.expectEqual(Action.trash_project, actionForCommand(ids.trash_project)); try std.testing.expectEqual(Action.delete_project_loops, actionForCommand(ids.delete_project_loops)); } diff --git a/graphcode-windows/src/GraphModel.zig b/graphcode-windows/src/GraphModel.zig index 8b35c579..a66e7316 100644 --- a/graphcode-windows/src/GraphModel.zig +++ b/graphcode-windows/src/GraphModel.zig @@ -22,11 +22,15 @@ pub const Node = struct { worktree_path: []u8 = @constCast(""), worktree_branch: []u8 = &.{}, subgraph_json: []u8 = &.{}, + created_at: i64 = 0, }; pub const ActivityEvent = struct { title: []u8, state: []u8, + project_path: []u8 = &.{}, + node_id: []u8 = &.{}, + timestamp: i64 = 0, }; pub const QuickChat = struct { @@ -179,6 +183,8 @@ pub const Model = struct { for (self.activity.items) |event| { self.allocator.free(event.title); self.allocator.free(event.state); + self.allocator.free(event.project_path); + self.allocator.free(event.node_id); } self.activity.deinit(); for (self.quick_chats.items) |chat| freeQuickChat(self.allocator, chat); @@ -887,16 +893,37 @@ pub const Model = struct { self.allocator.free(title); continue; }; - const event = ActivityEvent{ .title = title, .state = state }; + const project_path = self.allocator.dupe(u8, next.project.path) catch { + self.allocator.free(title); + self.allocator.free(state); + continue; + }; + const node_id = self.allocator.dupe(u8, node.id) catch { + self.allocator.free(title); + self.allocator.free(state); + self.allocator.free(project_path); + continue; + }; + const event = ActivityEvent{ + .title = title, + .state = state, + .project_path = project_path, + .node_id = node_id, + .timestamp = std.time.timestamp(), + }; self.activity.insert(0, event) catch { self.allocator.free(event.title); self.allocator.free(event.state); + self.allocator.free(event.project_path); + self.allocator.free(event.node_id); continue; }; if (self.activity.items.len > 32) { const removed = self.activity.pop() orelse continue; self.allocator.free(removed.title); self.allocator.free(removed.state); + self.allocator.free(removed.project_path); + self.allocator.free(removed.node_id); } } } @@ -989,6 +1016,7 @@ fn cloneNode(allocator: std.mem.Allocator, node: Node) !Node { .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, }; } @@ -1047,6 +1075,7 @@ 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), .worktree_path = try duplicateWorktreePath(allocator, scalar_object), .worktree_branch = try duplicateWorktreeBranch(allocator, scalar_object), .subgraph_json = try duplicateJsonObjectOrEmpty(allocator, object, "subGraph"), diff --git a/graphcode-windows/src/Sidebar.zig b/graphcode-windows/src/Sidebar.zig index 948db6e5..d932e49c 100644 --- a/graphcode-windows/src/Sidebar.zig +++ b/graphcode-windows/src/Sidebar.zig @@ -11,6 +11,7 @@ pub const State = struct { chats_collapsed: bool = false, collapsed_projects: std.StringHashMapUnmanaged(void) = .empty, expanded_nodes: std.StringHashMapUnmanaged(void) = .empty, + root_order: std.ArrayListUnmanaged([]u8) = .empty, pub fn init(allocator: std.mem.Allocator) State { return .{ .allocator = allocator }; @@ -19,6 +20,8 @@ pub const State = struct { pub fn deinit(self: *State) void { freeSet(self.allocator, &self.collapsed_projects); freeSet(self.allocator, &self.expanded_nodes); + for (self.root_order.items) |id| self.allocator.free(id); + self.root_order.deinit(self.allocator); self.* = undefined; } @@ -43,11 +46,30 @@ pub const State = struct { self.expanded_nodes = .empty; } + pub fn reorderRoots(self: *State, roots: []const []const u8) !void { + var seen = std.StringHashMapUnmanaged(void){}; + defer freeSet(self.allocator, &seen); + for (roots) |root| { + if (root.len == 0 or seen.contains(root)) return error.InvalidRootOrder; + try seen.put(self.allocator, try self.allocator.dupe(u8, root), {}); + } + var next: std.ArrayListUnmanaged([]u8) = .empty; + errdefer { + for (next.items) |id| self.allocator.free(id); + next.deinit(self.allocator); + } + for (roots) |root| try next.append(self.allocator, try self.allocator.dupe(u8, root)); + for (self.root_order.items) |id| self.allocator.free(id); + self.root_order.deinit(self.allocator); + self.root_order = next; + } + pub fn encode(self: *const State, allocator: std.mem.Allocator) ![]u8 { var result: std.ArrayList(u8) = .empty; errdefer result.deinit(allocator); var iterator = self.expanded_nodes.keyIterator(); while (iterator.next()) |id| try result.writer(allocator).print("expanded\t{s}\n", .{id.*}); + for (self.root_order.items) |id| try result.writer(allocator).print("root\t{s}\n", .{id}); return result.toOwnedSlice(allocator); } @@ -59,6 +81,12 @@ pub const State = struct { if (id.len != 0 and !self.expanded_nodes.contains(id)) try self.expanded_nodes.put(self.allocator, try self.allocator.dupe(u8, id), {}); } + var roots = std.mem.splitScalar(u8, data, '\n'); + while (roots.next()) |line| { + if (!std.mem.startsWith(u8, line, "root\t")) continue; + const id = line["root\t".len..]; + if (id.len != 0) try self.root_order.append(self.allocator, try self.allocator.dupe(u8, id)); + } } }; @@ -190,7 +218,10 @@ pub fn draw( if (row.depth != 0) drawText(hdc, allocator, ">", 28 + indent, row.top, 9, 0x006A6A6A); 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), 168, row.top, 9, stateColor(node.state)); + 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; + 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) drawText(hdc, allocator, if (state.isNodeExpanded(node.id)) "v" else ">", 204, row.top, 9, 0x00B8B8B8); } @@ -229,15 +260,27 @@ pub fn draw( if (model.attention_entries.items.len != 0) { for (model.attention_entries.items[0..@min(model.attention_entries.items.len, 4)]) |entry| { drawText(hdc, allocator, entry.node.title, 24, attention_y, 11, 0x00E6E6E6); - drawText(hdc, allocator, attentionContext(model, entry), 24, attention_y + 15, 9, stateColor(entry.node.state)); + drawText(hdc, allocator, attentionReason(entry.node), 24, attention_y + 15, 9, stateColor(entry.node.state)); attention_y += 34; } } else { for (model.attention.items[0..@min(model.attention.items.len, 4)]) |node| { drawText(hdc, allocator, node.title, 24, attention_y, 11, 0x00E6E6E6); - drawText(hdc, allocator, compactState(node.state), 24, attention_y + 15, 9, stateColor(node.state)); + drawText(hdc, allocator, attentionReason(node), 24, attention_y + 15, 9, stateColor(node.state)); attention_y += 34; } + if (model.activity.items.len != 0) { + const activity_y = section_y + 30 + @as(i32, @intCast(@min(model.attentionCount(), 4) * 34)) + 18; + drawText(hdc, allocator, "Activity", 18, activity_y, 11, 0x00B8B8B8); + var x: i32 = 24; + for (model.activity.items[0..@min(model.activity.items.len, 4)]) |event| { + const stamp = std.fmt.allocPrint(allocator, "{d}m", .{@max(0, @divTrunc(std.time.timestamp() - event.timestamp, 60))}) catch null; + defer if (stamp) |value| allocator.free(value); + drawText(hdc, allocator, event.title, x, activity_y + 18, 10, 0x00E6E6E6); + drawText(hdc, allocator, stamp orelse "", x, activity_y + 32, 9, stateColor(event.state)); + x += 116; + } + } } } @@ -300,6 +343,23 @@ fn attentionContext(model: *const GraphModel.Model, entry: GraphModel.AttentionE for (model.graphs.items) |graph| { if (std.mem.eql(u8, graph.project.path, entry.project_path)) return graph.project.name; } + + pub fn attentionReason(node: GraphModel.Node) []const u8 { + if (std.mem.eql(u8, node.state, "failed")) return "Failed — action needed"; + if (std.mem.eql(u8, node.state, "stalled")) return "Stalled — action needed"; + if (std.mem.eql(u8, node.presence, "awaitingInput")) return "Awaiting your input"; + if (std.mem.eql(u8, node.state, "blocked")) return "Blocked — upstream unavailable"; + return compactState(node.state); + } + + fn elapsedText(allocator: std.mem.Allocator, created_at: i64, now: i64) ![]u8 { + if (created_at <= 0 or now <= created_at) return allocator.dupe(u8, "—"); + const seconds = now - created_at; + if (seconds < 60) return std.fmt.allocPrint(allocator, "{d}s", .{seconds}); + if (seconds < 3600) return std.fmt.allocPrint(allocator, "{d}m", .{@divTrunc(seconds, 60)}); + if (seconds < 86400) return std.fmt.allocPrint(allocator, "{d}h", .{@divTrunc(seconds, 3600)}); + return std.fmt.allocPrint(allocator, "{d}d", .{@divTrunc(seconds, 86400)}); + } return compactState(entry.node.state); } @@ -659,6 +719,20 @@ pub fn contentBottom(model: *const GraphModel.Model, inspection: ?*const Worktre @as(i32, @intCast(@min(model.attentionCount(), 4))) * 34; } +pub fn attentionRowAt( + y: i32, + model: *const GraphModel.Model, + inspection: ?*const WorktreeStatus.Inspection, + state: ?*const State, + scroll_offset: i32, +) ?usize { + if (model.attentionCount() == 0) return null; + const top = sidebarSectionBottom(model, inspection, state) - scroll_offset + 30; + if (y < top) return null; + const index: usize = @intCast(@divTrunc(y - top, 34)); + return if (index < @min(model.attentionCount(), 4)) index else null; +} + pub fn sidebarSectionBottom(model: *const GraphModel.Model, inspection: ?*const WorktreeStatus.Inspection, state: ?*const State) i32 { var rows = appendRows(std.heap.page_allocator, model, inspection, 0, state) catch return Tokens.header_height; defer rows.deinit(std.heap.page_allocator); @@ -725,6 +799,17 @@ test "worktree row hit testing selects only visible rows" { try std.testing.expectEqual(@as(i32, worktreeRowTop(3, 0, 0) - worktreeRowTop(1, 0, 0)), 48); } +test "root reorder validates uniqueness and replaces order atomically" { + var state = State.init(std.testing.allocator); + defer state.deinit(); + try state.reorderRoots(&.{ "root-a", "root-b" }); + try std.testing.expectEqual(@as(usize, 2), state.root_order.items.len); + try std.testing.expectEqualStrings("root-a", state.root_order.items[0]); + try std.testing.expectError(error.InvalidRootOrder, state.reorderRoots(&.{ "root-a", "root-a" })); + try std.testing.expectEqualStrings("root-a", state.root_order.items[0]); + try std.testing.expectEqualStrings("root-b", state.root_order.items[1]); +} + test "shared sidebar layout routes every loop row after project rows and scroll" { var model = GraphModel.Model.init(std.testing.allocator); defer model.deinit(); diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index fc37578f..696b511d 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -47,16 +47,16 @@ Statuses: | Quick Chats group | Selectable header, hover New Chat, disclosure, child rows | The native header remains selectable, reveals a hover-only New Chat action and disclosure, and exposes stable selectable child rows with Rename/Delete context actions. Focused menu tests cover stable chat identity; the live UIA gate invokes New Chat, collapses and restores children, and verifies child runtime identity survives. | Validated | | Local/remote sections | Group labels, independent collapse, folder/network glyphs | LOCAL and REMOTE retain local/folder and remote/network identity and now toggle independently as native section actions. Focused layout coverage validates mixed ordering, and the live UIA gate collapses LOCAL while proving the REMOTE row and its stable automation identity remain present before restoring LOCAL. | Validated | | Project rows | Selection, folder type, hover New Loop, disclosure | Open project rows retain selection and local/remote glyphs, reveal hover-only New Loop and disclosure controls, and collapse/restore their own loop tree without changing row identity. The live UIA gate invokes the project-row New Loop action into the real native node form and exercises project collapse/expand through stable UIA actions. | Validated | -| Nested loop tree | Edge-derived hierarchy, persisted expansion, drag reorder of roots | Handoff edges derive a cycle-safe root/descendant tree; nested rows disclose and collapse by stable node ID, and expanded IDs persist atomically in the GraphCode support directory. Focused tests cover collapsed visibility, non-hierarchical message/spawn edges, and state round-trip; the live UIA gate expands a real nested fixture and verifies the child remains expanded after process restart. Root drag reorder is still absent: Windows has no persisted/sidebar-order command or safe reorder transaction, so this row remains incomplete. | Partial | -| Loop row presentation | Type stripe, title, elapsed time, state indicator | Rows now show a loop-type stripe, title, and compact state indicator using the same semantic colors as workspace chrome. Elapsed time remains absent | Partial | -| Project context menu | Move, worktrees, settings, Explorer, remote info, close, remove, delete loops/project | Recent and open sidebar project rows now expose Open, New Loop, Worktrees, Project Settings, Explorer or remote connection information, Close, Remove, and Delete All Loops actions. Move and filesystem Trash remain absent | Partial | +| Nested loop tree | Edge-derived hierarchy, persisted expansion, drag reorder of roots | Handoff edges derive a cycle-safe root/descendant tree; nested rows disclose and collapse by stable node ID, and expanded IDs persist atomically in the GraphCode support directory. A transactional root-order validator and persisted `root` records now exist, but pointer drag handling, daemon/sidebar-order command integration, and live reorder evidence remain absent | Partial | +| Loop row presentation | Type stripe, title, elapsed time, state indicator | Rows now show a loop-type stripe, title, compact state indicator, and a compact elapsed value derived from `createdAt`. Focused/live executable evidence for the elapsed clock is not yet complete | Partial | +| Project context menu | Move, worktrees, settings, Explorer, remote info, close, remove, delete loops/project | Project rows now expose Move and a confirmed Windows Recycle Bin action for local folders in addition to the existing lifecycle actions. Move is currently routed to Explorer rather than a completed relocation flow, and live filesystem evidence is not yet complete | Partial | | Loop context menu | Open, composite actions, rename, stop, delete | Sidebar and canvas loop rows share stable-ID Open, Rename, Stop, and Delete actions. Composite cards expose Open Group, Pilot Once, and Arm Schedule; the drilled-in canvas addresses mutations through the parent composite. Final live menu and accessibility evidence remains incomplete | Partial | -| Recent projects | Reachable from Add Folder menu | Recent rows are shown directly under “Projects”, without Add Folder grouping or recent/open distinction | Partial | -| Add Folder menu | Open Folder, Clone, Add Remote, recents | The restored File menu visibly exposes Open Folder, Clone Repository, and Add Remote Repository with shortcuts; a recent-folder submenu remains absent | Partial | +| Recent projects | Reachable from Add Folder menu | Recent rows remain directly under “Projects”; the new actions do not yet add the required Add Folder grouping or distinct recent/open presentation | Partial | +| Add Folder menu | Open Folder, Clone, Add Remote, recents | The File menu still exposes Open Folder, Clone Repository, and Add Remote Repository with shortcuts; the recent-folder submenu remains absent | Partial | | Sidebar update banner | Available version and click-to-install action | A persistent footer banner now shows the retained offered version and reopens the native update offer when clicked. A deterministic live fixture captured the banner and verified the click raises `GraphCode Update Available`; the offer still hands installation off to the verified release page | Partial | | Sidebar error footer | Persistent, scoped project-ingress error | Folder, clone, remote, and daemon-open failures now persist in a dedicated red sidebar footer independently of transient status. Successful project ingress clears it, and a deterministic live fixture verifies it stacks below the update offer; long-message wrapping and dedicated UIA semantics remain incomplete | Partial | -| Needs-you section | Navigable list with reason/project and Stop action | Up to four entries now show title plus project context (or compact state fallback) with semantic attention coloring. Selection, explicit reason copy, and Stop context action remain incomplete | Partial | -| Activity strip | Optional bottom strip, summary, attention-only filter, horizontally scrolling actionable events | The optional bottom strip now shows a recent-event count and state-colored event cards with compact state detail. Filtering, timestamps, scrolling, and click navigation remain incomplete | Partial | +| Needs-you section | Navigable list with reason/project and Stop action | Up to four entries now expose selection, explicit reason copy, stable UIA identities, and click/UIA navigation; Stop context routing and a live populated walkthrough remain incomplete | Partial | +| Activity strip | Optional bottom strip, summary, attention-only filter, horizontally scrolling actionable events | Activity events now retain project/node identity and timestamps, render timestamped cards, and expose stable UIA rows plus scroll controls; attention-only filtering, actual horizontal state, and live navigation evidence remain incomplete | Partial | ## Graph overview and project canvas From 7c2554b6d45632ea1fb2b0be142ecc5d8c196c64 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 18 Sep 2026 14:51:18 -0700 Subject: [PATCH 2/4] Fix sidebar parity source formatting Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5116ea46-45e4-48b6-8baf-cd368f667e97 --- graphcode-windows/src/App.zig | 323 ++++++++++++++------------- graphcode-windows/src/GraphModel.zig | 139 ++++++------ graphcode-windows/src/Sidebar.zig | 43 ++-- 3 files changed, 257 insertions(+), 248 deletions(-) diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index a1de6693..8790157b 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -411,9 +411,9 @@ pub const App = struct { 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(); + 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); @@ -1226,33 +1226,33 @@ pub const App = struct { } fn requestUpdateCheck(self: *App, user_initiated: bool) void { - self.update_lock.lock(); - self.update_generation += 1; - self.update_user_initiated = user_initiated; - self.update_pending = true; - if (self.update_thread != null) { - self.update_cancel.store(true, .release); - self.update_lock.unlock(); - return; - } - self.update_pending = false; - self.update_cancel.store(false, .release); + self.update_lock.lock(); + self.update_generation += 1; + self.update_user_initiated = user_initiated; + self.update_pending = true; + if (self.update_thread != null) { + self.update_cancel.store(true, .release); self.update_lock.unlock(); - self.launchUpdateCheck(); + return; + } + self.update_pending = false; + self.update_cancel.store(false, .release); + self.update_lock.unlock(); + self.launchUpdateCheck(); } fn launchUpdateCheck(self: *App) void { + self.update_lock.lock(); + self.update_done = false; + self.update_cancel.store(false, .release); + self.update_lock.unlock(); + self.update_thread = std.Thread.spawn(.{}, updateWorker, .{self}) catch { self.update_lock.lock(); - self.update_done = false; - self.update_cancel.store(false, .release); + self.update_done = true; self.update_lock.unlock(); - self.update_thread = std.Thread.spawn(.{}, updateWorker, .{self}) catch { - self.update_lock.lock(); - self.update_done = true; - self.update_lock.unlock(); - self.setStatus("Update check could not start"); - return; - }; + self.setStatus("Update check could not start"); + return; + }; } fn updateWorker(self: *App) void { @@ -1293,28 +1293,28 @@ pub const App = struct { } fn finishUpdateCheck(self: *App) void { + self.update_lock.lock(); + const done = self.update_done; + self.update_lock.unlock(); + if (!done) return; + if (self.update_thread) |thread| { + thread.join(); + self.update_thread = null; self.update_lock.lock(); - const done = self.update_done; + const pending = self.update_pending; + self.update_pending = false; + const label = self.update_state.label(); + const present_offer = self.update_state.shouldPresentOffer(self.update_user_initiated); + const version = self.update_version; + const release_url = self.update_release_url; self.update_lock.unlock(); - if (!done) return; - if (self.update_thread) |thread| { - thread.join(); - self.update_thread = null; - self.update_lock.lock(); - const pending = self.update_pending; - self.update_pending = false; - const label = self.update_state.label(); - const present_offer = self.update_state.shouldPresentOffer(self.update_user_initiated); - const version = self.update_version; - const release_url = self.update_release_url; - self.update_lock.unlock(); - if (pending) { - self.launchUpdateCheck(); - } else { - self.setStatus(label); - if (present_offer) self.showAvailableUpdate(version, release_url); - } + if (pending) { + self.launchUpdateCheck(); + } else { + self.setStatus(label); + if (present_offer) self.showAvailableUpdate(version, release_url); } + } } fn showAvailableUpdate(self: *App, version: []const u8, release_url: []const u8) void { @@ -1987,7 +1987,6 @@ pub const App = struct { for (graph.nodes.items) |node| { if (node.worktree_path.len != 0) bindings.append(.{ .path = node.worktree_path }) catch {}; } - } const inspection = WorktreeStatus.inspect(self.allocator, path, bindings.items) catch |err| { self.setStatus(switch (err) { @@ -2159,7 +2158,10 @@ pub const App = struct { }) catch return; self.worktree_inspection = inspection; self.worktree_dialog = WorktreeDialog.Dialog.init( - self.allocator, project, inspection.entries.items, .{ .allow_reclaim = true }, + self.allocator, + project, + inspection.entries.items, + .{ .allow_reclaim = true }, ) catch null; if (envFlag("GRAPHCODE_UIA_UPDATE_AVAILABLE")) { self.update_lock.lock(); @@ -2228,7 +2230,12 @@ pub const App = struct { if (bound.worktree_path.len != 0) bindings.append(.{ .path = bound.worktree_path }) catch {}; }; const removed = WorktreeStatus.reclaimSelectedWithPolicy( - self.allocator, path, selected_list.items, bindings.items, policy, true, + self.allocator, + path, + selected_list.items, + bindings.items, + policy, + true, ) catch |err| { self.reclaim_confirmation_armed = false; self.setStatus(switch (err) { @@ -3125,33 +3132,26 @@ pub const App = struct { } if (self.model.attention_entries.items.len != 0) { const section = Sidebar.sidebarSectionBottom(&self.model, if (self.worktree_inspection) |*value| value else null, &self.sidebar_state); - self.appendAccessibilityElement(&elements, &owned_identities, "needs-you-header", "needs-you", "Needs you", 1, - .{ .left = 12, .top = section + 4, .right = 232, .bottom = section + 28 }, false, true) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "needs-you-header", "needs-you", "Needs you", 1, .{ .left = 12, .top = section + 4, .right = 232, .bottom = section + 28 }, false, true) catch return; for (self.model.attention_entries.items[0..@min(self.model.attention_entries.items.len, 4)], 0..) |entry, index| { const identity = std.fmt.allocPrint(self.allocator, "{s}:{s}", .{ entry.project_path, entry.node.id }) catch return; defer self.allocator.free(identity); const name = std.fmt.allocPrint(self.allocator, "{s} - {s}", .{ entry.node.title, Sidebar.attentionReason(entry.node) }) catch return; defer self.allocator.free(name); - self.appendAccessibilityElement(&elements, &owned_identities, "needs-you-row", identity, name, 1, - .{ .left = 18, .top = section + 30 + @as(i32, @intCast(index * 34)), .right = 232, .bottom = section + 60 + @as(i32, @intCast(index * 34)) }, - self.model.selected_node_id != null and std.mem.eql(u8, self.model.selected_node_id.?, entry.node.id), true) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "needs-you-row", identity, name, 1, .{ .left = 18, .top = section + 30 + @as(i32, @intCast(index * 34)), .right = 232, .bottom = section + 60 + @as(i32, @intCast(index * 34)) }, self.model.selected_node_id != null and std.mem.eql(u8, self.model.selected_node_id.?, entry.node.id), true) catch return; } } if (self.model.activity.items.len != 0) { const section = Sidebar.sidebarSectionBottom(&self.model, if (self.worktree_inspection) |*value| value else null, &self.sidebar_state); const activity_top = section + 30 + @as(i32, @intCast(@min(self.model.attentionCount(), 4) * 34)) + 18; - self.appendAccessibilityElement(&elements, &owned_identities, "activity-header", "activity", "Activity", 1, - .{ .left = 12, .top = activity_top, .right = 232, .bottom = activity_top + 24 }, false, true) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "activity-header", "activity", "Activity", 1, .{ .left = 12, .top = activity_top, .right = 232, .bottom = activity_top + 24 }, false, true) catch return; for (self.model.activity.items[0..@min(self.model.activity.items.len, 4)], 0..) |event, index| { const identity = std.fmt.allocPrint(self.allocator, "{s}:{s}", .{ event.project_path, event.node_id }) catch return; defer self.allocator.free(identity); - self.appendAccessibilityElement(&elements, &owned_identities, "activity-row", identity, event.title, 1, - .{ .left = 18 + @as(i32, @intCast(index * 116)), .top = activity_top + 24, .right = 130 + @as(i32, @intCast(index * 116)), .bottom = activity_top + 58 }, false, true) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "activity-row", identity, event.title, 1, .{ .left = 18 + @as(i32, @intCast(index * 116)), .top = activity_top + 24, .right = 130 + @as(i32, @intCast(index * 116)), .bottom = activity_top + 58 }, false, true) catch return; } - self.appendAccessibilityElement(&elements, &owned_identities, "activity-control", "scroll-left", "Scroll activity left", 1, - .{ .left = 184, .top = activity_top, .right = 206, .bottom = activity_top + 24 }, false, true) catch return; - self.appendAccessibilityElement(&elements, &owned_identities, "activity-control", "scroll-right", "Scroll activity right", 1, - .{ .left = 208, .top = activity_top, .right = 230, .bottom = activity_top + 24 }, false, true) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "activity-control", "scroll-left", "Scroll activity left", 1, .{ .left = 184, .top = activity_top, .right = 206, .bottom = activity_top + 24 }, false, true) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "activity-control", "scroll-right", "Scroll activity right", 1, .{ .left = 208, .top = activity_top, .right = 230, .bottom = activity_top + 24 }, false, true) catch return; } switch (self.surface) { .project, .workspace => if (self.model.graph) |graph| { @@ -4001,22 +4001,22 @@ fn onWindowMessage( const index = app.model.selectedIndex() orelse graph.nodes.items.len; if (index < graph.nodes.items.len) { const node = graph.nodes.items[index]; - if (TerminalWorkspace.loopBarActionAt( - rail_left, - Tokens.header_height, - client.right - Tokens.loop_detail_width, - x, - y, - isResolvedLoopState(node.state), - )) |action| { - switch (action) { - .stop => app.stopSelectedNode(), - .show_graph => app.handleAction(.show_graph), + if (TerminalWorkspace.loopBarActionAt( + rail_left, + Tokens.header_height, + client.right - Tokens.loop_detail_width, + x, + y, + isResolvedLoopState(node.state), + )) |action| { + switch (action) { + .stop => app.stopSelectedNode(), + .show_graph => app.handleAction(.show_graph), + } + _ = c.InvalidateRect(hwnd, null, 0); + result.* = 0; + return true; } - _ = c.InvalidateRect(hwnd, null, 0); - result.* = 0; - return true; - } } } } @@ -4167,102 +4167,107 @@ fn onWindowMessage( return true; } if (Sidebar.rowAt( - x, y, &app.model, if (app.worktree_inspection) |*value| value else null, - app.sidebar_scroll, workspace_top, &app.sidebar_state, + x, + y, + &app.model, + if (app.worktree_inspection) |*value| value else null, + app.sidebar_scroll, + workspace_top, + &app.sidebar_state, )) |row| { const ctrl = (@as(i32, c.GetKeyState(c.VK_CONTROL)) & 0x8000) != 0; - switch (row.kind) { - .local_heading => app.sidebar_state.local_collapsed = !app.sidebar_state.local_collapsed, - .remote_heading => app.sidebar_state.remote_collapsed = !app.sidebar_state.remote_collapsed, - .project => app.openProject(app.model.recent_projects.items[row.index].path), - .open_project => if (row.project_path) |path| { - if (x >= 198 and row.has_children) { - app.sidebar_state.toggleProject(path) catch app.setStatus("Sidebar state could not be updated"); - app.clampSidebarScroll(); - app.syncAccessibility(); - _ = c.InvalidateRect(hwnd, null, 0); - result.* = 0; - return true; - } - if (x >= 174 and x < 198) { - if (app.selectProject(path)) app.createNode(); - result.* = 0; - return true; - } - if (app.selectProject(path)) { - app.surface = .project; - app.workspace_controls.panel_visible = false; - app.layoutWorkspace(); - app.clearEdgeSelection(); - app.rebindWorkspace(path); - } - }, - .overview => app.openGlobalOverview(), - .loop => if (row.project_path) |path| if (app.model.graphFor(path)) |graph| { - if (row.index < graph.nodes.items.len) { + switch (row.kind) { + .local_heading => app.sidebar_state.local_collapsed = !app.sidebar_state.local_collapsed, + .remote_heading => app.sidebar_state.remote_collapsed = !app.sidebar_state.remote_collapsed, + .project => app.openProject(app.model.recent_projects.items[row.index].path), + .open_project => if (row.project_path) |path| { if (x >= 198 and row.has_children) { - app.sidebar_state.toggleNode(graph.nodes.items[row.index].id) catch app.setStatus("Sidebar state could not be updated"); - if (app.sidebar_store) |*store| store.save(&app.sidebar_state) catch app.setStatus("Sidebar expansion could not be saved"); + app.sidebar_state.toggleProject(path) catch app.setStatus("Sidebar state could not be updated"); app.clampSidebarScroll(); app.syncAccessibility(); _ = c.InvalidateRect(hwnd, null, 0); result.* = 0; return true; } - if (!app.selectProject(path)) return true; - app.surface = .workspace; - app.workspace_controls.panel_visible = true; + if (x >= 174 and x < 198) { + if (app.selectProject(path)) app.createNode(); + result.* = 0; + return true; + } + if (app.selectProject(path)) { + app.surface = .project; + app.workspace_controls.panel_visible = false; + app.layoutWorkspace(); + app.clearEdgeSelection(); + app.rebindWorkspace(path); + } + }, + .overview => app.openGlobalOverview(), + .loop => if (row.project_path) |path| if (app.model.graphFor(path)) |graph| { + if (row.index < graph.nodes.items.len) { + if (x >= 198 and row.has_children) { + app.sidebar_state.toggleNode(graph.nodes.items[row.index].id) catch app.setStatus("Sidebar state could not be updated"); + if (app.sidebar_store) |*store| store.save(&app.sidebar_state) catch app.setStatus("Sidebar expansion could not be saved"); + app.clampSidebarScroll(); + app.syncAccessibility(); + _ = c.InvalidateRect(hwnd, null, 0); + result.* = 0; + return true; + } + if (!app.selectProject(path)) return true; + app.surface = .workspace; + app.workspace_controls.panel_visible = true; + app.layoutWorkspace(); + app.layoutEmptyStateControls(); + app.clearEdgeSelection(); + app.rebindWorkspace(path); + const selected_graph = app.model.graph orelse return true; + if (row.index >= selected_graph.nodes.items.len) return true; + _ = app.selectNodeIndex(row.index); + if (app.workspace) |workspace| { + workspace.openNode(0, selected_graph.nodes.items[row.index].id) catch { + app.setStatus("Unable to open selected loop"); + }; + workspace.focus(0); + } + } + }, + .worktree => if (app.worktree_inspection) |inspection| { + if (ctrl) { + _ = app.toggleWorktreeRow(row.index); + } else { + _ = app.selectWorktreeRow(inspection.entries.items[row.index].path); + } + app.ensureWorktreeVisible(row.index); + }, + .quick_chat_overview => { + if (x >= 198 and app.model.quick_chats.items.len != 0) { + app.sidebar_state.chats_collapsed = !app.sidebar_state.chats_collapsed; + app.clampSidebarScroll(); + app.syncAccessibility(); + _ = c.InvalidateRect(hwnd, null, 0); + result.* = 0; + return true; + } + if (x >= 174 and x < 198) { + app.createQuickChat(); + result.* = 0; + return true; + } + app.surface = .quick_chats; + app.workspace_controls.panel_visible = false; app.layoutWorkspace(); app.layoutEmptyStateControls(); - app.clearEdgeSelection(); - app.rebindWorkspace(path); - const selected_graph = app.model.graph orelse return true; - if (row.index >= selected_graph.nodes.items.len) return true; - _ = app.selectNodeIndex(row.index); - if (app.workspace) |workspace| { - workspace.openNode(0, selected_graph.nodes.items[row.index].id) catch { - app.setStatus("Unable to open selected loop"); - }; - workspace.focus(0); - } - } - }, - .worktree => if (app.worktree_inspection) |inspection| { - if (ctrl) { - _ = app.toggleWorktreeRow(row.index); - } else { - _ = app.selectWorktreeRow(inspection.entries.items[row.index].path); - } - app.ensureWorktreeVisible(row.index); - }, - .quick_chat_overview => { - if (x >= 198 and app.model.quick_chats.items.len != 0) { - app.sidebar_state.chats_collapsed = !app.sidebar_state.chats_collapsed; - app.clampSidebarScroll(); - app.syncAccessibility(); - _ = c.InvalidateRect(hwnd, null, 0); - result.* = 0; - return true; - } - if (x >= 174 and x < 198) { - app.createQuickChat(); - result.* = 0; - return true; - } - app.surface = .quick_chats; - app.workspace_controls.panel_visible = false; - app.layoutWorkspace(); - app.layoutEmptyStateControls(); - }, - .quick_chat => if (row.index < app.model.quick_chats.items.len) { - app.client.sendOpenQuickChat(app.model.quick_chats.items[row.index].id); - app.setStatus("Opening quick chat..."); - }, - } - app.clampSidebarScroll(); - app.syncAccessibility(); - _ = c.InvalidateRect(hwnd, null, 0); - result.* = 0; + }, + .quick_chat => if (row.index < app.model.quick_chats.items.len) { + app.client.sendOpenQuickChat(app.model.quick_chats.items[row.index].id); + app.setStatus("Opening quick chat..."); + }, + } + app.clampSidebarScroll(); + app.syncAccessibility(); + _ = c.InvalidateRect(hwnd, null, 0); + result.* = 0; return true; } } diff --git a/graphcode-windows/src/GraphModel.zig b/graphcode-windows/src/GraphModel.zig index a66e7316..a074db2d 100644 --- a/graphcode-windows/src/GraphModel.zig +++ b/graphcode-windows/src/GraphModel.zig @@ -574,61 +574,61 @@ pub const Model = struct { } fn decodeQuickChats(self: *Model, frame: []const u8, kind: Wire.EventKind) !void { - if (kind == .quick_chats) { - for (self.quick_chats.items) |chat| freeQuickChat(self.allocator, chat); - self.quick_chats.clearRetainingCapacity(); - } - const marker = switch (kind) { - .quick_chats => "\"quickChatsListed\"", - .quick_chat_changed => "\"quickChatChanged\"", - .quick_chat_deleted => "\"quickChatDeleted\"", - .quick_chat_activity => "\"quickChatActivity\"", - else => return, - }; - const start = std.mem.indexOf(u8, frame, marker) orelse return; - if (kind == .quick_chat_deleted) { - const id = Wire.jsonString(frame[start..], "quickChatDeleted") orelse return; - var index: usize = 0; - while (index < self.quick_chats.items.len) : (index += 1) { - if (std.mem.eql(u8, self.quick_chats.items[index].id, id)) { - const removed = self.quick_chats.orderedRemove(index); - freeQuickChat(self.allocator, removed); - return; - } + if (kind == .quick_chats) { + for (self.quick_chats.items) |chat| freeQuickChat(self.allocator, chat); + self.quick_chats.clearRetainingCapacity(); + } + const marker = switch (kind) { + .quick_chats => "\"quickChatsListed\"", + .quick_chat_changed => "\"quickChatChanged\"", + .quick_chat_deleted => "\"quickChatDeleted\"", + .quick_chat_activity => "\"quickChatActivity\"", + else => return, + }; + const start = std.mem.indexOf(u8, frame, marker) orelse return; + if (kind == .quick_chat_deleted) { + const id = Wire.jsonString(frame[start..], "quickChatDeleted") orelse return; + var index: usize = 0; + while (index < self.quick_chats.items.len) : (index += 1) { + if (std.mem.eql(u8, self.quick_chats.items[index].id, id)) { + const removed = self.quick_chats.orderedRemove(index); + freeQuickChat(self.allocator, removed); + return; } - return; } - const open = indexOfByte(frame, start, if (kind == .quick_chats) '[' else '{') orelse return; - const close = findClosing(frame, open, if (kind == .quick_chats) '[' else '{', if (kind == .quick_chats) ']' else '}') orelse return; - if (kind == .quick_chats) { - var cursor = open + 1; - while (cursor < close) { - const object_start = indexOfByte(frame, cursor, '{') orelse break; - if (object_start >= close) break; - const object_end = findClosing(frame, object_start, '{', '}') orelse break; - try self.upsertQuickChat(frame[object_start .. object_end + 1]); - cursor = object_end + 1; - } - } else if (kind == .quick_chat_activity) { - const object = frame[open .. close + 1]; - const id = Wire.jsonString(object, "id") orelse return; - const activity_start = std.mem.indexOf(u8, object, "\"activity\"") orelse return; - const activity_open = indexOfByte(object, activity_start, '{') orelse return; - const activity_close = findClosing(object, activity_open, '{', '}') orelse return; - const activity_object = object[activity_open .. activity_close + 1]; - const sequence = Wire.jsonNumber(activity_object, "sequence") orelse 0; - const activity = Wire.jsonString(activity_object, "text") orelse ""; - for (self.quick_chats.items) |*chat| { - if (std.mem.eql(u8, chat.id, id) and sequence >= chat.activity_sequence) { - self.allocator.free(chat.activity); - chat.activity = try self.allocator.dupe(u8, activity); - chat.activity_sequence = sequence; - } + return; + } + const open = indexOfByte(frame, start, if (kind == .quick_chats) '[' else '{') orelse return; + const close = findClosing(frame, open, if (kind == .quick_chats) '[' else '{', if (kind == .quick_chats) ']' else '}') orelse return; + if (kind == .quick_chats) { + var cursor = open + 1; + while (cursor < close) { + const object_start = indexOfByte(frame, cursor, '{') orelse break; + if (object_start >= close) break; + const object_end = findClosing(frame, object_start, '{', '}') orelse break; + try self.upsertQuickChat(frame[object_start .. object_end + 1]); + cursor = object_end + 1; + } + } else if (kind == .quick_chat_activity) { + const object = frame[open .. close + 1]; + const id = Wire.jsonString(object, "id") orelse return; + const activity_start = std.mem.indexOf(u8, object, "\"activity\"") orelse return; + const activity_open = indexOfByte(object, activity_start, '{') orelse return; + const activity_close = findClosing(object, activity_open, '{', '}') orelse return; + const activity_object = object[activity_open .. activity_close + 1]; + const sequence = Wire.jsonNumber(activity_object, "sequence") orelse 0; + const activity = Wire.jsonString(activity_object, "text") orelse ""; + for (self.quick_chats.items) |*chat| { + if (std.mem.eql(u8, chat.id, id) and sequence >= chat.activity_sequence) { + self.allocator.free(chat.activity); + chat.activity = try self.allocator.dupe(u8, activity); + chat.activity_sequence = sequence; } - } else { - try self.upsertQuickChat(frame[open .. close + 1]); } + } else { + try self.upsertQuickChat(frame[open .. close + 1]); } + } fn upsertQuickChat(self: *Model, object: []const u8) !void { const id = duplicateJsonString(self.allocator, object, "id") catch return; @@ -692,7 +692,8 @@ pub const Model = struct { } const was_selected = if (self.selected_project_path) |path| std.mem.eql(u8, path, graph.project.path) - else self.graph == null; + else + self.graph == null; const prior_node_id: ?[]const u8 = if (was_selected) self.selected_node_id else null; self.recordActivity(graph); try self.upsertSummary(&graph); @@ -865,23 +866,29 @@ pub const Model = struct { } fn rebuildAttention(self: *Model) void { - for (self.attention.items) |node| freeNode(self.allocator, node); - self.attention.clearRetainingCapacity(); - for (self.attention_entries.items) |entry| freeAttentionEntry(self.allocator, entry); - self.attention_entries.clearRetainingCapacity(); - for (self.graphs.items) |summary| { - for (summary.nodes.items) |node| { - if (!needsAttention(node) and !(std.mem.eql(u8, node.state, "blocked") and isStrandedSummary(&summary, node.id))) continue; - const node_copy = cloneNode(self.allocator, node) catch continue; - const entry = AttentionEntry{ .project_path = self.allocator.dupe(u8, summary.project.path) catch { freeNode(self.allocator, node_copy); continue; }, .node = node_copy }; - self.attention_entries.append(entry) catch { freeAttentionEntry(self.allocator, entry); continue; }; - const compat = cloneNode(self.allocator, node) catch continue; - self.attention.append(compat) catch freeNode(self.allocator, compat); - } + for (self.attention.items) |node| freeNode(self.allocator, node); + self.attention.clearRetainingCapacity(); + for (self.attention_entries.items) |entry| freeAttentionEntry(self.allocator, entry); + self.attention_entries.clearRetainingCapacity(); + for (self.graphs.items) |summary| { + for (summary.nodes.items) |node| { + if (!needsAttention(node) and !(std.mem.eql(u8, node.state, "blocked") and isStrandedSummary(&summary, node.id))) continue; + const node_copy = cloneNode(self.allocator, node) catch continue; + const entry = AttentionEntry{ .project_path = self.allocator.dupe(u8, summary.project.path) catch { + freeNode(self.allocator, node_copy); + continue; + }, .node = node_copy }; + self.attention_entries.append(entry) catch { + freeAttentionEntry(self.allocator, entry); + continue; + }; + const compat = cloneNode(self.allocator, node) catch continue; + self.attention.append(compat) catch freeNode(self.allocator, compat); } - std.sort.heap(AttentionEntry, self.attention_entries.items, {}, compareAttentionEntry); - std.sort.heap(Node, self.attention.items, {}, compareAttentionNode); } + std.sort.heap(AttentionEntry, self.attention_entries.items, {}, compareAttentionEntry); + std.sort.heap(Node, self.attention.items, {}, compareAttentionNode); + } fn recordActivity(self: *Model, next: Graph) void { const previous = self.graphFor(next.project.path) orelse return; diff --git a/graphcode-windows/src/Sidebar.zig b/graphcode-windows/src/Sidebar.zig index d932e49c..0bf083db 100644 --- a/graphcode-windows/src/Sidebar.zig +++ b/graphcode-windows/src/Sidebar.zig @@ -201,11 +201,11 @@ pub fn draw( .open_project => if (row.project_path) |path| if (model.graphFor(path)) |summary| { const selected = if (model.selected_project_path) |selected_path| std.mem.eql(u8, selected_path, path) - else false; + else + false; drawText(hdc, allocator, if (state.isProjectCollapsed(path)) ">" else "v", 18, row.top, 9, 0x007A7A7A); drawText(hdc, allocator, if (summary.project.isRemote()) "R" else "L", 31, row.top + 1, 9, 0x007A7A7A); - drawText(hdc, allocator, summary.project.name, 44, row.top, 13, - if (selected) 0x00FFFFFF else 0x00D0D0D0); + drawText(hdc, allocator, summary.project.name, 44, row.top, 13, if (selected) 0x00FFFFFF else 0x00D0D0D0); if (hover_y >= row.top and hover_y < row.top + 24) { drawText(hdc, allocator, "+", 181, row.top, 13, 0x00B8B8B8); if (row.has_children) drawText(hdc, allocator, if (state.isProjectCollapsed(path)) ">" else "v", 204, row.top, 9, 0x00B8B8B8); @@ -232,8 +232,7 @@ pub fn draw( if (selected and WorktreeStatus.decision(entry) == .reclaimable) fill(hdc, rect(12, row.top - 3, Tokens.sidebar_width - 12, row.top + 25), 0x003A3A44); drawText(hdc, allocator, entry.path, 24, row.top, 11, 0x00E6E6E6); - drawText(hdc, allocator, reason(entry), 24, row.top + 14, 10, - if (WorktreeStatus.decision(entry) == .reclaimable) 0x0078D7A8 else 0x00FFCD7A); + drawText(hdc, allocator, reason(entry), 24, row.top + 14, 10, if (WorktreeStatus.decision(entry) == .reclaimable) 0x0078D7A8 else 0x00FFCD7A); }, .quick_chat_overview => { drawText(hdc, allocator, if (state.chats_collapsed) ">" else "v", 18, row.top, 9, 0x007A7A7A); @@ -282,7 +281,6 @@ pub fn draw( } } } - } if (ingress_error.len != 0) { const bounds = errorFooterRect(viewport_bottom); @@ -343,24 +341,24 @@ fn attentionContext(model: *const GraphModel.Model, entry: GraphModel.AttentionE for (model.graphs.items) |graph| { if (std.mem.eql(u8, graph.project.path, entry.project_path)) return graph.project.name; } + return compactState(entry.node.state); +} - pub fn attentionReason(node: GraphModel.Node) []const u8 { - if (std.mem.eql(u8, node.state, "failed")) return "Failed — action needed"; - if (std.mem.eql(u8, node.state, "stalled")) return "Stalled — action needed"; - if (std.mem.eql(u8, node.presence, "awaitingInput")) return "Awaiting your input"; - if (std.mem.eql(u8, node.state, "blocked")) return "Blocked — upstream unavailable"; - return compactState(node.state); - } +pub fn attentionReason(node: GraphModel.Node) []const u8 { + if (std.mem.eql(u8, node.state, "failed")) return "Failed - action needed"; + if (std.mem.eql(u8, node.state, "stalled")) return "Stalled - action needed"; + if (std.mem.eql(u8, node.presence, "awaitingInput")) return "Awaiting your input"; + if (std.mem.eql(u8, node.state, "blocked")) return "Blocked - upstream unavailable"; + return compactState(node.state); +} - fn elapsedText(allocator: std.mem.Allocator, created_at: i64, now: i64) ![]u8 { - if (created_at <= 0 or now <= created_at) return allocator.dupe(u8, "—"); - const seconds = now - created_at; - if (seconds < 60) return std.fmt.allocPrint(allocator, "{d}s", .{seconds}); - if (seconds < 3600) return std.fmt.allocPrint(allocator, "{d}m", .{@divTrunc(seconds, 60)}); - if (seconds < 86400) return std.fmt.allocPrint(allocator, "{d}h", .{@divTrunc(seconds, 3600)}); - return std.fmt.allocPrint(allocator, "{d}d", .{@divTrunc(seconds, 86400)}); - } - return compactState(entry.node.state); +fn elapsedText(allocator: std.mem.Allocator, created_at: i64, now: i64) ![]u8 { + if (created_at <= 0 or now <= created_at) return allocator.dupe(u8, "-"); + const seconds = now - created_at; + if (seconds < 60) return std.fmt.allocPrint(allocator, "{d}s", .{seconds}); + if (seconds < 3600) return std.fmt.allocPrint(allocator, "{d}m", .{@divTrunc(seconds, 60)}); + if (seconds < 86400) return std.fmt.allocPrint(allocator, "{d}h", .{@divTrunc(seconds, 3600)}); + return std.fmt.allocPrint(allocator, "{d}d", .{@divTrunc(seconds, 86400)}); } pub fn loopRowTop(project_count: usize, index: usize) i32 { @@ -848,7 +846,6 @@ test "shared sidebar layout routes every loop row after project rows and scroll" try std.testing.expectEqual(RowKind.loop, row.kind); try std.testing.expectEqual(index, row.index); } - } test "multi-project rows share render and hit-test offsets with project identity" { From 6df16caf31d1ac9336145e5074095cea6ea8ac75 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 18 Sep 2026 21:08:33 -0700 Subject: [PATCH 3/4] Fix sidebar activity row compile casts Cast bounded row indexes before pixel multiplication so Zig does not infer tiny integer types from four-row slices. This fixes the windows-shell and windows-spikes build failure at App.zig and Sidebar.zig. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5116ea46-45e4-48b6-8baf-cd368f667e97 --- graphcode-windows/src/App.zig | 9 ++++++--- graphcode-windows/src/Sidebar.zig | 25 +++++++++++++------------ 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 8790157b..ea64507e 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -3134,21 +3134,24 @@ pub const App = struct { const section = Sidebar.sidebarSectionBottom(&self.model, if (self.worktree_inspection) |*value| value else null, &self.sidebar_state); self.appendAccessibilityElement(&elements, &owned_identities, "needs-you-header", "needs-you", "Needs you", 1, .{ .left = 12, .top = section + 4, .right = 232, .bottom = section + 28 }, false, true) catch return; for (self.model.attention_entries.items[0..@min(self.model.attention_entries.items.len, 4)], 0..) |entry, index| { + const row_offset = @as(i32, @intCast(index)) * 34; const identity = std.fmt.allocPrint(self.allocator, "{s}:{s}", .{ entry.project_path, entry.node.id }) catch return; defer self.allocator.free(identity); const name = std.fmt.allocPrint(self.allocator, "{s} - {s}", .{ entry.node.title, Sidebar.attentionReason(entry.node) }) catch return; defer self.allocator.free(name); - self.appendAccessibilityElement(&elements, &owned_identities, "needs-you-row", identity, name, 1, .{ .left = 18, .top = section + 30 + @as(i32, @intCast(index * 34)), .right = 232, .bottom = section + 60 + @as(i32, @intCast(index * 34)) }, self.model.selected_node_id != null and std.mem.eql(u8, self.model.selected_node_id.?, entry.node.id), true) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "needs-you-row", identity, name, 1, .{ .left = 18, .top = section + 30 + row_offset, .right = 232, .bottom = section + 60 + row_offset }, self.model.selected_node_id != null and std.mem.eql(u8, self.model.selected_node_id.?, entry.node.id), true) catch return; } } if (self.model.activity.items.len != 0) { const section = Sidebar.sidebarSectionBottom(&self.model, if (self.worktree_inspection) |*value| value else null, &self.sidebar_state); - const activity_top = section + 30 + @as(i32, @intCast(@min(self.model.attentionCount(), 4) * 34)) + 18; + const attention_rows = @min(self.model.attentionCount(), 4); + const activity_top = section + 30 + (@as(i32, @intCast(attention_rows)) * 34) + 18; self.appendAccessibilityElement(&elements, &owned_identities, "activity-header", "activity", "Activity", 1, .{ .left = 12, .top = activity_top, .right = 232, .bottom = activity_top + 24 }, false, true) catch return; for (self.model.activity.items[0..@min(self.model.activity.items.len, 4)], 0..) |event, index| { + const row_offset = @as(i32, @intCast(index)) * 116; const identity = std.fmt.allocPrint(self.allocator, "{s}:{s}", .{ event.project_path, event.node_id }) catch return; defer self.allocator.free(identity); - self.appendAccessibilityElement(&elements, &owned_identities, "activity-row", identity, event.title, 1, .{ .left = 18 + @as(i32, @intCast(index * 116)), .top = activity_top + 24, .right = 130 + @as(i32, @intCast(index * 116)), .bottom = activity_top + 58 }, false, true) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "activity-row", identity, event.title, 1, .{ .left = 18 + row_offset, .top = activity_top + 24, .right = 130 + row_offset, .bottom = activity_top + 58 }, false, true) catch return; } self.appendAccessibilityElement(&elements, &owned_identities, "activity-control", "scroll-left", "Scroll activity left", 1, .{ .left = 184, .top = activity_top, .right = 206, .bottom = activity_top + 24 }, false, true) catch return; self.appendAccessibilityElement(&elements, &owned_identities, "activity-control", "scroll-right", "Scroll activity right", 1, .{ .left = 208, .top = activity_top, .right = 230, .bottom = activity_top + 24 }, false, true) catch return; diff --git a/graphcode-windows/src/Sidebar.zig b/graphcode-windows/src/Sidebar.zig index 0bf083db..88ccfcaa 100644 --- a/graphcode-windows/src/Sidebar.zig +++ b/graphcode-windows/src/Sidebar.zig @@ -268,18 +268,19 @@ pub fn draw( drawText(hdc, allocator, attentionReason(node), 24, attention_y + 15, 9, stateColor(node.state)); attention_y += 34; } - if (model.activity.items.len != 0) { - const activity_y = section_y + 30 + @as(i32, @intCast(@min(model.attentionCount(), 4) * 34)) + 18; - drawText(hdc, allocator, "Activity", 18, activity_y, 11, 0x00B8B8B8); - var x: i32 = 24; - for (model.activity.items[0..@min(model.activity.items.len, 4)]) |event| { - const stamp = std.fmt.allocPrint(allocator, "{d}m", .{@max(0, @divTrunc(std.time.timestamp() - event.timestamp, 60))}) catch null; - defer if (stamp) |value| allocator.free(value); - drawText(hdc, allocator, event.title, x, activity_y + 18, 10, 0x00E6E6E6); - drawText(hdc, allocator, stamp orelse "", x, activity_y + 32, 9, stateColor(event.state)); - x += 116; - } - } + } + } + if (model.activity.items.len != 0) { + const attention_rows = @min(model.attentionCount(), 4); + const activity_y = section_y + 30 + (@as(i32, @intCast(attention_rows)) * 34) + 18; + drawText(hdc, allocator, "Activity", 18, activity_y, 11, 0x00B8B8B8); + var x: i32 = 24; + for (model.activity.items[0..@min(model.activity.items.len, 4)]) |event| { + const stamp = std.fmt.allocPrint(allocator, "{d}m", .{@max(0, @divTrunc(std.time.timestamp() - event.timestamp, 60))}) catch null; + defer if (stamp) |value| allocator.free(value); + drawText(hdc, allocator, event.title, x, activity_y + 18, 10, 0x00E6E6E6); + drawText(hdc, allocator, stamp orelse "", x, activity_y + 32, 9, stateColor(event.state)); + x += 116; } } if (ingress_error.len != 0) { From a236241591481ab1ad0df6bcfc90565ae481234e Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 18 Sep 2026 21:30:02 -0700 Subject: [PATCH 4/4] Keep generated sidebar UIA names alive Preserve synthesized Needs-you row names until accessibility sync copies them so the native UIA provider does not receive empty names during the live gate. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5116ea46-45e4-48b6-8baf-cd368f667e97 --- graphcode-windows/src/App.zig | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index ea64507e..1660bd16 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -3138,7 +3138,10 @@ pub const App = struct { const identity = std.fmt.allocPrint(self.allocator, "{s}:{s}", .{ entry.project_path, entry.node.id }) catch return; defer self.allocator.free(identity); const name = std.fmt.allocPrint(self.allocator, "{s} - {s}", .{ entry.node.title, Sidebar.attentionReason(entry.node) }) catch return; - defer self.allocator.free(name); + owned_identities.append(name) catch { + self.allocator.free(name); + return; + }; self.appendAccessibilityElement(&elements, &owned_identities, "needs-you-row", identity, name, 1, .{ .left = 18, .top = section + 30 + row_offset, .right = 232, .bottom = section + 60 + row_offset }, self.model.selected_node_id != null and std.mem.eql(u8, self.model.selected_node_id.?, entry.node.id), true) catch return; } }