From b87d6749f45c58c32c35faf43828f5203c45c75f Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Thu, 8 Oct 2026 15:03:51 -0700 Subject: [PATCH] Keep every open project's graph live in the Windows shell The shell's v2 hello subscribed only to the most recently opened project, so after Ctrl+O the daemon filtered out graphChanged events for every other open project (beta17 ProjectLiveUpdates). The shell is a sidebar client, so the hello now never narrows delivery. The daemon sends v2 clients presence ticks as nodesChanged deltas, which the shell ignored, so Needs you stayed stale until an unrelated snapshot. The model now folds deltas into the held snapshot by node ID with a revision guard and applies the result as a snapshot, matching macOS foldDelta. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/windows-shell.ps1 | 6 +- graphcode-windows/README.md | 10 +++ graphcode-windows/src/App.zig | 61 ++++++++++++++- graphcode-windows/src/DaemonClient.zig | 53 ++++++++++--- graphcode-windows/src/GraphModel.zig | 100 ++++++++++++++++++++++++- graphcode-windows/src/Wire.zig | 3 + 6 files changed, 215 insertions(+), 18 deletions(-) diff --git a/Tools/windows/windows-shell.ps1 b/Tools/windows/windows-shell.ps1 index ccec9fc5..d72e14ef 100644 --- a/Tools/windows/windows-shell.ps1 +++ b/Tools/windows/windows-shell.ps1 @@ -366,7 +366,6 @@ try { foreach ($property in @( "protocolConnected", "correlatedRequests", - "subscriptionSeen", "reconnectObserved", "graphSent" )) { @@ -374,6 +373,11 @@ try { throw "Stub daemon evidence failed: $property" } } + # The shell is a sidebar client: a hello that names projectPaths would stop the + # daemon delivering every other open project's live updates (beta17 ProjectLiveUpdates). + if ([bool] $evidence.subscriptionSeen) { + throw "Stub daemon evidence failed: a shell hello narrowed delivery to specific projects" + } if ($evidence.error) { throw "Stub daemon reported an error: $($evidence.error)" } diff --git a/graphcode-windows/README.md b/graphcode-windows/README.md index 7eb8e6d9..ce49cb07 100644 --- a/graphcode-windows/README.md +++ b/graphcode-windows/README.md @@ -180,6 +180,16 @@ deleted-node, or changed-type results do not send or retarget a command. Pure production-adapter tests and Swift fixture decoding cover this path; native keyboard/UIA and real-daemon promotion remain unverified. +Live graph updates reach every open project, not only the focused one. The +daemon connection is a sidebar client (`restoreOpenProjects`), and its v2 hello +carries no `subscription.projectPaths` filter; the "observation subscription" +above records the focused project and drives a drained re-dial, never a delivery +filter. Presence/activity ticks arrive as `nodesChanged` deltas, which the model +folds into the held snapshot by node ID (ignoring unknown loops and deltas no +newer than the held `revision`) and then applies like a `graphChanged`, matching +macOS `AppFeature.foldDelta`. Unit tests cover the hello and the delta fold; the +Dev Box multi-project and Needs-you walkthroughs are the runtime evidence. + The shell exposes a native File/Loop/Terminal/View/Help menu bar. Menu items share the same application action router as keyboard shortcuts, and project actions use the Windows `IFileOpenDialog` folder picker. The no-project state diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 9f34f723..cc61bb7e 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -1732,7 +1732,9 @@ pub const App = struct { self.queueProject(self.model.recent_projects.items[0].path); } }, - .graph_changed => { + // A presence delta is folded into the held snapshot by the model, so the UI + // treats it as the snapshot it amounts to. + .graph_changed, .nodes_changed => { if (incoming_project_path) |path| { if (self.pending_rebind_path.len != 0 and (std.mem.eql(u8, path, self.pending_rebind_path) or @@ -1886,7 +1888,7 @@ pub const App = struct { }, else => {}, } - if (event == .graph_changed) publish(self); + if (event == .graph_changed or event == .nodes_changed) publish(self); } /// Runs after every graph change. It re-observes only the open loop's own pane, in the @@ -12058,6 +12060,61 @@ test "graphChanged republishes renamed project card and sidebar accessibility na try std.testing.expectEqualStrings("Renamed loop", app.model.graphFor("A").?.nodes.items[0].title); } +test "nodesChanged presence delta republishes Needs you for a non-selected project" { + const Probe = struct { + var sink: @This() = .{}; + var updates: usize = 0; + var needs_you_row = false; + + fn publish(app: *App) void { + app.syncAccessibilityTo(&sink, .{ .left = 0, .top = 0, .right = 1200, .bottom = 900 }); + } + + fn syncCanvasBounds(_: *@This(), _: c.RECT) void {} + + fn syncElements(_: *@This(), _: []const u8, elements: []const Accessibility.DynamicElement, _: WorktreeStatus.Policy, _: Accessibility.WorktreeCapabilities) void { + updates += 1; + needs_you_row = false; + for (elements) |element| { + if (std.mem.eql(u8, element.identity, "needs-you-row:B:b1")) needs_you_row = true; + } + } + }; + const allocator = std.testing.allocator; + var app: App = .{ + .allocator = allocator, + .client = .{ .allocator = allocator, .frame_buffer = try @import("FrameBuffer.zig").FrameBuffer.init(allocator, .v2) }, + .daemon = undefined, + .model = GraphModel.Model.init(allocator), + .sidebar_state = Sidebar.State.init(allocator), + .declared_entry_ids = std.array_list.Managed([]u8).init(allocator), + .kept_worktree_paths = std.array_list.Managed([]u8).init(allocator), + }; + defer app.client.deinit(); + defer app.model.deinit(); + defer app.sidebar_state.deinit(); + defer app.declared_entry_ids.deinit(); + defer app.kept_worktree_paths.deinit(); + defer if (app.selected_node_id.len != 0) allocator.free(app.selected_node_id); + _ = try app.model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"a","revision":1,"project":{"path":"A","name":"Alpha"},"nodes":[{"id":"a1","title":"Alpha loop","state":"running"}],"edges":[]}}} + ); + _ = try app.model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":2,"event":{"graphChanged":{"id":"b","revision":1,"project":{"path":"B","name":"Beta"},"nodes":[{"id":"b1","title":"Asker","state":"running","presence":{"presence":"busy"}}],"edges":[]}}} + ); + try std.testing.expect(app.model.selectProject("A")); + app.last_project_opened = "A"; + Probe.updates = 0; + + app.onFrameWithAccessibilityPublish( + \\{"version":2,"kind":"event","sequence":3,"event":{"nodesChanged":{"projectPath":"B","revision":2,"nodes":[{"id":"b1","title":"Asker","state":"running","presence":{"presence":"awaitingInput"}}]}}} + , Probe.publish); + + try std.testing.expectEqual(@as(usize, 1), Probe.updates); + try std.testing.expect(Probe.needs_you_row); + try std.testing.expectEqualStrings("A", app.model.graph.?.project.path); +} + test "folder picker completion waits for native callback unwind" { const Probe = struct { const class_name = std.unicode.utf8ToUtf16LeStringLiteral("GraphCodeFolderOpenLifetimeTest"); diff --git a/graphcode-windows/src/DaemonClient.zig b/graphcode-windows/src/DaemonClient.zig index 3c3e4e9b..00c8d53a 100644 --- a/graphcode-windows/src/DaemonClient.zig +++ b/graphcode-windows/src/DaemonClient.zig @@ -1040,17 +1040,7 @@ pub const DaemonClient = struct { } self.publishState(.negotiating, ""); self.negotiation_deadline_ms = now + negotiation_timeout_ms; - const subscription = self.subscriptionSnapshot() catch { - self.scheduleRetry(now, "daemon subscription allocation failed"); - return; - }; - defer self.allocator.free(subscription); - const hello = Wire.v2Hello( - self.allocator, - &self.client_id, - if (self.resume_from == 0) null else self.resume_from, - subscription, - ) catch { + const hello = self.helloFrame(self.allocator) catch { self.scheduleRetry(now, "daemon hello encoding failed"); return; }; @@ -1061,6 +1051,19 @@ pub const DaemonClient = struct { }; } + /// The shell is a sidebar client (`restoreOpenProjects`): every open project's rows + /// and Needs-you entries must stay live, so the hello never narrows delivery to the + /// focused project. `subscription_path` only records that focus and still triggers + /// the drained re-dial whose restore resynchronizes every open project. + fn helloFrame(self: *DaemonClient, allocator: std.mem.Allocator) ![]u8 { + return Wire.v2Hello( + allocator, + &self.client_id, + if (self.resume_from == 0) null else self.resume_from, + "", + ); + } + fn prepareV2Negotiation(self: *DaemonClient) void { self.mode = .v2; self.selected_version = Wire.current_version; @@ -1498,6 +1501,34 @@ test "subscription reconnect also waits for legacy response accounting" { try std.testing.expectEqual(ReconnectDecision.ready, client.takeReconnect(nowMilliseconds())); } +/// Mirrors GraphcodeKit's `DaemonConnectionChannel.isSubscribed`: a hello without a +/// subscription admits every project the connection joined; one with `projectPaths` +/// admits only the listed projects. +fn daemonAdmitsProjectEvents(hello: []const u8, project_path: []const u8) !bool { + var parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, hello, .{}); + defer parsed.deinit(); + const subscription = parsed.value.object.get("subscription") orelse return true; + const paths = subscription.object.get("projectPaths") orelse return true; + for (paths.array.items) |path| { + if (std.mem.eql(u8, path.string, project_path)) return true; + } + return false; +} + +test "opening a second project keeps the first project's live graph events admitted" { + var client = try DaemonClient.init(std.testing.allocator); + defer client.deinit(); + client.setSubscription("C:\\GraphCode-Fixtures\\Core"); + client.setSubscription("C:\\GraphCode-Fixtures\\Destructive"); + client.resume_from = 42; + const hello = try client.helloFrame(std.testing.allocator); + defer std.testing.allocator.free(hello); + try std.testing.expect(try daemonAdmitsProjectEvents(hello, "C:\\GraphCode-Fixtures\\Destructive")); + try std.testing.expect(try daemonAdmitsProjectEvents(hello, "C:\\GraphCode-Fixtures\\Core")); + try std.testing.expect(try daemonAdmitsProjectEvents(hello, "graphcode://global")); + try std.testing.expect(std.mem.indexOf(u8, hello, "\"resumeFrom\":42") != null); +} + test "new negotiations reset legacy fallback state to v2 framing" { const allocator = std.testing.allocator; var client = try DaemonClient.init(allocator); diff --git a/graphcode-windows/src/GraphModel.zig b/graphcode-windows/src/GraphModel.zig index eccaf8ac..aab74bf0 100644 --- a/graphcode-windows/src/GraphModel.zig +++ b/graphcode-windows/src/GraphModel.zig @@ -107,6 +107,8 @@ pub const Graph = struct { project: Project, nodes: std.array_list.Managed(Node), edges: std.array_list.Managed(Edge), + /// The daemon's per-graph frame revision (`LoopGraph.revision`); null when absent. + revision: ?u64 = null, }; pub const AttentionEntry = struct { @@ -345,6 +347,7 @@ pub const GraphSummary = struct { project: Project, nodes: std.array_list.Managed(Node), edges: std.array_list.Managed(Edge), + revision: ?u64 = null, worktree_notice: ?WorktreeStatus.NoticeRecord = null, fn deinit(self: *GraphSummary, allocator: std.mem.Allocator) void { @@ -761,6 +764,10 @@ pub const Model = struct { try self.decodeGraph(frame); return .graph_changed; }, + .nodes_changed => { + try self.applyNodesChanged(frame); + return .nodes_changed; + }, .recent_projects => { try self.decodeRecentProjects(frame); return .recent_projects; @@ -978,6 +985,46 @@ pub const Model = struct { else => return err, } orelse return; defer freeGraph(self.allocator, &graph); + try self.applyGraph(&graph); + } + + /// The presence tick as the loops it moved (`DaemonEvent.nodesChanged`), folded into + /// the snapshot held for that project and then applied as that snapshot — the same + /// shape macOS's `AppFeature.foldDelta` gives it. A delta never adds a loop, names a + /// project this shell does not hold, or overrides a snapshot with a newer revision. + fn applyNodesChanged(self: *Model, frame: []const u8) !void { + var root = try JsonFields.init(self.allocator, frame); + defer root.deinit(); + var event = try JsonFields.init(self.allocator, root.get("event").container('{') orelse "{}"); + defer event.deinit(); + const value = if (root.has("event")) event.get("nodesChanged") else root.get("nodesChanged"); + const payload = value.container('{') orelse return error.MalformedGraph; + var fields = try JsonFields.init(self.allocator, payload); + defer fields.deinit(); + const project_path = try fields.get("projectPath").duplicateString(self.allocator, ""); + defer self.allocator.free(project_path); + const revision = fields.get("revision").unsigned(u64) orelse return error.MalformedGraph; + const held = self.graphFor(project_path) orelse return; + if (held.revision) |current| { + if (revision <= current) return; + } + var moved = std.array_list.Managed(Node).init(self.allocator); + defer { + for (moved.items) |node| freeNode(self.allocator, node); + moved.deinit(); + } + if (fields.get("nodes").container('[')) |nodes| try decodeNodes(self.allocator, nodes, &moved); + var merged = try cloneGraph(self.allocator, held.*); + defer freeGraph(self.allocator, &merged); + merged.revision = revision; + for (moved.items) |*node| { + const index = findNodeIndexByID(merged.nodes.items, node.id) orelse continue; + std.mem.swap(Node, &merged.nodes.items[index], node); + } + try self.applyGraph(&merged); + } + + fn applyGraph(self: *Model, graph: *const Graph) !void { const was_selected = if (self.selected_project_path) |path| std.mem.eql(u8, path, graph.project.path) else @@ -996,13 +1043,13 @@ pub const Model = struct { if (open_project != null) try self.open_projects.ensureUnusedCapacity(1); const seen = try self.prepareGraphSeen(graph.project.path); errdefer if (seen) |entry| self.allocator.free(entry.project_path); - var activity = try self.prepareActivity(graph); + var activity = try self.prepareActivity(graph.*); defer { for (activity.items) |event| freeActivityEvent(self.allocator, event); activity.deinit(); } try self.activity.ensureUnusedCapacity(activity.items.len); - var attention = try self.prepareAttention(&graph); + var attention = try self.prepareAttention(graph); defer attention.deinit(self.allocator); var selected_index = self.selected_index; var selected_node_id: ?[]const u8 = self.selected_node_id; @@ -1038,7 +1085,7 @@ pub const Model = struct { // The summary replacement is the last fallible step; all other owned // values and append capacity are ready before any model data is replaced. - try self.upsertSummary(&graph); + try self.upsertSummary(graph); if (open_project) |project| self.open_projects.appendAssumeCapacity(project); if (selected_path) |path| self.selected_project_path = path; self.markGraphSeen(graph.project.path, seen); @@ -1146,11 +1193,12 @@ pub const Model = struct { self.applyWorktreeBindingChange(binding_change); std.mem.swap(std.array_list.Managed(Node), &summary.nodes, ©.nodes); std.mem.swap(std.array_list.Managed(Edge), &summary.edges, ©.edges); + summary.revision = graph.revision; return; } try self.graphs.ensureUnusedCapacity(1); self.applyWorktreeBindingChange(binding_change); - self.graphs.appendAssumeCapacity(.{ .project = copy.project, .nodes = copy.nodes, .edges = copy.edges }); + self.graphs.appendAssumeCapacity(.{ .project = copy.project, .nodes = copy.nodes, .edges = copy.edges, .revision = graph.revision }); copy.project = .{ .path = &.{}, .name = &.{} }; copy.nodes = std.array_list.Managed(Node).init(self.allocator); copy.edges = std.array_list.Managed(Edge).init(self.allocator); @@ -2230,6 +2278,7 @@ fn decodeGraphObject(allocator: std.mem.Allocator, parent_project: ?Project, jso defer project.deinit(); graph.project.path = try project.get("path").duplicateString(allocator, ""); graph.project.name = try project.get("name").duplicateString(allocator, ""); + graph.revision = fields.get("revision").unsigned(u64); } if (fields.get("nodes").container('[')) |nodes| try decodeNodes(allocator, nodes, &graph.nodes); if (fields.get("edges").container('[')) |edges| graph.edges = try decodeEdges(allocator, edges); @@ -3193,6 +3242,49 @@ test "activity log records state transitions but not initial snapshot" { try std.testing.expectEqualStrings("Worker", model.activity.items[0].title); } +test "nodesChanged presence delta updates Needs you without a full snapshot" { + var model = Model.init(std.testing.allocator); + defer model.deinit(); + _ = try model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"a","revision":3,"project":{"path":"C:\\work\\core","name":"Core"},"nodes":[{"id":"a1","title":"Core loop","loopType":"turnBased","state":"running","presence":{"presence":"busy","confidence":"reported"}}],"edges":[]}}} + ); + _ = try model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":2,"event":{"graphChanged":{"id":"b","revision":4,"project":{"path":"C:\\work\\dest","name":"Destructive"},"nodes":[{"id":"b1","title":"Asker","loopType":"turnBased","state":"running","presence":{"presence":"busy","confidence":"reported"}},{"id":"b2","title":"Other","loopType":"turnBased","state":"running"}],"edges":[{"id":"e","from":"b1","to":"b2","kind":"handoff"}]}}} + ); + try std.testing.expect(model.selectProject("C:\\work\\core")); + try std.testing.expectEqual(@as(usize, 0), model.attention_entries.items.len); + + _ = try model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":3,"event":{"nodesChanged":{"projectPath":"C:\\work\\dest","revision":5,"nodes":[{"id":"b1","title":"Asker","loopType":"turnBased","state":"running","activity":"Asked a question","presence":{"presence":"awaitingInput","confidence":"reported"}},{"id":"stranger","title":"Not in graph","loopType":"turnBased","state":"running","presence":{"presence":"awaitingInput"}}]}}} + ); + try std.testing.expectEqual(@as(usize, 1), model.attention_entries.items.len); + try std.testing.expectEqualStrings("C:\\work\\dest", model.attention_entries.items[0].project_path); + try std.testing.expectEqualStrings("b1", model.attention_entries.items[0].node.id); + const dest = model.graphFor("C:\\work\\dest").?; + try std.testing.expectEqual(@as(usize, 2), dest.nodes.items.len); + try std.testing.expectEqualStrings("awaitingInput", dest.nodes.items[0].presence); + try std.testing.expectEqualStrings("Asked a question", dest.nodes.items[0].activity); + try std.testing.expectEqual(@as(usize, 1), dest.edges.items.len); + try std.testing.expectEqualStrings("C:\\work\\core", model.selected_project_path.?); + try std.testing.expectEqualStrings("Core loop", model.graph.?.nodes.items[0].title); + + // A delta older than the snapshot already held is dropped, as on macOS. + _ = try model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":4,"event":{"nodesChanged":{"projectPath":"C:\\work\\dest","revision":5,"nodes":[{"id":"b1","title":"Asker","loopType":"turnBased","state":"running","presence":{"presence":"idle"}}]}}} + ); + try std.testing.expectEqual(@as(usize, 1), model.attention_entries.items.len); + + // A delta for the selected project refreshes the visible graph and keeps selection. + try std.testing.expect(model.selectProject("C:\\work\\dest")); + _ = model.setSelectedIndex(1); + _ = try model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":5,"event":{"nodesChanged":{"projectPath":"C:\\work\\dest","revision":6,"nodes":[{"id":"b1","title":"Asker","loopType":"turnBased","state":"running","presence":{"presence":"idle"}}]}}} + ); + try std.testing.expectEqual(@as(usize, 0), model.attention_entries.items.len); + try std.testing.expectEqualStrings("idle", model.graph.?.nodes.items[0].presence); + try std.testing.expectEqualStrings("b2", model.selectedNodeID().?); +} + test "presence polling does not evict or create activity history" { var model = Model.init(std.testing.allocator); defer model.deinit(); diff --git a/graphcode-windows/src/Wire.zig b/graphcode-windows/src/Wire.zig index 60f7585d..bbcbad0d 100644 --- a/graphcode-windows/src/Wire.zig +++ b/graphcode-windows/src/Wire.zig @@ -24,6 +24,7 @@ pub const ConnectionState = enum { pub const EventKind = enum { recent_projects, graph_changed, + nodes_changed, quick_chats, quick_chat_changed, quick_chat_deleted, @@ -113,6 +114,8 @@ pub fn eventKind(data: []const u8) EventKind { if (std.mem.indexOf(u8, data, "\"recentProjectsListed\"") != null) { return .recent_projects; } + // Keyed with the colon so a loop titled "nodesChanged" in a snapshot is not a delta. + if (std.mem.indexOf(u8, data, "\"nodesChanged\":") != null) return .nodes_changed; if (std.mem.indexOf(u8, data, "\"graphChanged\"") != null) { return .graph_changed; }