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; }