Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Tools/windows/windows-shell.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -366,14 +366,18 @@ try {
foreach ($property in @(
"protocolConnected",
"correlatedRequests",
"subscriptionSeen",
"reconnectObserved",
"graphSent"
)) {
if (-not [bool] $evidence.$property) {
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)"
}
Expand Down
10 changes: 10 additions & 0 deletions graphcode-windows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 59 additions & 2 deletions graphcode-windows/src/App.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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");
Expand Down
53 changes: 42 additions & 11 deletions graphcode-windows/src/DaemonClient.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
100 changes: 96 additions & 4 deletions graphcode-windows/src/GraphModel.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1146,11 +1193,12 @@ pub const Model = struct {
self.applyWorktreeBindingChange(binding_change);
std.mem.swap(std.array_list.Managed(Node), &summary.nodes, &copy.nodes);
std.mem.swap(std.array_list.Managed(Edge), &summary.edges, &copy.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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading