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
53 changes: 53 additions & 0 deletions Tools/windows/uia-live-gate.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,7 @@ $oldShowUpdate = [Environment]::GetEnvironmentVariable("GRAPHCODE_UIA_SHOW_UPDAT
$oldIngressError = [Environment]::GetEnvironmentVariable("GRAPHCODE_UIA_INGRESS_ERROR")
$oldDaemonCommandLog = [Environment]::GetEnvironmentVariable("GRAPHCODE_UIA_DAEMON_COMMAND_LOG")
$oldShellExecuteLog = [Environment]::GetEnvironmentVariable("GRAPHCODE_UIA_SHELL_EXECUTE_LOG")
$oldLocalAppData = [Environment]::GetEnvironmentVariable("LOCALAPPDATA")
$process = $null
$settingsProcess = $null
$status = $null
Expand All @@ -615,6 +616,7 @@ $settingsPath = $null
$settingsErrorPath = $null
$daemonCommandLogPath = $null
$shellExecuteLogPath = $null
$templateDirectory = $null
try {
if ($Zmx) { $env:GRAPHCODE_ZMX = $Zmx }
$env:GRAPHCODE_GATE_CWD = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
Expand All @@ -631,6 +633,14 @@ try {
Remove-Item -LiteralPath $shellExecuteLogPath -Force -ErrorAction SilentlyContinue
$env:GRAPHCODE_UIA_DAEMON_COMMAND_LOG = $daemonCommandLogPath
$env:GRAPHCODE_UIA_SHELL_EXECUTE_LOG = $shellExecuteLogPath
$templateDirectory = Join-Path ([IO.Path]::GetTempPath()) "graphcode-uia-templates-$PID"
$env:LOCALAPPDATA = $templateDirectory
$savedTemplates = Join-Path $templateDirectory "GraphCode\templates"
New-Item -ItemType Directory -Path $savedTemplates -Force | Out-Null
[IO.File]::WriteAllText(
(Join-Path $savedTemplates "uia-release-review.md"),
"---`nid: 11111111-1111-4111-8111-111111111111`nname: UIA release review`nshape: turn`n---`nReview the release diff.`n"
)
$settingsDirectory = Join-Path $env:GRAPHCODE_GATE_CWD ".graphcode-uia-product-settings-$PID"
$settingsPath = Join-Path $settingsDirectory "settings.json"
$settingsErrorPath = Join-Path $settingsDirectory "stderr.log"
Expand Down Expand Up @@ -905,6 +915,43 @@ try {
-diagnosticWindow $shellWindow `
-RecoverForeground
Require ($null -ne $sidebarNodeForm) "project-row New Loop did not open the node form"
$templatesButton = $sidebarNodeForm.FindFirst(
[System.Windows.Automation.TreeScope]::Descendants,
(New-Object System.Windows.Automation.PropertyCondition(
[System.Windows.Automation.AutomationElement]::NameProperty, "Templates"
))
)
Require ($null -ne $templatesButton) "node form omitted the explicit Templates action"
Require ($templatesButton.Current.AutomationId -eq "4") `
"Templates action did not expose its stable native command identity"
Require ([GraphCodeUiaGateState]::PostCommand(
[IntPtr]$sidebarNodeForm.Current.NativeWindowHandle, 4
)) "Templates action rejected invocation"
$templatePickerCondition = New-Object System.Windows.Automation.AndCondition(
(New-Object System.Windows.Automation.PropertyCondition(
[System.Windows.Automation.AutomationElement]::ProcessIdProperty, $process.Id
)),
(New-Object System.Windows.Automation.PropertyCondition(
[System.Windows.Automation.AutomationElement]::NameProperty, "Choose a saved template"
))
)
$templatePicker = Wait-ForDesktopElement `
-desktop $desktop `
-condition $templatePickerCondition `
-label "project-row New Loop template picker" `
-diagnosticWindow $shellWindow `
-RecoverForeground
Require ($null -ne $templatePicker) "project-row New Loop did not expose the saved template picker"
Require ([GraphCodeUiaGateState]::PostKeyboard(
[IntPtr]$templatePicker.Current.NativeWindowHandle, 0x0D
)) "template picker rejected keyboard application"
$sidebarNodeForm = Wait-ForDesktopElement `
-desktop $desktop `
-condition $sidebarNodeFormCondition `
-label "project-row New Loop node form" `
-diagnosticWindow $shellWindow `
-RecoverForeground
Require ($null -ne $sidebarNodeForm) "project-row New Loop did not open the node form"
Require ([GraphCodeUiaGateState]::PostClose(
[IntPtr]$sidebarNodeForm.Current.NativeWindowHandle
)) "project-row New Loop form rejected cancellation"
Expand Down Expand Up @@ -2432,6 +2479,9 @@ try {
if ($shellExecuteLogPath) {
Remove-Item -LiteralPath $shellExecuteLogPath -Force -ErrorAction SilentlyContinue
}
if ($templateDirectory) {
Remove-Item -LiteralPath $templateDirectory -Recurse -Force -ErrorAction SilentlyContinue
}
if ($null -eq $oldZmx) { Remove-Item Env:GRAPHCODE_ZMX -ErrorAction SilentlyContinue }
else { $env:GRAPHCODE_ZMX = $oldZmx }
if ($null -eq $oldCwd) { Remove-Item Env:GRAPHCODE_GATE_CWD -ErrorAction SilentlyContinue }
Expand Down Expand Up @@ -2470,4 +2520,7 @@ try {
if ($null -eq $oldShellExecuteLog) {
Remove-Item Env:GRAPHCODE_UIA_SHELL_EXECUTE_LOG -ErrorAction SilentlyContinue
} else { $env:GRAPHCODE_UIA_SHELL_EXECUTE_LOG = $oldShellExecuteLog }
if ($null -eq $oldLocalAppData) {
Remove-Item Env:LOCALAPPDATA -ErrorAction SilentlyContinue
} else { $env:LOCALAPPDATA = $oldLocalAppData }
}
128 changes: 119 additions & 9 deletions graphcode-windows/src/App.zig
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const CanvasLayoutStore = @import("CanvasLayoutStore.zig");
const GraphContextMenu = @import("GraphContextMenu.zig");
const Forms = @import("Forms.zig");
const NativeForms = @import("NativeForms.zig");
const TemplateLibrary = @import("TemplateLibrary.zig");
const JumpPalette = @import("JumpPalette.zig");
const NativeDialogs = @import("WindowsNativeDialogs.zig");
const Sidebar = @import("Sidebar.zig");
Expand Down Expand Up @@ -1135,24 +1136,86 @@ pub const App = struct {
const path = self.allocator.dupe(u8, current_path) catch return;
defer self.allocator.free(path);
const settings = self.product_settings orelse return;
var draft = NativeForms.node(self.window.hwnd, self.allocator, .{
const initial = Forms.NodeDraft{
.title = "",
.backend = settings.default_backend,
.model_tier = settings.default_model,
.claude_permissions = settings.claude_permissions,
.copilot_permissions = settings.copilot_permissions,
.briefing_enabled = settings.briefing,
.activity_enabled = settings.activity,
}) catch {
self.setStatus("Unable to open node form");
return;
} orelse return;
defer draft.deinit(self.allocator);
Forms.validateNode(draft) catch {
self.setStatus("Invalid node form");
};
var templates = TemplateLibrary.load(self.allocator, path) catch {
self.setStatus("Unable to load saved templates");
return;
};
self.client.sendCreateNodeDraft(path, draft);
defer templates.deinit();
if (templates.templates.items.len == 0) {
var draft = NativeForms.node(self.window.hwnd, self.allocator, initial) catch {
self.setStatus("Unable to open node form");
return;
} orelse return;
defer draft.deinit(self.allocator);
Forms.validateNode(draft) catch {
self.setStatus("Invalid node form");
return;
};
self.client.sendCreateNodeDraft(path, draft);
return;
}

var labels = std.array_list.Managed([]const u8).init(self.allocator);
defer {
for (labels.items) |label| self.allocator.free(label);
labels.deinit();
}
for (templates.templates.items) |template| {
const label = std.fmt.allocPrint(self.allocator, "{s} — {s}", .{ template.name, template.body }) catch {
self.setStatus("Unable to prepare saved template list");
return;
};
labels.append(label) catch {
self.allocator.free(label);
self.setStatus("Unable to prepare saved template list");
return;
};
}

var current = initial;
var owns_current = false;
defer if (owns_current) current.deinit(self.allocator);
while (true) {
const result = NativeForms.nodeWithTemplates(self.window.hwnd, self.allocator, current, true) catch {
self.setStatus("Unable to open node form");
return;
};
switch (result) {
.cancelled => return,
.draft => |draft| {
var submitted = draft;
defer submitted.deinit(self.allocator);
Forms.validateNode(submitted) catch {
self.setStatus("Invalid node form");
return;
};
self.client.sendCreateNodeDraft(path, submitted);
return;
},
.templates => |draft| {
if (owns_current) current.deinit(self.allocator);
current = draft;
owns_current = true;
const selected = NativeForms.templatePicker(self.window.hwnd, self.allocator, labels.items) catch {
self.setStatus("Unable to open saved template picker");
return;
};
if (selected) |index| TemplateLibrary.applyOwned(&current, templates.templates.items[index], self.allocator) catch {
self.setStatus("Unable to apply selected template");
return;
};
},
}
}
}

fn editSelectedNode(self: *App) void {
Expand Down Expand Up @@ -1913,6 +1976,7 @@ pub const App = struct {
.composite = composite,
.can_arm = std.mem.eql(u8, graph.nodes.items[index].pilot_state, "piloted"),
.unwired = unwired,
.follows_template = graph.nodes.items[index].follows_template,
} },
x,
y,
Expand Down Expand Up @@ -2035,6 +2099,11 @@ pub const App = struct {
},
.wire_node => self.beginWireSelectedNode(index),
.mark_entry => self.markSelectedNodeAsEntry(index),
.detach_template => {
self.client.sendDetachTemplate(graph.project.path, graph.nodes.items[index].id);
self.setStatus("Detached loop from its template");
},
.save_node_template => self.saveSelectedNodeAsTemplate(index),
else => {},
}
},
Expand Down Expand Up @@ -2073,6 +2142,47 @@ pub const App = struct {
_ = c.InvalidateRect(self.window.hwnd, null, 0);
}

fn saveSelectedNodeAsTemplate(self: *App, index: usize) void {
const graph = self.model.graph orelse return;
if (index >= graph.nodes.items.len) return;
var result = NativeDialogs.textWithDescription(
self.window.hwnd, self.allocator, "Save as Template",
"Name this reusable prompt template. It is stored in your per-user GraphCode library.",
&.{"Template name"}, &.{graph.nodes.items[index].title},
) catch {
self.setStatus("Unable to open template save form");
return;
} orelse return;
defer result.deinit(self.allocator);
const node = graph.nodes.items[index];
const draft = Forms.NodeDraft{
.title = self.allocator.dupe(u8, node.title) catch return,
.loop_type = self.allocator.dupe(u8, node.loop_type) catch return,
.first_instruction = self.allocator.dupe(u8, if (node.trigger_prompt.len != 0) node.trigger_prompt else node.check_description) catch return,
.goal_summary = self.allocator.dupe(u8, node.goal_summary) catch return,
.trigger_prompt = self.allocator.dupe(u8, node.trigger_prompt) catch return,
.claude_permissions = "",
.copilot_permissions = "",
};
defer {
self.allocator.free(draft.title);
self.allocator.free(draft.loop_type);
self.allocator.free(draft.first_instruction);
self.allocator.free(draft.goal_summary);
self.allocator.free(draft.trigger_prompt);
}
var template = TemplateLibrary.fromDraft(self.allocator, result.values[0], draft) catch {
self.setStatus("A template needs a name and prompt");
return;
};
defer template.deinit(self.allocator);
TemplateLibrary.save(self.allocator, template) catch {
self.setStatus("Unable to save template");
return;
};
self.setStatus("Saved reusable template");
}

fn showCompositeGroup(self: *App, node: GraphModel.Node) void {
const node_id = self.allocator.dupe(u8, node.id) catch return;
defer self.allocator.free(node_id);
Expand Down
8 changes: 8 additions & 0 deletions graphcode-windows/src/DaemonClient.zig
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,14 @@ pub const DaemonClient = struct {
self.sendCommand(command);
}

pub fn sendDetachTemplate(self: *DaemonClient, project_path: []const u8, node_id: []const u8) void {
const command = Wire.commandGraphDetachTemplate(self.allocator, project_path, node_id) catch {
self.publishState(self.connectionState(), "detach template command encoding failed");
return;
};
self.sendCommand(command);
}

pub fn sendSidebarRootOrder(
self: *DaemonClient,
project_path: []const u8,
Expand Down
11 changes: 11 additions & 0 deletions graphcode-windows/src/GraphContextMenu.zig
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ pub const NodeTarget = struct {
composite: bool = false,
can_arm: bool = false,
unwired: bool = false,
follows_template: bool = false,
};

pub const EdgeTarget = struct {
Expand Down Expand Up @@ -44,6 +45,8 @@ pub const Action = enum {
open_composite,
pilot_composite,
arm_composite,
save_node_template,
detach_template,
wire_node,
mark_entry,
edit_edge,
Expand Down Expand Up @@ -92,6 +95,8 @@ const ids = struct {
const open_composite = 5113;
const pilot_composite = 5107;
const arm_composite = 5108;
const save_node_template = 5114;
const detach_template = 5115;
const wire_node = 5109;
const mark_entry = 5112;
const edit_edge = 5110;
Expand Down Expand Up @@ -177,6 +182,8 @@ pub fn show(
separator(menu);
}
append(menu, ids.edit_node, "Edit Details...\tCtrl+E");
append(menu, ids.save_node_template, "Save as Template...");
if (node.follows_template) append(menu, ids.detach_template, "Detach from Template");
append(menu, ids.rename_node, "Rename...\tF2");
append(menu, ids.stop_node, "Stop\tCtrl+S");
append(menu, ids.delete_node, "Delete Loop...\tDelete");
Expand Down Expand Up @@ -222,6 +229,8 @@ fn actionForCommand(command: c_int) Action {
ids.open_composite => .open_composite,
ids.pilot_composite => .pilot_composite,
ids.arm_composite => .arm_composite,
ids.save_node_template => .save_node_template,
ids.detach_template => .detach_template,
ids.wire_node => .wire_node,
ids.mark_entry => .mark_entry,
ids.edit_edge => .edit_edge,
Expand Down Expand Up @@ -285,6 +294,8 @@ test "context actions remain stable when graph IDs are reordered" {
try std.testing.expectEqual(Action.arm_composite, actionForCommand(ids.arm_composite));
try std.testing.expectEqual(Action.wire_node, actionForCommand(ids.wire_node));
try std.testing.expectEqual(Action.mark_entry, actionForCommand(ids.mark_entry));
try std.testing.expectEqual(Action.save_node_template, actionForCommand(ids.save_node_template));
try std.testing.expectEqual(Action.detach_template, actionForCommand(ids.detach_template));
}

test "destructive context actions cannot bypass a cancelled confirmation" {
Expand Down
28 changes: 28 additions & 0 deletions graphcode-windows/src/GraphModel.zig
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ pub const Node = struct {
worktree_path: []u8 = @constCast(""),
worktree_branch: []u8 = &.{},
subgraph_json: []u8 = &.{},
follows_template: bool = false,
};

pub const ActivityEvent = struct {
Expand Down Expand Up @@ -1043,6 +1044,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),
.follows_template = node.follows_template,
};
}

Expand Down Expand Up @@ -1111,9 +1113,19 @@ fn decodeNodes(
.worktree_path = try duplicateWorktreePath(allocator, scalar_object),
.worktree_branch = try duplicateWorktreeBranch(allocator, scalar_object),
.subgraph_json = try duplicateJsonObjectOrEmpty(allocator, object, "subGraph"),
.follows_template = hasNonNullJsonField(scalar_object, "templateFollow"),
});
cursor = end + 1;
}

}

fn hasNonNullJsonField(object: []const u8, key: []const u8) bool {
const marker = std.fmt.allocPrint(std.heap.page_allocator, "\"{s}\":", .{key}) catch return false;
defer std.heap.page_allocator.free(marker);
const start = std.mem.indexOf(u8, object, marker) orelse return false;
const value = std.mem.trimLeft(u8, object[start + marker.len ..], " \t\r\n");
return !std.mem.startsWith(u8, value, "null");
}

fn decodeEdges(
Expand Down Expand Up @@ -1482,6 +1494,22 @@ test "stub graph snapshot decodes two actionable nodes" {
try std.testing.expectEqualStrings("idle", graph.nodes.items[1].presence);
}

test "template-following nodes retain their detach eligibility" {
const allocator = std.testing.allocator;
var nodes = std.array_list.Managed(Node).init(allocator);
defer {
for (nodes.items) |node| freeNode(allocator, node);
nodes.deinit();
}
try decodeNodes(
allocator,
\\[{"id":"11111111-1111-4111-8111-111111111111","title":"Template loop","loopType":"turnBased","state":{"idle":{}},"pausesBeforeWritesOnly":false,"pilotState":"notPiloted","templateFollow":{"id":"22222222-2222-4222-8222-222222222222"}}]
,
&nodes,
);
try std.testing.expect(nodes.items[0].follows_template);
}

test "reordered graph fixture preserves nonsequential edge IDs" {
const allocator = std.testing.allocator;
const frame = try std.fs.cwd().readFileAlloc(
Expand Down
Loading
Loading