From 77bc7eaee9b6089175f0aab8ce606ef4146e7638 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 13 Oct 2026 21:57:42 -0700 Subject: [PATCH 01/11] Fix quick chat workspace chrome Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f073d0e-2c12-43fc-bfed-4ab3c4867b37 Signed-off-by: Colin Neilens --- graphcode-windows/src/App.zig | 215 +++++++++++++++++++++++++++------- 1 file changed, 173 insertions(+), 42 deletions(-) diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index a1a10e92..aec3250c 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -1246,6 +1246,15 @@ fn workspaceLoopBar(controls: WorkspaceControls.State, client_right: i32, resolv ); } +fn workspaceQuickChatBar(controls: WorkspaceControls.State, client_right: i32) LoopBarLayout.Layout { + return TerminalWorkspace.loopBarLayout( + if (controls.rail_visible) Tokens.sidebar_width else 0, + client_right, + true, + false, + ); +} + fn workspaceGraph(model: *const GraphModel.Model) ?*const GraphModel.GraphSummary { if (model.currentGraph()) |graph| if (graph.nodes.items.len != 0) return graph; if (model.selected_project_path) |path| { @@ -3042,7 +3051,7 @@ pub const App = struct { self.selected_quick_chat = index; self.workspace_is_quick_chat = true; self.surface = .workspace; - self.workspace_controls.panel_visible = true; + self.workspace_controls.panel_visible = false; self.layoutWorkspace(); self.layoutEmptyStateControls(); if (self.workspace) |workspace| { @@ -6971,6 +6980,10 @@ pub const App = struct { } fn toggleWorkspacePanelState(self: *App) void { + if (self.workspace_is_quick_chat) { + self.workspace_controls.panel_visible = false; + return; + } self.workspace_controls.apply(.toggle_panel); if (self.workspace_controls.panel_visible) { self.workspace_is_quick_chat = false; @@ -6981,6 +6994,10 @@ pub const App = struct { } fn toggleWorkspaceDetailPanelState(self: *App) void { + if (self.workspace_is_quick_chat) { + self.workspace_controls.panel_visible = false; + return; + } self.workspace_controls.panel_visible = !self.workspace_controls.panel_visible; if (self.workspace_controls.panel_visible) self.surface = .workspace; } @@ -7094,19 +7111,22 @@ pub const App = struct { ); } if (self.empty_global_overview_button != null) { - const label = if (is_quick_chats) "New Chat" else "New Loop"; + const label = if (is_quick_chats) "+ New Chat" else "New Loop"; if (!childTextEquals(self.empty_global_overview_button, label)) setButtonText(self.empty_global_overview_button, label); const show_primary = is_quick_chats or is_overview or (self.surface == .project and graph != null and !is_global); - const primary_x = if (is_empty) x else content_right - 140; - const primary_y = if (is_empty) + const primary_x = if (is_quick_chats) content_right - 140 else if (is_empty) x else content_right - 140; + const primary_y = if (is_quick_chats) + Tokens.header_height + 14 + else if (is_empty) y + (if (is_global or is_overview) @as(i32, 42) else @as(i32, 0)) else Tokens.header_height + 14; + const primary_width: i32 = if (is_quick_chats) 120 else if (is_empty) 220 else 120; placeChildControl(self.empty_global_overview_button, show_primary, .{ .left = physicalCoordinate(primary_x, self.dpi), .top = physicalCoordinate(primary_y, self.dpi), - .right = physicalCoordinate(primary_x, self.dpi) + physicalCoordinate(if (is_empty) 220 else 120, self.dpi), + .right = physicalCoordinate(primary_x, self.dpi) + physicalCoordinate(primary_width, self.dpi), .bottom = physicalCoordinate(primary_y, self.dpi) + physicalCoordinate(32, self.dpi), }); } @@ -7860,42 +7880,47 @@ pub const App = struct { } if (self.surface == .workspace) { if (self.workspace) |workspace| { - const selected_index = self.model.selectedIndex() orelse 0; - const loop_bar = workspaceLoopBar( - self.workspace_controls, - client.right, - selected_index >= graph.nodes.items.len or isResolvedLoopState(graph.nodes.items[selected_index].state), - ); if (!self.workspace_is_quick_chat) { + const selected_index = self.model.selectedIndex() orelse 0; + const loop_bar = workspaceLoopBar( + self.workspace_controls, + client.right, + selected_index >= graph.nodes.items.len or isResolvedLoopState(graph.nodes.items[selected_index].state), + ); self.appendAccessibilityElement(&elements, &owned_identities, "workspace-toolbar", graph.project.path, header.title, 4, .{ .logical = header_layout.identity }, false, false) catch return; elements.items[elements.items.len - 1].invokable = false; - } - self.appendAccessibilityElement(&elements, &owned_identities, "workspace-loop-bar", if (selected_index < graph.nodes.items.len) graph.nodes.items[selected_index].id else "none", "Selected loop workspace", 4, .{ .logical = loopBarRect(loop_bar.bar) }, false, false) catch return; - self.appendAccessibilityElement(&elements, &owned_identities, "workspace-show-graph", "show-graph", "Show in Graph", 4, .{ .logical = loopBarRect(loop_bar.show_graph) }, false, false) catch return; - if (loop_bar.stop) |stop| { - self.appendAccessibilityElement(&elements, &owned_identities, "workspace-stop", graph.nodes.items[selected_index].id, "Stop loop", 4, .{ .logical = loopBarRect(stop) }, false, false) catch return; - } - const panel_toggle = if (loop_bar.panel_toggle) |expand| - loopBarRect(expand) - else - GraphCanvas.loopDetailCollapseBounds(client.right); - self.appendAccessibilityElement(&elements, &owned_identities, "workspace-toggle-panel", "control", if (self.workspace_controls.panel_visible) "Collapse loop panel" else "Expand loop panel", 4, .{ .logical = panel_toggle }, false, true) catch return; - if (self.workspace_controls.panel_visible and selected_index < graph.nodes.items.len) { - const selected_node = graph.nodes.items[selected_index]; - if (selected_node.metric_sample_count >= 2) { - self.appendAccessibilityElement(&elements, &owned_identities, "workspace-detail-sparkline", selected_node.id, "Metric sparkline", 4, .{ .logical = GraphCanvas.loopDetailSparklineBounds(client.right, client.bottom) }, false, false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-loop-bar", if (selected_index < graph.nodes.items.len) graph.nodes.items[selected_index].id else "none", "Selected loop workspace", 4, .{ .logical = loopBarRect(loop_bar.bar) }, false, false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-show-graph", "show-graph", "Show in Graph", 4, .{ .logical = loopBarRect(loop_bar.show_graph) }, false, false) catch return; + if (loop_bar.stop) |stop| { + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-stop", graph.nodes.items[selected_index].id, "Stop loop", 4, .{ .logical = loopBarRect(stop) }, false, false) catch return; } - const detail_footer = GraphCanvas.loopDetailFooterBounds(client.right, client.bottom); - if (selected_node.created_at != null) { - self.appendAccessibilityElement(&elements, &owned_identities, "workspace-detail-start", selected_node.id, "Start time", 4, .{ .logical = detail_footer }, false, false) catch return; + const panel_toggle = if (loop_bar.panel_toggle) |expand| + loopBarRect(expand) + else + GraphCanvas.loopDetailCollapseBounds(client.right); + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-toggle-panel", "control", if (self.workspace_controls.panel_visible) "Collapse loop panel" else "Expand loop panel", 4, .{ .logical = panel_toggle }, false, true) catch return; + if (self.workspace_controls.panel_visible and selected_index < graph.nodes.items.len) { + const detail_left = client.right - Tokens.loop_detail_width; + const selected_node = graph.nodes.items[selected_index]; + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-detail-sparkline", selected_node.id, "Metric sparkline", 4, .{ .logical = .{ .left = detail_left + 18, .top = client.bottom - 102, .right = client.right - 18, .bottom = client.bottom - 70 } }, false, false) catch return; + if (selected_node.created_at != null) { + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-detail-start", selected_node.id, "Start time", 4, .{ .logical = .{ .left = detail_left + 18, .top = client.bottom - 64, .right = client.right - 18, .bottom = client.bottom - 44 } }, false, false) catch return; + } + if (selected_node.token_usage) |tokens| { + const usage_name = std.fmt.allocPrint(self.allocator, "{d} tokens", .{tokens}) catch return; + owned_identities.append(usage_name) catch { + self.allocator.free(usage_name); + return; + }; + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-detail-usage", selected_node.id, usage_name, 4, .{ .logical = .{ .left = detail_left + 18, .top = client.bottom - 44, .right = client.right - 18, .bottom = client.bottom - 24 } }, false, false) catch return; + } } - if (selected_node.token_usage) |tokens| { - const usage_name = std.fmt.allocPrint(self.allocator, "{d} tokens", .{tokens}) catch return; - owned_identities.append(usage_name) catch { - self.allocator.free(usage_name); - return; - }; - self.appendAccessibilityElement(&elements, &owned_identities, "workspace-detail-usage", selected_node.id, usage_name, 4, .{ .logical = detail_footer }, false, false) catch return; + } else if (self.selected_quick_chat) |chat_index| { + if (chat_index < self.model.quick_chats.items.len) { + const chat = self.model.quick_chats.items[chat_index]; + const loop_bar = workspaceQuickChatBar(self.workspace_controls, client.right); + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-loop-bar", chat.id, chat.title, 4, .{ .logical = loopBarRect(loop_bar.bar) }, false, false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-show-graph", "show-graph", "Show in Graph", 4, .{ .logical = loopBarRect(loop_bar.show_graph) }, false, false) catch return; } } for (workspace.layout.tabs.items, 0..) |tab, tab_index| { @@ -8346,7 +8371,7 @@ pub const App = struct { self.selected_quick_chat = index; self.workspace_is_quick_chat = true; self.surface = .workspace; - self.workspace_controls.panel_visible = true; + self.workspace_controls.panel_visible = false; self.layoutWorkspace(); self.layoutEmptyStateControls(); self.syncAccessibility(); @@ -9128,8 +9153,31 @@ fn onWindowMessage( app.update_lock.unlock(); if (app.workspace_controls.panel_visible or app.surface == .workspace) { if (app.surface == .workspace) { - if (workspaceGraph(&app.model)) |graph| { - const workspace_right = logical_right - (if (app.workspace_controls.panel_visible) Tokens.loop_detail_width else 0); + const workspace_right = logical_right - (if (app.workspace_controls.panel_visible and !app.workspace_is_quick_chat) Tokens.loop_detail_width else 0); + if (app.workspace_is_quick_chat) { + if (app.selected_quick_chat) |chat_index| { + if (chat_index < app.model.quick_chats.items.len) { + const chat = app.model.quick_chats.items[chat_index]; + TerminalWorkspace.Workspace.paintLoopBar( + hdc, + app.allocator, + if (app.workspace_controls.rail_visible) Tokens.sidebar_width else 0, + workspace_right, + "Chat", + chat.title, + "composite", + "IDLE", + if (chat.activity.len != 0) chat.activity else "Not piloted", + if (chat.backend.len != 0) chat.backend else "claudeCode", + null, + 0, + null, + true, + false, + ); + } + } + } else if (workspaceGraph(&app.model)) |graph| { const index = app.model.selectedIndex() orelse 0; if (index < graph.nodes.items.len) { const node = graph.nodes.items[index]; @@ -9160,7 +9208,7 @@ fn onWindowMessage( workspace.paintChrome(hdc); _ = c.RestoreDC(hdc, saved_mapping); } - if (app.surface == .workspace and app.workspace_controls.panel_visible) { + if (app.surface == .workspace and app.workspace_controls.panel_visible and !app.workspace_is_quick_chat) { if (workspaceGraph(&app.model)) |graph| { const index = app.model.selectedIndex() orelse graph.nodes.items.len; GraphCanvas.paintLoopDetailRail( @@ -9500,13 +9548,26 @@ fn onWindowMessage( } if ((app.workspace_controls.panel_visible or app.surface == .workspace) and x >= rail_left and y >= workspace_top) { if (app.surface == .workspace) { - if (GraphCanvas.hitTestLoopDetailCollapse(x, y, client.right, app.workspace_controls.panel_visible)) { + if (!app.workspace_is_quick_chat and GraphCanvas.hitTestLoopDetailCollapse(x, y, client.right, app.workspace_controls.panel_visible)) { app.toggleWorkspaceDetailPanel(); _ = c.InvalidateRect(hwnd, null, 0); result.* = 0; return true; } - if (workspaceGraph(&app.model)) |graph| { + if (app.workspace_is_quick_chat) { + if (app.selected_quick_chat) |chat_index| { + if (chat_index < app.model.quick_chats.items.len) { + const chat = app.model.quick_chats.items[chat_index]; + if (workspaceQuickChatBar(app.workspace_controls, client.right).actionAt(x, y)) |_| { + _ = chat; + app.handleAction(.show_graph); + _ = c.InvalidateRect(hwnd, null, 0); + result.* = 0; + return true; + } + } + } + } else if (workspaceGraph(&app.model)) |graph| { const index = app.model.selectedIndex() orelse graph.nodes.items.len; if (index < graph.nodes.items.len) { const node = graph.nodes.items[index]; @@ -17761,6 +17822,17 @@ fn expectNewLoopAtTopRight(app: *App) !void { try std.testing.expect(!emptyStateButton(app, app.empty_open_folder_button).visible); } +fn expectNewChatAtTopRight(app: *App) !void { + const button = emptyStateButton(app, app.empty_global_overview_button); + try std.testing.expect(button.visible); + try std.testing.expect(App.childTextEquals(app.empty_global_overview_button, "+ New Chat")); + const client = logicalClientRect(app.window.hwnd, app.dpi); + try std.testing.expectEqual(physicalCoordinate(client.right - 140, app.dpi), button.rect.left); + try std.testing.expectEqual(physicalCoordinate(Tokens.header_height + 14, app.dpi), button.rect.top); + try std.testing.expectEqual(physicalCoordinate(120, app.dpi), button.rect.right - button.rect.left); + try std.testing.expect(!emptyStateButton(app, app.empty_open_folder_button).visible); +} + fn clickSidebarProjectRow(app: *App, path: []const u8) !void { var rows = try Sidebar.appendRows(app.allocator, &app.model, app.currentWorktreeInspection(), app.sidebar_scroll, &app.sidebar_state); defer rows.deinit(app.allocator); @@ -17809,6 +17881,65 @@ test "workspace surface: canvas New Loop and Open Folder buttons follow the proj try expectNewLoopAtTopRight(&app); } +test "workspace surface: Quick Chats empty canvas button stays at the top right and says + New Chat" { + var app = try overviewTestApp(Dpi.base_dpi); + defer deinitOverviewTestApp(&app); + defer app.allocator.free(app.pending_project_path); + try std.testing.expect(c.MoveWindow(app.window.hwnd, 0, 0, physicalCoordinate(1200, app.dpi), physicalCoordinate(700, app.dpi), 0) != 0); + app.surface = .quick_chats; + app.createEmptyStateControls(); + defer { + _ = c.DestroyWindow(app.empty_open_folder_button); + _ = c.DestroyWindow(app.empty_global_overview_button); + } + try std.testing.expect(app.empty_open_folder_button != null and app.empty_global_overview_button != null); + try expectNewChatAtTopRight(&app); +} + +test "opening quick chat resets the workspace chrome to the chat and hides the loop panel" { + const allocator = std.testing.allocator; + var app = try overviewTestApp(Dpi.base_dpi); + defer deinitOverviewTestApp(&app); + defer app.allocator.free(app.pending_project_path); + _ = try app.model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"project":{"path":"C:\\loop-fixture","name":"Loop fixture"},"nodes":[{"id":"loop-a","title":"Loop A","loopType":"turnBased","state":{"idle":{}}}],"edges":[]}}} + ); + try std.testing.expect(app.model.setSelectedIndex(0)); + try app.model.quick_chats.append(.{ + .id = try allocator.dupe(u8, "chat-1"), + .title = try allocator.dupe(u8, "Chat 1"), + .backend = try allocator.dupe(u8, "claudeCode"), + }); + var workspace: TerminalWorkspace.Workspace = .{ + .parent = null, + .allocator = allocator, + .zmx_path = &.{}, + .cwd = &.{}, + .input_queue = .{ .allocator = allocator }, + .layout = try @import("WorkspaceLayout.zig").Layout.init(allocator, "C:\\loop-fixture"), + .layout_path = &.{}, + .project_key = &.{}, + }; + defer workspace.deinit(); + try workspace.layout.addTab("agent", true); + try workspace.layout.addTab("shell", false); + app.workspace = &workspace; + app.workspace_controls = .{ .rail_visible = true, .panel_visible = true, .activity_enabled = false }; + app.surface = .project; + app.openQuickChat("chat-1"); + try std.testing.expect(app.workspace_is_quick_chat); + try std.testing.expectEqual(GraphCanvas.Surface.workspace, app.surface); + try std.testing.expect(!app.workspace_controls.panel_visible); + const header = app.headerPresentation(); + try std.testing.expectEqualStrings("Chat 1", header.title); + try std.testing.expectEqualStrings("Quick Chat workspace", header.context); + LoopBarProbe.publish(&app); + try std.testing.expect(LoopBarProbe.has_bar); + try std.testing.expectEqualStrings("chat-1", LoopBarProbe.barLoop()); + try std.testing.expect(LoopBarProbe.stop == null); + try std.testing.expect(LoopBarProbe.panel_toggle == null); +} + test "selection routes with a loop workspace open: Ctrl+Tab review moves the loop bar and pane together" { var fixture: ShowGraphFixture = undefined; try fixture.init(); From af61db773b5a84cd15189b56bafc81614d7326cd Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 13 Oct 2026 22:06:14 -0700 Subject: [PATCH 02/11] Date-stamp quick chats Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f073d0e-2c12-43fc-bfed-4ab3c4867b37 Signed-off-by: Colin Neilens --- graphcode-windows/src/App.zig | 55 ++++++++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index aec3250c..ddc5e20e 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -3003,8 +3003,40 @@ pub const App = struct { } } + fn quickChatMonthName(month: u8) []const u8 { + return switch (month) { + 1 => "Jan", + 2 => "Feb", + 3 => "Mar", + 4 => "Apr", + 5 => "May", + 6 => "Jun", + 7 => "Jul", + 8 => "Aug", + 9 => "Sep", + 10 => "Oct", + 11 => "Nov", + else => "Dec", + }; + } + + fn quickChatTitle(buffer: *[32]u8, unix_seconds: i64) ![]const u8 { + const secs: u64 = @intCast(if (unix_seconds < 0) 0 else unix_seconds); + const epoch_seconds = std.time.epoch.EpochSeconds{ .secs = secs }; + const epoch_day = epoch_seconds.getEpochDay(); + const year_day = epoch_day.calculateYearDay(); + const month_day = year_day.calculateMonthDay(); + return std.fmt.bufPrint(buffer, "Chat — {s} {d}", .{ + quickChatMonthName(month_day.month.numeric()), + month_day.day_index + 1, + }); + } + fn createQuickChat(self: *App) void { - self.client.sendCreateQuickChat("Chat", "claudeCode"); + var title_buffer: [32]u8 = undefined; + const quick_chat_title = quickChatTitle(&title_buffer, std.time.timestamp()) catch "Chat"; + const backend = if (self.product_settings) |settings| settings.default_backend else "claudeCode"; + self.client.sendCreateQuickChat(quick_chat_title, backend); self.setStatus("Creating quick chat..."); } @@ -17940,6 +17972,27 @@ test "opening quick chat resets the workspace chrome to the chat and hides the l try std.testing.expect(LoopBarProbe.panel_toggle == null); } +test "quick chat titles include the creation date" { + var title_buffer: [32]u8 = undefined; + try std.testing.expectEqualStrings("Chat — Jan 1", try App.quickChatTitle(&title_buffer, 0)); +} + +test "creating a quick chat uses the default backend and a date-stamped title" { + var app = try overviewTestApp(Dpi.base_dpi); + defer deinitOverviewTestApp(&app); + defer app.allocator.free(app.pending_project_path); + app.product_settings = try ProductSettings.Settings.init(std.testing.allocator); + std.testing.allocator.free(app.product_settings.?.default_backend); + app.product_settings.?.default_backend = try std.testing.allocator.dupe(u8, "copilotCLI"); + defer app.product_settings.?.deinit(); + const before = app.client.outbound_count; + app.createQuickChat(); + try std.testing.expectEqual(before + 1, app.client.outbound_count); + const command = app.client.outbound[(app.client.outbound_head + app.client.outbound_count - 1) % app.client.outbound.len]; + try std.testing.expect(std.mem.indexOf(u8, command, "\"title\":\"Chat — ") != null); + try std.testing.expect(std.mem.indexOf(u8, command, "\"backend\":\"copilotCLI\"") != null); +} + test "selection routes with a loop workspace open: Ctrl+Tab review moves the loop bar and pane together" { var fixture: ShowGraphFixture = undefined; try fixture.init(); From c216f1e720a6d15ff9e785de69e48987ad00a3e9 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 13 Oct 2026 22:15:06 -0700 Subject: [PATCH 03/11] Format quick chat changes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f073d0e-2c12-43fc-bfed-4ab3c4867b37 Signed-off-by: Colin Neilens --- graphcode-windows/src/App.zig | 62 +++++++++++++++++------------------ 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index ddc5e20e..71b614c0 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -7576,7 +7576,8 @@ pub const App = struct { const sidebar_bottom = self.sidebarContentBottom(client.bottom); const footer_viewport_bottom = GraphCanvas.sidebarBottom(client.bottom, self.workspace_controls, self.surface); provider.syncCanvasBounds((AccessibilityBounds{ .logical = canvas_rect }).physicalRect(self.dpi)); - provider.syncCanvasBounds((AccessibilityBounds{ .logical = canvas_rect }).physicalRect(self.dpi)); const current_inspection = self.currentWorktreeInspection(); + provider.syncCanvasBounds((AccessibilityBounds{ .logical = canvas_rect }).physicalRect(self.dpi)); + const current_inspection = self.currentWorktreeInspection(); // The scroll sink is optional so test sinks that only record elements need not declare it. if (comptime providerHasDecl(@TypeOf(provider), "syncSidebarScroll")) { provider.syncSidebarScroll( @@ -11702,8 +11703,8 @@ test "sidebar UIA bounds and hit targets stay clear of the update banner at 1280 std.debug.print( "{s} bounds {d},{d},{d},{d} intersect banner {d},{d},{d},{d} at dpi {d} width {d} scroll {d}\n", .{ - sink.name(index), bounds.left, bounds.top, bounds.right, bounds.bottom, - banner.left, banner.top, banner.right, banner.bottom, case.dpi, + sink.name(index), bounds.left, bounds.top, bounds.right, bounds.bottom, + banner.left, banner.top, banner.right, banner.bottom, case.dpi, case.width, app.sidebar_scroll, }, ); @@ -13783,7 +13784,6 @@ const LiveTerminalFixture = struct { _ = onWindowMessage(&self.app, self.app.window.hwnd, c.WM_TIMER, MainWindow.timer_id, 0, &result); } - /// A daemon graph change through the production frame path (and its workspace refresh). fn deliver(self: *LiveTerminalFixture, loops: []const LiveLoop) !void { self.revision += 1; @@ -15695,7 +15695,7 @@ test "workspace layout: deleting a loop ends the shell sessions of its own tabs test "workspace layout: restore drops a pane whose session is gone instead of recreating it" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); const allocator = std.testing.allocator; const WorkspaceLayoutFile = @import("WorkspaceLayout.zig"); @@ -15736,7 +15736,7 @@ fn restoredAliveShell(fixture: *LiveTerminalFixture, session: []const u8) bool { test "workspace layout: at startup an unreachable agent pane and shell tab keep their retry slots while a live saved pane restores, and both come back" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); const allocator = std.testing.allocator; const WorkspaceLayoutFile = @import("WorkspaceLayout.zig"); @@ -15821,7 +15821,7 @@ test "workspace layout: restore keeps a pane whose session zmx could not reach, test "workspace layout: a loop's shells are neither ended nor forgotten while zmx cannot reach their session" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); try fixture.saveLoopLayout(fixture.project, "gone-loop", &.{ .{ .id = "gone-loop", .agent = true }, .{ .id = shell_uuid_one } }); try fixture.markOwned("gone-loop", &.{shell_uuid_one}); @@ -15843,7 +15843,7 @@ test "workspace layout: a loop's shells are neither ended nor forgotten while zm test "workspace layout: a shell whose daemon refused the connection is gone: its record is forgotten and nothing is killed" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); try fixture.saveLoopLayout(fixture.project, "gone-loop", &.{ .{ .id = "gone-loop", .agent = true }, .{ .id = shell_uuid_one } }); try fixture.markOwned("gone-loop", &.{shell_uuid_one}); @@ -15858,7 +15858,7 @@ test "workspace layout: a shell whose daemon refused the connection is gone: its test "live terminal: opening a loop whose session zmx could not reach waits and attaches only once it answers" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); const app = &fixture.app; @@ -16014,7 +16014,7 @@ const nodes_full = node_loop_a ++ "," ++ test "workspace layout: a project's first snapshot ends the shells of loops deleted while the shell was closed, once the daemon's own answer agrees, and only those" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); const other_project = "C:\\another-project"; const alias_project = try std.mem.replaceOwned(u8, std.testing.allocator, fixture.project, "\\", "/"); @@ -16057,7 +16057,7 @@ test "workspace layout: a project's first snapshot ends the shells of loops dele test "workspace layout: an empty or partial first snapshot sweeps nothing that the full snapshot still holds" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); try fixture.saveLoopLayout(fixture.project, "loop-a", &.{ .{ .id = "loop-a", .agent = true }, .{ .id = shell_uuid_one } }); try fixture.saveLoopLayout(fixture.project, "gone-loop", &.{ .{ .id = "gone-loop", .agent = true }, .{ .id = shell_uuid_two } }); @@ -16092,7 +16092,7 @@ test "workspace layout: an empty or partial first snapshot sweeps nothing that t test "workspace layout: a layout saved after this shell started is another shell's and is never swept" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); // Written while this shell runs, as a second shell sharing the support directory would. try fixture.saveLoopLayout(fixture.project, "other-shells-loop", &.{ .{ .id = "other-shells-loop", .agent = true }, .{ .id = shell_uuid_one } }); @@ -16699,7 +16699,7 @@ test "workspace layout: a stale or revisionless frame that names the loop again /// A layout older than this shell, with a recorded live shell session, for a loop the project's /// first usable snapshot will not name; the shell saw only an empty graph of the project before. fn emptyFirstGraphWithOldLayout(fixture: *LiveTerminalFixture) !i128 { - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); try fixture.saveLoopLayout(fixture.project, "loop-d", &.{ .{ .id = "loop-d", .agent = true }, .{ .id = shell_uuid_two } }); try fixture.markOwned("loop-d", &.{shell_uuid_two}); try fixture.setLive(&.{ "loop-a", "loop-d", shell_uuid_two }); @@ -16745,7 +16745,7 @@ test "workspace layout: after a reconnect a project the shell holds no snapshot test "workspace layout: a project first opened after a reconnect is left alone too" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); try fixture.saveLoopLayout(fixture.project, "loop-d", &.{ .{ .id = "loop-d", .agent = true }, .{ .id = shell_uuid_two } }); try fixture.markOwned("loop-d", &.{shell_uuid_two}); @@ -16768,7 +16768,7 @@ test "workspace layout: a project first opened after a reconnect is left alone t test "workspace layout: a reconnect before the shell's first poll or snapshot still ends the age sweep" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); try fixture.saveLoopLayout(fixture.project, "loop-d", &.{ .{ .id = "loop-d", .agent = true }, .{ .id = shell_uuid_two } }); try fixture.markOwned("loop-d", &.{shell_uuid_two}); @@ -16793,7 +16793,7 @@ test "workspace layout: a reconnect before the shell's first poll or snapshot st test "workspace layout: a snapshot queued before the transport failed arms nothing while the client is not connected" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); try fixture.saveLoopLayout(fixture.project, "loop-d", &.{ .{ .id = "loop-d", .agent = true }, .{ .id = shell_uuid_two } }); try fixture.markOwned("loop-d", &.{shell_uuid_two}); @@ -16823,7 +16823,7 @@ test "workspace layout: a snapshot queued before the transport failed arms nothi test "workspace layout: a look the age sweep started for a layout the shell never held does not outlive the connection" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); try fixture.saveLoopLayout(fixture.project, "loop-d", &.{ .{ .id = "loop-d", .agent = true }, .{ .id = shell_uuid_two } }); try fixture.markOwned("loop-d", &.{shell_uuid_two}); @@ -17196,7 +17196,7 @@ test "workspace layout: an empty graph, and a project the shell does not hold, a test "workspace layout: a shell session whose uuid is a graph node's id is never ended" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); // Node ids are uuids; the daemon names a loop's session after the uppercase one. const node_uuid = shell_uuid_seven; @@ -17221,7 +17221,7 @@ test "workspace layout: a shell session whose uuid is a graph node's id is never test "workspace layout: a layout that cannot be attributed to the deleted loop ends nothing" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); const allocator = std.testing.allocator; const WorkspaceLayoutFile = @import("WorkspaceLayout.zig"); @@ -17244,7 +17244,7 @@ test "workspace layout: a layout that cannot be attributed to the deleted loop e test "workspace layout: deleting a loop ends only recorded, live, shell-shaped sessions nothing else names" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); const foreign = "other-" ++ shell_uuid_two; // A layout from an earlier shell, a stale copy or a hand edit can record anything as a @@ -17274,7 +17274,7 @@ test "workspace layout: deleting a loop ends only recorded, live, shell-shaped s test "workspace layout: a loop's shells are ended only after a successful session listing" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); try fixture.saveLoopLayout(fixture.project, "gone-loop", &.{ .{ .id = "gone-loop", .agent = true }, .{ .id = shell_uuid_one } }); try fixture.markOwned("gone-loop", &.{shell_uuid_one}); @@ -17398,7 +17398,7 @@ test "workspace layout: a corrupt, foreign or newer-schema loop layout is kept a test "workspace layout: a live session that only a layout names is never ended without proof the shell made it" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); // A foreign session that happens to look like a shell's: lowercase uuid, live, no node's // id, named by no other layout. The name shape is not provenance. @@ -17418,7 +17418,7 @@ test "workspace layout: a live session that only a layout names is never ended w test "workspace layout: a tab's session is recorded as the shell's own when minted and forgotten once ended" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); const app = &fixture.app; const workspace = &fixture.workspace; @@ -17449,7 +17449,7 @@ test "workspace layout: a tab's session is recorded as the shell's own when mint test "workspace layout: a layout scan cut short by its cap refuses the kill instead of assuming nothing claims it" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); try fixture.saveLoopLayout(fixture.project, "gone-loop", &.{ .{ .id = "gone-loop", .agent = true }, .{ .id = shell_uuid_one } }); try fixture.markOwned("gone-loop", &.{shell_uuid_one}); @@ -17471,7 +17471,7 @@ test "workspace layout: a layout scan cut short by its cap refuses the kill inst test "workspace layout: a session another loop's record names is never ended by a different loop's retirement" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); // The window in a second shell sharing this layout directory: it has recorded a fresh // session for its own loop-a, while a stale layout of the deleted gone-loop names the uuid. @@ -17491,7 +17491,7 @@ test "workspace layout: a session another loop's record names is never ended by /// sequence waits for each attach to finish before the next save. fn expectClaimAndRecordSeenByAttach(hold_layout: bool) !void { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); const app = &fixture.app; @@ -17574,7 +17574,7 @@ fn attachStartCount(dir: std.fs.Dir) !usize { test "workspace layout: New Tab and Split with no free surface slot fail before claiming a session" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); const allocator = std.testing.allocator; const workspace = &fixture.workspace; @@ -17614,7 +17614,7 @@ test "workspace layout: New Tab and Split with no free surface slot fail before test "workspace layout: New Tab and Split with every free slot reserved by a pending restore fail before claiming a session" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); const allocator = std.testing.allocator; const workspace = &fixture.workspace; @@ -17696,7 +17696,7 @@ test "workspace layout: a failed rollback save leaves the claim on disk, never a test "workspace layout: restore drops a loop layout's claimed pane whose session was never started" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); const allocator = std.testing.allocator; const WorkspaceLayoutFile = @import("WorkspaceLayout.zig"); @@ -17723,7 +17723,7 @@ test "workspace layout: restore drops a loop layout's claimed pane whose session test "workspace layout: an unreadable unrelated layout defers the cleanup, and the next sweep ends the shell" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); try fixture.saveLoopLayout(fixture.project, "gone-loop", &.{ .{ .id = "gone-loop", .agent = true }, .{ .id = shell_uuid_one } }); try fixture.markOwned("gone-loop", &.{shell_uuid_one}); @@ -17747,7 +17747,7 @@ test "workspace layout: an unreadable unrelated layout defers the cleanup, and t test "workspace layout: a corrupt layout that cannot be set aside is left as it is and its loop is not opened" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); const app = &fixture.app; const truncated = "{\"schemaVersion\":2,\"project\":\""; From 81da06f34c29caa03a96db80a801960263d4a2e4 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 13 Oct 2026 22:18:26 -0700 Subject: [PATCH 04/11] Use local time for quick chat titles Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f073d0e-2c12-43fc-bfed-4ab3c4867b37 Signed-off-by: Colin Neilens --- graphcode-windows/src/App.zig | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 71b614c0..23fbf0b5 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -3020,21 +3020,18 @@ pub const App = struct { }; } - fn quickChatTitle(buffer: *[32]u8, unix_seconds: i64) ![]const u8 { - const secs: u64 = @intCast(if (unix_seconds < 0) 0 else unix_seconds); - const epoch_seconds = std.time.epoch.EpochSeconds{ .secs = secs }; - const epoch_day = epoch_seconds.getEpochDay(); - const year_day = epoch_day.calculateYearDay(); - const month_day = year_day.calculateMonthDay(); + fn quickChatTitleFromSystemTime(buffer: *[32]u8, local_time: c.SYSTEMTIME) ![]const u8 { return std.fmt.bufPrint(buffer, "Chat — {s} {d}", .{ - quickChatMonthName(month_day.month.numeric()), - month_day.day_index + 1, + quickChatMonthName(@intCast(local_time.wMonth)), + local_time.wDay, }); } fn createQuickChat(self: *App) void { var title_buffer: [32]u8 = undefined; - const quick_chat_title = quickChatTitle(&title_buffer, std.time.timestamp()) catch "Chat"; + var local_time: c.SYSTEMTIME = undefined; + c.GetLocalTime(&local_time); + const quick_chat_title = quickChatTitleFromSystemTime(&title_buffer, local_time) catch "Chat"; const backend = if (self.product_settings) |settings| settings.default_backend else "claudeCode"; self.client.sendCreateQuickChat(quick_chat_title, backend); self.setStatus("Creating quick chat..."); @@ -17974,7 +17971,17 @@ test "opening quick chat resets the workspace chrome to the chat and hides the l test "quick chat titles include the creation date" { var title_buffer: [32]u8 = undefined; - try std.testing.expectEqualStrings("Chat — Jan 1", try App.quickChatTitle(&title_buffer, 0)); + const local_time = c.SYSTEMTIME{ + .wYear = 2026, + .wMonth = 1, + .wDayOfWeek = 0, + .wDay = 1, + .wHour = 0, + .wMinute = 0, + .wSecond = 0, + .wMilliseconds = 0, + }; + try std.testing.expectEqualStrings("Chat — Jan 1", try App.quickChatTitleFromSystemTime(&title_buffer, local_time)); } test "creating a quick chat uses the default backend and a date-stamped title" { From e861f45bb6349e69baefa668efed88f24a0b0fbd Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 13 Oct 2026 23:21:50 -0700 Subject: [PATCH 05/11] Fix Quick Chat workspace bounds Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f073d0e-2c12-43fc-bfed-4ab3c4867b37 Signed-off-by: Colin Neilens --- graphcode-windows/src/App.zig | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 23fbf0b5..101a8149 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -7762,9 +7762,13 @@ pub const App = struct { self.allocator.free(identity); return; }; + const workspace_top = if (self.workspace_controls.panel_visible) + inputBounds(client.right, client.bottom, self.workspace_controls).workspace_top + else + canvas_rect.top; const workspace_bounds = (AccessibilityBounds{ .logical = .{ .left = canvas_rect.left, - .top = inputBounds(client.right, client.bottom, self.workspace_controls).workspace_top, + .top = workspace_top, .right = canvas_rect.right, .bottom = client.bottom, } }).physicalRect(self.dpi); @@ -17927,6 +17931,22 @@ test "workspace surface: Quick Chats empty canvas button stays at the top right test "opening quick chat resets the workspace chrome to the chat and hides the loop panel" { const allocator = std.testing.allocator; + const Probe = struct { + canvas: ?c.RECT = null, + workspace: ?Accessibility.DynamicElement = null, + checked: bool = false, + + fn syncCanvasBounds(self: *@This(), bounds: c.RECT) void { + self.canvas = bounds; + } + + fn syncElements(self: *@This(), _: []const u8, elements: []const Accessibility.DynamicElement, _: WorktreeStatus.Policy, _: Accessibility.WorktreeCapabilities) void { + self.checked = true; + for (elements) |element| { + if (std.mem.eql(u8, element.identity, "quick-chat-workspace:chat-1")) self.workspace = element; + } + } + }; var app = try overviewTestApp(Dpi.base_dpi); defer deinitOverviewTestApp(&app); defer app.allocator.free(app.pending_project_path); @@ -17967,6 +17987,13 @@ test "opening quick chat resets the workspace chrome to the chat and hides the l try std.testing.expectEqualStrings("chat-1", LoopBarProbe.barLoop()); try std.testing.expect(LoopBarProbe.stop == null); try std.testing.expect(LoopBarProbe.panel_toggle == null); + var accessibility: Probe = .{}; + app.syncAccessibilityTo(&accessibility, .{ .left = 0, .top = 0, .right = 1200, .bottom = 900 }); + try std.testing.expect(accessibility.checked); + const canvas = accessibility.canvas orelse return error.MissingCanvasBounds; + try std.testing.expectEqualDeep([4]i32{ 220, 34, 1200, 900 }, [4]i32{ canvas.left, canvas.top, canvas.right, canvas.bottom }); + const workspace_element = accessibility.workspace orelse return error.MissingQuickChatWorkspace; + try std.testing.expectEqualDeep([4]i32{ 220, 34, 1200, 900 }, [4]i32{ workspace_element.left, workspace_element.top, workspace_element.right, workspace_element.bottom }); } test "quick chat titles include the creation date" { From 340820a13e29e4a8f129c11449c1bf2306c0979a Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Wed, 14 Oct 2026 01:53:42 -0700 Subject: [PATCH 06/11] Fix Quick Chat workspace chrome Scope the Quick Chat workspace to its own bar and persist its creation timestamp. Refs #774 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3072b347-66bd-4d89-8c59-71a1f6f4a33d Signed-off-by: Colin Neilens --- .../AccessibilityProvider.Native.Tests.cpp | 112 +++++++ graphcode-windows/src/App.zig | 119 ++++---- graphcode-windows/src/GraphModel.zig | 68 ++++- graphcode-windows/src/TerminalSurface.zig | 282 +++++++++++++++++- 4 files changed, 503 insertions(+), 78 deletions(-) diff --git a/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp b/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp index 6b3f8dd7..1f8736ae 100644 --- a/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp +++ b/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp @@ -520,12 +520,124 @@ static void nativeCommandAndScrollChecks() { DestroyWindow(capture); } +static int canvasCardCount(IRawElementProviderFragment *graph) { + IRawElementProviderFragment *element = nullptr; + check(graph && graph->Navigate(NavigateDirection_FirstChild, &element) == S_OK, + "Graph children are available for the canvas-card count"); + int count = 0; + while (element) { + if (stringProperty(element, UIA_AutomationIdPropertyId).rfind(L"canvas-card-", 0) == 0) + ++count; + IRawElementProviderFragment *next = nullptr; + check(element->Navigate(NavigateDirection_NextSibling, &next) == S_OK, + "Graph child sibling navigation succeeds"); + element->Release(); + element = next; + } + return count; +} + +static void nativeWorkspaceRestartAutomationIdChecks() { + HWND capture = createCaptureWindow(); + check(capture != nullptr, "Quick Chat provider window exists"); + if (!capture) return; + IRawElementProviderSimple *provider = gc_uia_create(capture); + check(provider != nullptr, "Quick Chat provider exists"); + if (!provider) { + DestroyWindow(capture); + return; + } + + auto update = reinterpret_cast(&gc_uia_update); + const char *baseline_identities[] = {"canvas-card:existing"}; + const char *baseline_names[] = {"Existing card"}; + const int baseline_parents[] = {4}; + const int baseline_flags[] = {0}; + const int baseline_bounds[] = {0, 0, 0, 0}; + check(update(provider, "Baseline", baseline_identities, baseline_names, baseline_parents, + baseline_flags, baseline_flags, baseline_flags, baseline_bounds, + 1, 0, 1, 0, 0, 0, 0) == S_OK, + "baseline canvas card publication succeeds"); + + IRawElementProviderFragment *root = nullptr; + check(provider->QueryInterface(IID_PPV_ARGS(&root)) == S_OK && root, + "Quick Chat provider exposes its root fragment"); + if (root) { + auto *projects = child(root, NavigateDirection_FirstChild); + auto *loops = child(projects, NavigateDirection_NextSibling); + auto *worktrees = child(loops, NavigateDirection_NextSibling); + auto *graph = child(worktrees, NavigateDirection_NextSibling); + const int canvas_cards_before = canvasCardCount(graph); + check(canvas_cards_before == 1, "baseline canvas-card count is one"); + + const char *identities[] = {"canvas-card:existing", "workspace-restart-session:chat"}; + const char *names[] = {"Existing card", "Restart session"}; + const int parents[] = {4, 4}; + const int none[] = {0, 0}; + const int actions[] = {0, 1}; + const int bounds[] = {0, 0, 0, 0, 0, 0, 0, 0}; + check(update(provider, "Quick Chat", identities, names, parents, none, none, actions, + bounds, 2, 0, 1, 0, 0, 0, 0) == S_OK, + "Quick Chat restart publication succeeds"); + + IRawElementProviderFragment *element = nullptr; + check(graph && graph->Navigate(NavigateDirection_FirstChild, &element) == S_OK, + "Quick Chat Graph children are available"); + IRawElementProviderFragment *restart = nullptr; + while (element) { + if (stringProperty(element, UIA_NamePropertyId) == L"Restart session") { + restart = element; + break; + } + IRawElementProviderFragment *next = nullptr; + check(element->Navigate(NavigateDirection_NextSibling, &next) == S_OK, + "Quick Chat child sibling navigation succeeds"); + element->Release(); + element = next; + } + check(restart != nullptr, "Restart session is exposed as a Graph child"); + if (restart) { + const std::wstring automation_id = stringProperty(restart, UIA_AutomationIdPropertyId); + check(automation_id.rfind(L"workspace-restart-session-", 0) == 0, + "Restart session has its stable workspace AutomationId"); + IRawElementProviderSimple *simple = nullptr; + check(restart->QueryInterface(IID_PPV_ARGS(&simple)) == S_OK && simple, + "Restart session supports native UIA properties"); + if (simple) { + VARIANT value; + VariantInit(&value); + const HRESULT result = simple->GetPropertyValue(UIA_ControlTypePropertyId, &value); + check(result == S_OK && value.vt == VT_I4 && + value.lVal == UIA_ButtonControlTypeId, + "Restart session is exposed as an action button"); + VariantClear(&value); + simple->Release(); + } + restart->Release(); + } else if (element) { + element->Release(); + } + + const int canvas_cards_after = canvasCardCount(graph); + check(canvas_cards_after == canvas_cards_before, + "Restart session does not increase the canvas-card count"); + if (graph) graph->Release(); + if (worktrees) worktrees->Release(); + if (loops) loops->Release(); + if (projects) projects->Release(); + root->Release(); + } + gc_uia_release(provider); + DestroyWindow(capture); +} + int main() { HWND hwnd = CreateWindowW(L"STATIC", L"UIA native test", WS_OVERLAPPED, 137, 211, 300, 300, nullptr, nullptr, nullptr, nullptr); check(hwnd != nullptr, "native window exists"); if (!hwnd) return 1; nativeCommandAndScrollChecks(); + nativeWorkspaceRestartAutomationIdChecks(); auto *provider = gc_uia_create(hwnd); check(provider != nullptr, "native provider exists"); diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 101a8149..1cb5b5c5 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -26,6 +26,7 @@ const GraphModel = @import("GraphModel.zig"); const InputRouter = @import("InputRouter.zig"); const MainWindow = @import("MainWindow.zig"); const TerminalWorkspace = @import("TerminalWorkspace.zig"); +const TerminalSurface = @import("TerminalSurface.zig"); const LoopBarLayout = @import("LoopBarLayout.zig"); const Clipboard = @import("Clipboard.zig"); const TerminalKeys = @import("TerminalKeys.zig"); @@ -1246,12 +1247,10 @@ fn workspaceLoopBar(controls: WorkspaceControls.State, client_right: i32, resolv ); } -fn workspaceQuickChatBar(controls: WorkspaceControls.State, client_right: i32) LoopBarLayout.Layout { - return TerminalWorkspace.loopBarLayout( +fn workspaceQuickChatBar(controls: WorkspaceControls.State, client_right: i32) TerminalSurface.QuickChatBarLayout { + return TerminalSurface.quickChatBarLayout( if (controls.rail_visible) Tokens.sidebar_width else 0, client_right, - true, - false, ); } @@ -1343,6 +1342,7 @@ const UiaDynamicTarget = union(enum) { }, quick_chat: []const u8, workspace_show_graph, + workspace_restart_session, workspace_stop, workspace_new_tab, workspace_split_right, @@ -3086,13 +3086,18 @@ pub const App = struct { if (self.workspace) |workspace| { if (std.process.getEnvVarOwned(self.allocator, "USERPROFILE")) |home| { defer self.allocator.free(home); - _ = workspace.rebindProject(home) catch {}; + _ = workspace.rebindProject(home) catch { + self.setStatus("Unable to open quick chat workspace"); + return; + }; } else |_| {} - workspace.scopeToLoop(chat.id, TerminalWorkspace.quick_chat_scope_project, false) catch { + workspace.scopeToLoop(chat.id, TerminalWorkspace.quick_chat_scope_project, true) catch { self.setStatus("Unable to open quick chat workspace"); + return; }; - workspace.openNode(0, chat.id) catch { + workspace.openLaunchedNode(0, chat.id, 0) catch { self.setStatus("Unable to open quick chat workspace"); + return; }; } self.syncAccessibility(); @@ -7140,22 +7145,19 @@ pub const App = struct { ); } if (self.empty_global_overview_button != null) { - const label = if (is_quick_chats) "+ New Chat" else "New Loop"; + const label = if (is_quick_chats) "New Chat" else "New Loop"; if (!childTextEquals(self.empty_global_overview_button, label)) setButtonText(self.empty_global_overview_button, label); const show_primary = is_quick_chats or is_overview or (self.surface == .project and graph != null and !is_global); - const primary_x = if (is_quick_chats) content_right - 140 else if (is_empty) x else content_right - 140; - const primary_y = if (is_quick_chats) - Tokens.header_height + 14 - else if (is_empty) + const primary_x = if (is_empty) x else content_right - 140; + const primary_y = if (is_empty) y + (if (is_global or is_overview) @as(i32, 42) else @as(i32, 0)) else Tokens.header_height + 14; - const primary_width: i32 = if (is_quick_chats) 120 else if (is_empty) 220 else 120; placeChildControl(self.empty_global_overview_button, show_primary, .{ .left = physicalCoordinate(primary_x, self.dpi), .top = physicalCoordinate(primary_y, self.dpi), - .right = physicalCoordinate(primary_x, self.dpi) + physicalCoordinate(primary_width, self.dpi), + .right = physicalCoordinate(primary_x, self.dpi) + physicalCoordinate(if (is_empty) 220 else 120, self.dpi), .bottom = physicalCoordinate(primary_y, self.dpi) + physicalCoordinate(32, self.dpi), }); } @@ -7955,6 +7957,7 @@ pub const App = struct { const loop_bar = workspaceQuickChatBar(self.workspace_controls, client.right); self.appendAccessibilityElement(&elements, &owned_identities, "workspace-loop-bar", chat.id, chat.title, 4, .{ .logical = loopBarRect(loop_bar.bar) }, false, false) catch return; self.appendAccessibilityElement(&elements, &owned_identities, "workspace-show-graph", "show-graph", "Show in Graph", 4, .{ .logical = loopBarRect(loop_bar.show_graph) }, false, false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-restart-session", "control", "Restart session", 4, .{ .logical = loopBarRect(loop_bar.restart_session) }, false, false) catch return; } } for (workspace.layout.tabs.items, 0..) |tab, tab_index| { @@ -8244,6 +8247,7 @@ pub const App = struct { } const workspace_static = [_]struct { identity: []const u8, target: UiaDynamicTarget }{ .{ .identity = "workspace-show-graph:show-graph", .target = .workspace_show_graph }, + .{ .identity = "workspace-restart-session:control", .target = .workspace_restart_session }, .{ .identity = "workspace-new-tab:control", .target = .workspace_new_tab }, .{ .identity = "workspace-split-right:control", .target = .workspace_split_right }, .{ .identity = "workspace-split-down:control", .target = .workspace_split_down }, @@ -8416,6 +8420,7 @@ pub const App = struct { } }, .workspace_show_graph => self.handleAction(.show_graph), + .workspace_restart_session => {}, .workspace_stop => self.stopSelectedNode(), .workspace_new_tab => self.handleAction(.new_tab), .workspace_split_right => self.handleAction(.split_horizontal), @@ -9192,22 +9197,12 @@ fn onWindowMessage( if (app.selected_quick_chat) |chat_index| { if (chat_index < app.model.quick_chats.items.len) { const chat = app.model.quick_chats.items[chat_index]; - TerminalWorkspace.Workspace.paintLoopBar( + TerminalWorkspace.Workspace.paintQuickChatBar( hdc, - app.allocator, if (app.workspace_controls.rail_visible) Tokens.sidebar_width else 0, workspace_right, - "Chat", chat.title, - "composite", - "IDLE", - if (chat.activity.len != 0) chat.activity else "Not piloted", - if (chat.backend.len != 0) chat.backend else "claudeCode", - null, - 0, - null, - true, - false, + chat.created_at, ); } } @@ -9591,10 +9586,11 @@ fn onWindowMessage( if (app.workspace_is_quick_chat) { if (app.selected_quick_chat) |chat_index| { if (chat_index < app.model.quick_chats.items.len) { - const chat = app.model.quick_chats.items[chat_index]; - if (workspaceQuickChatBar(app.workspace_controls, client.right).actionAt(x, y)) |_| { - _ = chat; - app.handleAction(.show_graph); + if (workspaceQuickChatBar(app.workspace_controls, client.right).actionAt(x, y)) |action| { + switch (action) { + .restart_session => {}, + .show_graph => app.handleAction(.show_graph), + } _ = c.InvalidateRect(hwnd, null, 0); result.* = 0; return true; @@ -17855,17 +17851,6 @@ fn expectNewLoopAtTopRight(app: *App) !void { try std.testing.expect(!emptyStateButton(app, app.empty_open_folder_button).visible); } -fn expectNewChatAtTopRight(app: *App) !void { - const button = emptyStateButton(app, app.empty_global_overview_button); - try std.testing.expect(button.visible); - try std.testing.expect(App.childTextEquals(app.empty_global_overview_button, "+ New Chat")); - const client = logicalClientRect(app.window.hwnd, app.dpi); - try std.testing.expectEqual(physicalCoordinate(client.right - 140, app.dpi), button.rect.left); - try std.testing.expectEqual(physicalCoordinate(Tokens.header_height + 14, app.dpi), button.rect.top); - try std.testing.expectEqual(physicalCoordinate(120, app.dpi), button.rect.right - button.rect.left); - try std.testing.expect(!emptyStateButton(app, app.empty_open_folder_button).visible); -} - fn clickSidebarProjectRow(app: *App, path: []const u8) !void { var rows = try Sidebar.appendRows(app.allocator, &app.model, app.currentWorktreeInspection(), app.sidebar_scroll, &app.sidebar_state); defer rows.deinit(app.allocator); @@ -17914,23 +17899,18 @@ test "workspace surface: canvas New Loop and Open Folder buttons follow the proj try expectNewLoopAtTopRight(&app); } -test "workspace surface: Quick Chats empty canvas button stays at the top right and says + New Chat" { - var app = try overviewTestApp(Dpi.base_dpi); - defer deinitOverviewTestApp(&app); - defer app.allocator.free(app.pending_project_path); - try std.testing.expect(c.MoveWindow(app.window.hwnd, 0, 0, physicalCoordinate(1200, app.dpi), physicalCoordinate(700, app.dpi), 0) != 0); - app.surface = .quick_chats; - app.createEmptyStateControls(); - defer { - _ = c.DestroyWindow(app.empty_open_folder_button); - _ = c.DestroyWindow(app.empty_global_overview_button); - } - try std.testing.expect(app.empty_open_folder_button != null and app.empty_global_overview_button != null); - try expectNewChatAtTopRight(&app); -} - test "opening quick chat resets the workspace chrome to the chat and hides the loop panel" { const allocator = std.testing.allocator; + const user_profile_key = std.unicode.utf8ToUtf16LeStringLiteral("USERPROFILE"); + var original_environment = try std.process.getEnvMap(allocator); + defer original_environment.deinit(); + defer setWorkspaceTestEnvironment(user_profile_key, original_environment.get("USERPROFILE")) catch @panic("USERPROFILE restore failed"); + var temporary = std.testing.tmpDir(.{}); + defer temporary.cleanup(); + const home = try temporary.dir.realpathAlloc(allocator, "."); + defer allocator.free(home); + try setWorkspaceTestEnvironment(user_profile_key, home); + const Probe = struct { canvas: ?c.RECT = null, workspace: ?Accessibility.DynamicElement = null, @@ -17959,18 +17939,21 @@ test "opening quick chat resets the workspace chrome to the chat and hides the l .title = try allocator.dupe(u8, "Chat 1"), .backend = try allocator.dupe(u8, "claudeCode"), }); + const layout_path = try std.fs.path.join(allocator, &.{ home, "layout.json" }); var workspace: TerminalWorkspace.Workspace = .{ .parent = null, .allocator = allocator, .zmx_path = &.{}, .cwd = &.{}, .input_queue = .{ .allocator = allocator }, - .layout = try @import("WorkspaceLayout.zig").Layout.init(allocator, "C:\\loop-fixture"), - .layout_path = &.{}, - .project_key = &.{}, + .layout = try @import("WorkspaceLayout.zig").Layout.init(allocator, home), + .layout_path = layout_path, + .project_key = try allocator.dupe(u8, home), + .project_path = try allocator.dupe(u8, home), + .legacy_directory = try allocator.dupe(u8, home), }; defer workspace.deinit(); - try workspace.layout.addTab("agent", true); + try workspace.layout.addTab("loop-a", true); try workspace.layout.addTab("shell", false); app.workspace = &workspace; app.workspace_controls = .{ .rail_visible = true, .panel_visible = true, .activity_enabled = false }; @@ -17982,6 +17965,22 @@ test "opening quick chat resets the workspace chrome to the chat and hides the l const header = app.headerPresentation(); try std.testing.expectEqualStrings("Chat 1", header.title); try std.testing.expectEqualStrings("Quick Chat workspace", header.context); + const quick_chat_bar = workspaceQuickChatBar(app.workspace_controls, 1200); + try std.testing.expect(quick_chat_bar.title != null); + try std.testing.expect(quick_chat_bar.state != null); + try std.testing.expect(quick_chat_bar.activity != null); + try std.testing.expect(quick_chat_bar.age != null); + try std.testing.expectEqual( + TerminalSurface.QuickChatBarAction.restart_session, + quick_chat_bar.actionAt( + @divTrunc(quick_chat_bar.restart_session.left + quick_chat_bar.restart_session.right, 2), + @divTrunc(quick_chat_bar.restart_session.top + quick_chat_bar.restart_session.bottom, 2), + ).?, + ); + try std.testing.expectEqual(@as(usize, 1), workspace.layout.tabs.items.len); + try std.testing.expectEqual(@as(usize, 1), workspace.layout.tabs.items[0].panes.items.len); + try std.testing.expectEqualStrings("chat-1", workspace.layout.tabs.items[0].panes.items[0].id); + try std.testing.expect(workspace.layout.tabs.items[0].panes.items[0].launches_agent); LoopBarProbe.publish(&app); try std.testing.expect(LoopBarProbe.has_bar); try std.testing.expectEqualStrings("chat-1", LoopBarProbe.barLoop()); diff --git a/graphcode-windows/src/GraphModel.zig b/graphcode-windows/src/GraphModel.zig index 5a41f071..76b9434f 100644 --- a/graphcode-windows/src/GraphModel.zig +++ b/graphcode-windows/src/GraphModel.zig @@ -46,6 +46,7 @@ pub const QuickChat = struct { id: []u8, title: []u8, backend: []u8, + created_at: ?u64 = null, activity: []u8 = &.{}, activity_sequence: u64 = 0, }; @@ -951,10 +952,14 @@ pub const Model = struct { fn upsertQuickChat(self: *Model, object: []const u8) !void { const id = duplicateJsonString(self.allocator, object, "id") catch return; errdefer self.allocator.free(id); + var fields = try JsonFields.init(self.allocator, object); + defer fields.deinit(); + const created_at = unixSecondsFromReferenceDate(fields.get("createdAt")); for (self.quick_chats.items) |*existing| { if (std.mem.eql(u8, existing.id, id)) { self.allocator.free(existing.title); existing.title = try duplicateJsonString(self.allocator, object, "title"); + if (created_at) |timestamp| existing.created_at = timestamp; self.allocator.free(id); return; } @@ -963,6 +968,7 @@ pub const Model = struct { .id = id, .title = try duplicateJsonString(self.allocator, object, "title"), .backend = try duplicateJsonStringOr(self.allocator, object, "backend", "claudeCode"), + .created_at = created_at, }; if (std.mem.indexOf(u8, object, "\"activity\"")) |activity_key| { if (indexOfByte(object, activity_key, '{')) |activity_open| { @@ -1884,10 +1890,14 @@ const reference_date_unix_offset: u64 = 978_307_200; // as a placeholder, so it and anything earlier stay unknown rather than becoming // a decades-old age; results stay below the label helpers' 1e12 millisecond cutoff. fn unixSecondsFromReferenceDate(value: JsonValue) ?u64 { + return unixSecondsFromReferenceSeconds(value.float()); +} + +fn unixSecondsFromReferenceSeconds(seconds: ?f64) ?u64 { const max_reference_seconds: f64 = 1_000_000_000_000 - @as(f64, @floatFromInt(reference_date_unix_offset)); - const seconds = value.float() orelse return null; - if (!(seconds > 0) or seconds >= max_reference_seconds) return null; - return @as(u64, @intFromFloat(@floor(seconds))) + reference_date_unix_offset; + const seconds_value = seconds orelse return null; + if (!(seconds_value > 0) or seconds_value >= max_reference_seconds) return null; + return @as(u64, @intFromFloat(@floor(seconds_value))) + reference_date_unix_offset; } fn decodeNodes( @@ -2895,6 +2905,58 @@ test "quick chat fixture preserves stable identity and activity ordering" { try std.testing.expectEqual(@as(u64, 2), model.quick_chats.items[0].activity_sequence); } +test "quick chats retain creation timestamps for the workspace age" { + try std.testing.expect(@hasField(QuickChat, "created_at")); +} + +test "quick chat timestamps decode valid dates and tolerate missing or malformed values" { + const cases = [_]struct { + frame: []const u8, + expected: ?u64, + }{ + .{ + .frame = "{\"version\":2,\"kind\":\"event\",\"sequence\":1,\"event\":{\"quickChatsListed\":[{\"id\":\"chat-1\",\"title\":\"Chat\",\"createdAt\":812912680.03}]}}", + .expected = 1_791_219_880, + }, + .{ + .frame = "{\"version\":2,\"kind\":\"event\",\"sequence\":2,\"event\":{\"quickChatsListed\":[{\"id\":\"chat-1\",\"title\":\"Chat\"}]}}", + .expected = null, + }, + .{ + .frame = "{\"version\":2,\"kind\":\"event\",\"sequence\":3,\"event\":{\"quickChatsListed\":[{\"id\":\"chat-1\",\"title\":\"Chat\",\"createdAt\":\"not-a-date\"}]}}", + .expected = null, + }, + }; + var model = Model.init(std.testing.allocator); + defer model.deinit(); + for (cases) |case| { + try std.testing.expectEqual(Wire.EventKind.quick_chats, try model.updateFromFrame(case.frame)); + try std.testing.expectEqual(case.expected, model.quick_chats.items[0].created_at); + } +} + +test "quick chat upsert refreshes creation timestamp and retains it on malformed updates" { + var model = Model.init(std.testing.allocator); + defer model.deinit(); + const first = + \\{"version":2,"kind":"event","sequence":1,"event":{"quickChatChanged":{"id":"chat-1","title":"Chat","createdAt":812912680.03}}} + ; + const renamed = + \\{"version":2,"kind":"event","sequence":2,"event":{"quickChatChanged":{"id":"chat-1","title":"Renamed","createdAt":812913000}}} + ; + const malformed = + \\{"version":2,"kind":"event","sequence":3,"event":{"quickChatChanged":{"id":"chat-1","title":"Renamed again","createdAt":"invalid"}}} + ; + try std.testing.expectEqual(Wire.EventKind.quick_chat_changed, try model.updateFromFrame(first)); + try std.testing.expectEqual(@as(?u64, 1_791_219_880), model.quick_chats.items[0].created_at); + try std.testing.expectEqual(Wire.EventKind.quick_chat_changed, try model.updateFromFrame(renamed)); + try std.testing.expectEqualStrings("Renamed", model.quick_chats.items[0].title); + try std.testing.expectEqual(@as(?u64, 1_791_220_200), model.quick_chats.items[0].created_at); + try std.testing.expectEqual(Wire.EventKind.quick_chat_changed, try model.updateFromFrame(malformed)); + try std.testing.expectEqualStrings("Renamed again", model.quick_chats.items[0].title); + try std.testing.expectEqual(@as(?u64, 1_791_220_200), model.quick_chats.items[0].created_at); +} + test "project identity derives remote and global from Codable paths" { const remote = Project{ .path = @constCast("ssh://build/graph"), .name = @constCast("Remote") }; const global = Project{ .path = @constCast("graphcode://global"), .name = @constCast("Graph") }; diff --git a/graphcode-windows/src/TerminalSurface.zig b/graphcode-windows/src/TerminalSurface.zig index 455465c3..08cbb1cb 100644 --- a/graphcode-windows/src/TerminalSurface.zig +++ b/graphcode-windows/src/TerminalSurface.zig @@ -3,6 +3,7 @@ const c = @import("Win32.zig").c; const WorkspaceLayout = @import("WorkspaceLayout.zig"); const Tokens = @import("DesignTokens.zig"); const LoopBarLayout = @import("LoopBarLayout.zig"); +const GdiplusAA = @import("GdiplusAA.zig"); const AppFont = @import("AppFont.zig"); const GdiGradient = @import("GdiGradient.zig"); const Dpi = @import("Dpi.zig"); @@ -166,10 +167,78 @@ pub const ChromeAction = enum { new_tab, split_right, split_down }; pub const TabAction = enum { select, close }; pub const LoopBarAction = LoopBarLayout.Action; +pub const QuickChatBarAction = enum { restart_session, show_graph }; + +const quick_chat_bar_accent: u32 = 0x00E98590; // COLORREF for macOS's #9085e9. + +pub const QuickChatBarLayout = struct { + bar: LoopBarLayout.Rect, + stripe: ?LoopBarLayout.Rect, + title: ?LoopBarLayout.Rect, + state: ?LoopBarLayout.Rect, + activity: ?LoopBarLayout.Rect, + divider: ?LoopBarLayout.Rect, + age: ?LoopBarLayout.Rect, + restart_session: LoopBarLayout.Rect, + show_graph: LoopBarLayout.Rect, + + pub fn actionAt(self: QuickChatBarLayout, x: i32, y: i32) ?QuickChatBarAction { + if (self.restart_session.contains(x, y)) return .restart_session; + if (self.show_graph.contains(x, y)) return .show_graph; + return null; + } +}; + pub fn loopBarLayout(left: i32, right: i32, resolved: bool, panel_toggle: bool) LoopBarLayout.Layout { return LoopBarLayout.compute(left, Tokens.header_height, right, resolved, panel_toggle); } +fn quickChatBarTextRect(left: i32, top: i32, right: i32, bottom: i32) ?LoopBarLayout.Rect { + if (right - left < LoopBarLayout.min_text_width) return null; + return .{ .left = left, .top = top, .right = right, .bottom = bottom }; +} + +pub fn quickChatBarLayout(left: i32, right: i32) QuickChatBarLayout { + const top = Tokens.header_height; + const show_graph = LoopBarLayout.Rect{ + .left = right - 92, + .top = top + 10, + .right = right - 14, + .bottom = top + 36, + }; + const restart_session = LoopBarLayout.Rect{ + .left = show_graph.left - 10 - 108, + .top = top + 10, + .right = show_graph.left - 10, + .bottom = top + 36, + }; + const text_limit = restart_session.left - 8; + const state = LoopBarLayout.Rect{ + .left = left + 127, + .top = top + 6, + .right = left + 179, + .bottom = top + 25, + }; + const divider_x = left + 460; + return .{ + .bar = .{ .left = left, .top = top, .right = right, .bottom = top + Tokens.loop_bar_height }, + .stripe = if (left + 18 <= text_limit) + .{ .left = left + 14, .top = top + 11, .right = left + 18, .bottom = top + 35 } + else + null, + .title = quickChatBarTextRect(left + 27, top + 5, @min(left + 120, text_limit), top + 24), + .state = if (state.right <= text_limit) state else null, + .activity = quickChatBarTextRect(left + 27, top + 24, @min(left + 447, text_limit), top + 42), + .divider = if (divider_x + 1 < text_limit) + .{ .left = divider_x, .top = top + 10, .right = divider_x + 1, .bottom = top + 36 } + else + null, + .age = quickChatBarTextRect(left + 474, top + 13, @min(left + 546, text_limit), top + 31), + .restart_session = restart_session, + .show_graph = show_graph, + }; +} + pub fn loopBarActionAt(left: i32, top: i32, right: i32, x: i32, y: i32, resolved: bool, panel_toggle: bool) ?LoopBarAction { return LoopBarLayout.compute(left, top, right, resolved, panel_toggle).actionAt(x, y); } @@ -564,14 +633,12 @@ pub const Workspace = struct { .input_queue = .{ .allocator = allocator_ }, .layout = try WorkspaceLayout.Layout.init( allocator_, - std.process.getEnvVarOwned(allocator_, "GRAPHCODE_WORKSPACE_PROJECT") - catch "global", + std.process.getEnvVarOwned(allocator_, "GRAPHCODE_WORKSPACE_PROJECT") catch "global", ), .layout_path = &.{}, .project_key = try allocator_.dupe( u8, - std.process.getEnvVarOwned(allocator_, "GRAPHCODE_WORKSPACE_PROJECT") - catch "global", + std.process.getEnvVarOwned(allocator_, "GRAPHCODE_WORKSPACE_PROJECT") catch "global", ), }; for (&workspace.surfaces) |*surface| { @@ -811,7 +878,7 @@ pub const Workspace = struct { defer self.allocator.free(configured); const suffix = WorkspaceLayout.projectSuffix(project); return std.fmt.allocPrint(self.allocator, "{s}.{s}.json", .{ - configured[0 .. if (std.mem.endsWith(u8, configured, ".json")) configured.len - 5 else configured.len], + configured[0..if (std.mem.endsWith(u8, configured, ".json")) configured.len - 5 else configured.len], suffix, }); } else |_| {} @@ -837,6 +904,7 @@ pub const Workspace = struct { pub fn scopeToLoop(self: *Workspace, loop: []const u8, layout_project: []const u8, launches_agent: bool) !void { if (loop.len == 0) return; if (std.mem.eql(u8, self.loop_id, loop) and std.mem.eql(u8, self.layout.project_key, layout_project)) { + if (launches_agent) try ensureAgentPane(&self.layout, loop); // Reopening the open loop picks up a saved pane an earlier restore could not attach. self.expediteShellRestores(); self.restorePersistedSurfaces(loop, 1); @@ -849,7 +917,7 @@ pub const Workspace = struct { errdefer self.allocator.free(new_loop); var new_layout = try self.loadLoopLayout(new_path, layout_project, loop); errdefer new_layout.deinit(); - if (launches_agent) try new_layout.ensureAgentFirst(loop); + if (launches_agent) try ensureAgentPane(&new_layout, loop); for (self.surfaces, 0..) |_, index| self.destroySurface(index); self.clearAllRecreateState(); @@ -865,6 +933,19 @@ pub const Workspace = struct { self.restorePersistedSurfaces(loop, 1); } + fn ensureAgentPane(layout: *WorkspaceLayout.Layout, loop: []const u8) !void { + try layout.ensureAgentFirst(loop); + for (layout.tabs.items) |*tab| { + for (tab.panes.items) |*pane| { + if (std.mem.eql(u8, pane.id, loop)) { + pane.launches_agent = true; + return; + } + } + } + return error.MissingAgentPane; + } + /// The saved layout of a loop, else one adopted from the legacy project-wide file, else /// an empty one. fn loadLoopLayout(self: *Workspace, path: []const u8, layout_project: []const u8, loop: []const u8) !WorkspaceLayout.Layout { @@ -2146,10 +2227,10 @@ pub const Workspace = struct { if (layout.stripe) |stripe| { fillRect(hdc, .{ .left = stripe.left, .top = stripe.top, .right = stripe.right, .bottom = stripe.bottom }, loopTypeAccent(loop_type)); } - if (layout.title) |bounds| drawUtf8Bounded(hdc, title, bounds, 13, 0x00F2F2F7); - if (layout.state) |bounds| drawUtf8Bounded(hdc, state, bounds, 10, stateAccent(state)); + if (layout.title) |bounds| drawUtf8Bounded(hdc, title, bounds, 13, 0x00F2F2F7, false); + if (layout.state) |bounds| drawUtf8Bounded(hdc, state, bounds, 10, stateAccent(state), false); const live_line = if (activity.len != 0) activity else project_name; - if (layout.activity) |bounds| drawUtf8Bounded(hdc, live_line, bounds, 10, 0x008E8E93); + if (layout.activity) |bounds| drawUtf8Bounded(hdc, live_line, bounds, 10, 0x008E8E93, false); var usage: [32]u8 = undefined; const usage_text = if (token_usage) |value| std.fmt.bufPrint(&usage, "{d} tokens", .{value}) catch "usage n/a" else "usage n/a"; var detail: [256]u8 = undefined; @@ -2159,7 +2240,7 @@ pub const Workspace = struct { metric_passes, usage_text, }) catch "workspace metadata unavailable"; - if (layout.detail) |bounds| drawUtf8Bounded(hdc, detail_text, bounds, 9, 0x008E8E93); + if (layout.detail) |bounds| drawUtf8Bounded(hdc, detail_text, bounds, 9, 0x008E8E93, false); if (layout.stop) |stop| { fillRect(hdc, .{ .left = stop.left, .top = stop.top, .right = stop.right, .bottom = stop.bottom }, 0x00303035); drawUtf8(hdc, "Stop loop", stop.left + 12, top + 17, 10, 0x00D8D8DC); @@ -2171,6 +2252,59 @@ pub const Workspace = struct { _ = allocator; } + pub fn paintQuickChatBar( + hdc: c.HDC, + left: i32, + right: i32, + title: []const u8, + created_at: ?u64, + ) void { + const layout = quickChatBarLayout(left, right); + const top = Tokens.header_height; + GdiGradient.fillVertical( + hdc, + .{ .left = layout.bar.left, .top = layout.bar.top, .right = layout.bar.right, .bottom = layout.bar.bottom }, + Tokens.loop_bar_top, + Tokens.loop_bar_bottom, + ); + if (layout.stripe) |stripe| { + drawQuickChatRoundedRect( + hdc, + .{ .left = stripe.left, .top = stripe.top, .right = stripe.right, .bottom = stripe.bottom }, + 2, + quick_chat_bar_accent, + quick_chat_bar_accent, + ); + } + if (layout.title) |bounds| drawUtf8Bounded(hdc, title, bounds, 13, 0x00F2F2F7, true); + if (layout.state) |bounds| { + drawQuickChatRoundedRect(hdc, bounds, 5, 0x00353030, 0x00353030); + drawQuickChatStateDot(hdc, bounds.left + 9, @divTrunc(bounds.top + bounds.bottom, 2), 0x007A7878); + drawUtf8Bounded( + hdc, + "IDLE", + .{ .left = bounds.left + 16, .top = bounds.top, .right = bounds.right - 7, .bottom = bounds.bottom }, + 9, + 0x00808080, + true, + ); + } + if (layout.activity) |bounds| { + drawUtf8Bounded(hdc, "Not piloted", bounds, 10, 0x008E8E93, false); + } + if (layout.divider) |divider| { + fillRect(hdc, .{ .left = divider.left, .top = divider.top, .right = divider.right, .bottom = divider.bottom }, 0x002B2B30); + } + if (layout.age) |bounds| { + var age_buffer: [32]u8 = undefined; + drawUtf8Bounded(hdc, quickChatAgeLabel(&age_buffer, created_at), bounds, 10, 0x008E8E93, false); + } + drawQuickChatRoundedRect(hdc, layout.restart_session, 6, 0x00352F2F, 0x00403B3B); + drawUtf8Centered(hdc, "Restart session", layout.restart_session, 11, 0x00CCCCCC, true); + drawUtf8(hdc, "Show in graph", layout.show_graph.left + 4, top + 17, 10, 0x00737373); + fillRect(hdc, .{ .left = left, .top = top + Tokens.loop_bar_height - 1, .right = right, .bottom = top + Tokens.loop_bar_height }, Tokens.tab_bar_shadow_line); + } + pub fn paintWorkspaceToolbar( hdc: c.HDC, allocator: std.mem.Allocator, @@ -2249,7 +2383,6 @@ pub const Workspace = struct { if (slot.surface) |surface| { _ = set_focus(surface, if (index == other_index) 1 else 0); } - } self.persistFocusedSurface(index); } @@ -5187,7 +5320,7 @@ test "every function key F1 to F12 reaches a focused terminal exactly once throu .target_enabled = true, }; const expected = [_][]const u8{ - "\x1bOP", "\x1bOQ", "\x1bOR", "\x1bOS", + "\x1bOP", "\x1bOQ", "\x1bOR", "\x1bOS", "\x1b[15~", "\x1b[17~", "\x1b[18~", "\x1b[19~", "\x1b[20~", "\x1b[21~", "\x1b[23~", "\x1b[24~", }; @@ -7111,10 +7244,14 @@ fn fillRect(hdc: c.HDC, bounds: c.RECT, color: u32) void { } fn drawUtf8(hdc: c.HDC, text: []const u8, x: i32, y: i32, size: i32, color: u32) void { + drawUtf8WithWeight(hdc, text, x, y, size, color, false); +} + +fn drawUtf8WithWeight(hdc: c.HDC, text: []const u8, x: i32, y: i32, size: i32, color: u32, bold: bool) void { const wide = std.unicode.utf8ToUtf16LeAlloc(std.heap.page_allocator, text) catch return; defer std.heap.page_allocator.free(wide); if (wide.len == 0) return; - const old_font = AppFont.select(hdc, size, false); + const old_font = AppFont.select(hdc, size, bold); _ = c.SetTextColor(hdc, color); _ = c.SetBkMode(hdc, c.TRANSPARENT); var bounds = c.RECT{ .left = x, .top = y, .right = x + 220, .bottom = y + size + 8 }; @@ -7124,11 +7261,11 @@ fn drawUtf8(hdc: c.HDC, text: []const u8, x: i32, y: i32, size: i32, color: u32) // Clipped to `bounds`; a run squeezed narrower than its natural width ends in an // ellipsis instead of being cut mid-glyph. -fn drawUtf8Bounded(hdc: c.HDC, text: []const u8, bounds: LoopBarLayout.Rect, size: i32, color: u32) void { +fn drawUtf8Bounded(hdc: c.HDC, text: []const u8, bounds: LoopBarLayout.Rect, size: i32, color: u32, bold: bool) void { const wide = std.unicode.utf8ToUtf16LeAlloc(std.heap.page_allocator, text) catch return; defer std.heap.page_allocator.free(wide); if (wide.len == 0) return; - const old_font = AppFont.select(hdc, size, false); + const old_font = AppFont.select(hdc, size, bold); _ = c.SetTextColor(hdc, color); _ = c.SetBkMode(hdc, c.TRANSPARENT); var rect = c.RECT{ .left = bounds.left, .top = bounds.top, .right = bounds.right, .bottom = bounds.bottom }; @@ -7138,6 +7275,66 @@ fn drawUtf8Bounded(hdc: c.HDC, text: []const u8, bounds: LoopBarLayout.Rect, siz _ = c.SelectObject(hdc, old_font); } +fn drawUtf8Centered(hdc: c.HDC, text: []const u8, bounds: LoopBarLayout.Rect, size: i32, color: u32, bold: bool) void { + const wide = std.unicode.utf8ToUtf16LeAlloc(std.heap.page_allocator, text) catch return; + defer std.heap.page_allocator.free(wide); + if (wide.len == 0) return; + const old_font = AppFont.select(hdc, size, bold); + _ = c.SetTextColor(hdc, color); + _ = c.SetBkMode(hdc, c.TRANSPARENT); + var rect = c.RECT{ .left = bounds.left, .top = bounds.top, .right = bounds.right, .bottom = bounds.bottom }; + _ = c.DrawTextW(hdc, wide.ptr, @intCast(wide.len), &rect, c.DT_CENTER | c.DT_SINGLELINE | c.DT_VCENTER); + _ = c.SelectObject(hdc, old_font); +} + +fn drawQuickChatRoundedRect(hdc: c.HDC, bounds: LoopBarLayout.Rect, radius: i32, fill: u32, border: u32) void { + const rect = c.RECT{ .left = bounds.left, .top = bounds.top, .right = bounds.right, .bottom = bounds.bottom }; + if (!GdiplusAA.drawRoundedRect(hdc, rect, radius, fill, border, 1)) { + fillRect(hdc, rect, fill); + } +} + +fn drawQuickChatStateDot(hdc: c.HDC, x: i32, y: i32, color: u32) void { + const brush = c.CreateSolidBrush(color); + if (brush == null) return; + const pen = c.CreatePen(c.PS_SOLID, 1, color); + if (pen == null) { + _ = c.DeleteObject(brush); + return; + } + const old_brush = c.SelectObject(hdc, brush); + const old_pen = c.SelectObject(hdc, pen); + _ = c.Ellipse(hdc, x - 3, y - 3, x + 4, y + 4); + _ = c.SelectObject(hdc, old_pen); + _ = c.SelectObject(hdc, old_brush); + _ = c.DeleteObject(pen); + _ = c.DeleteObject(brush); +} + +fn quickChatAgeLabel(buffer: *[32]u8, created_at: ?u64) []const u8 { + const created = created_at orelse return "age n/a"; + const now = std.time.timestamp(); + const created_seconds: i64 = @intCast(created); + const total: u64 = if (now > created_seconds) @intCast(now - created_seconds) else 0; + return quickChatDurationLabel(buffer, total); +} + +fn quickChatDurationLabel(buffer: *[32]u8, total: u64) []const u8 { + const days = total / 86_400; + const hours = total / 3_600 % 24; + const minutes = total / 60 % 60; + if (days > 0) { + if (hours > 0) return std.fmt.bufPrint(buffer, "{d}d {d}h", .{ days, hours }) catch "age n/a"; + return std.fmt.bufPrint(buffer, "{d}d", .{days}) catch "age n/a"; + } + if (hours > 0) { + if (minutes > 0) return std.fmt.bufPrint(buffer, "{d}h {d}m", .{ hours, minutes }) catch "age n/a"; + return std.fmt.bufPrint(buffer, "{d}h", .{hours}) catch "age n/a"; + } + if (minutes > 0) return std.fmt.bufPrint(buffer, "{d}m", .{minutes}) catch "age n/a"; + return std.fmt.bufPrint(buffer, "{d}s", .{total}) catch "age n/a"; +} + fn tabLabel(tab: WorkspaceLayout.Tab, index: usize) []const u8 { if (tab.panes.items.len > 1) return "split"; if (index == 0) return "agent"; @@ -7589,6 +7786,61 @@ test "loop bar actions expose stop only for active loops" { try std.testing.expectEqual(LoopBarAction.show_graph, loopBarActionAt(220, 34, 1200, 1050, 50, false, true).?); } +test "quick chat bar keeps its actions separate from the chat details" { + const layout = quickChatBarLayout(220, 1200); + const title = layout.title orelse return error.MissingTitle; + const state = layout.state orelse return error.MissingState; + const activity = layout.activity orelse return error.MissingActivity; + const divider = layout.divider orelse return error.MissingDivider; + const age = layout.age orelse return error.MissingAge; + + try std.testing.expect(title.right <= state.left); + try std.testing.expect(activity.right <= divider.left); + try std.testing.expect(divider.right <= age.left); + try std.testing.expect(age.right <= layout.restart_session.left); + try std.testing.expect(layout.restart_session.right <= layout.show_graph.left); + try std.testing.expectEqual( + QuickChatBarAction.restart_session, + layout.actionAt( + @divTrunc(layout.restart_session.left + layout.restart_session.right, 2), + @divTrunc(layout.restart_session.top + layout.restart_session.bottom, 2), + ).?, + ); + try std.testing.expectEqual( + QuickChatBarAction.show_graph, + layout.actionAt( + @divTrunc(layout.show_graph.left + layout.show_graph.right, 2), + @divTrunc(layout.show_graph.top + layout.show_graph.bottom, 2), + ).?, + ); + try std.testing.expect(layout.actionAt(layout.bar.left + 8, layout.bar.top + 8) == null); + + const compact = quickChatBarLayout(220, 960); + try std.testing.expect(compact.bar.left <= compact.restart_session.left); + try std.testing.expect(compact.restart_session.right <= compact.show_graph.left); + try std.testing.expect(compact.show_graph.right <= compact.bar.right); + try std.testing.expect(!compact.restart_session.intersects(compact.show_graph)); + try std.testing.expect(!compact.age.?.intersects(compact.restart_session)); + + var duration: [32]u8 = undefined; + try std.testing.expectEqualStrings("3d", quickChatDurationLabel(&duration, 3 * 86_400)); + try std.testing.expectEqualStrings("3d 2h", quickChatDurationLabel(&duration, 3 * 86_400 + 2 * 3_600)); + try std.testing.expectEqualStrings("2h", quickChatDurationLabel(&duration, 2 * 3_600)); + try std.testing.expectEqualStrings("2h 5m", quickChatDurationLabel(&duration, 2 * 3_600 + 5 * 60)); + try std.testing.expectEqualStrings("5m", quickChatDurationLabel(&duration, 5 * 60)); + try std.testing.expectEqualStrings("42s", quickChatDurationLabel(&duration, 42)); +} + +test "ensureAgentPane promotes an existing shell pane to an agent pane" { + var layout = try WorkspaceLayout.Layout.init(std.testing.allocator, "project"); + defer layout.deinit(); + try layout.addTab("chat-1", false); + + try Workspace.ensureAgentPane(&layout, "chat-1"); + + try std.testing.expect(layout.tabs.items[0].panes.items[0].launches_agent); +} + const AttachOutputProbe = struct { input: []const u8, output: []u8, From 5d3862f27dee65dd1e76e985a806b3bb7002445b Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Wed, 14 Oct 2026 04:13:48 -0700 Subject: [PATCH 07/11] Restore Quick Chat accessibility regression coverage Use the current-main loop detail bounds and load the Quick Chat row through the protocol fixture in the workspace regression. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3072b347-66bd-4d89-8c59-71a1f6f4a33d Signed-off-by: Colin Neilens --- graphcode-windows/src/App.zig | 89 +++++++++++++++++++++++++++++++---- 1 file changed, 80 insertions(+), 9 deletions(-) diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 1cb5b5c5..6037c167 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -7936,11 +7936,13 @@ pub const App = struct { GraphCanvas.loopDetailCollapseBounds(client.right); self.appendAccessibilityElement(&elements, &owned_identities, "workspace-toggle-panel", "control", if (self.workspace_controls.panel_visible) "Collapse loop panel" else "Expand loop panel", 4, .{ .logical = panel_toggle }, false, true) catch return; if (self.workspace_controls.panel_visible and selected_index < graph.nodes.items.len) { - const detail_left = client.right - Tokens.loop_detail_width; const selected_node = graph.nodes.items[selected_index]; - self.appendAccessibilityElement(&elements, &owned_identities, "workspace-detail-sparkline", selected_node.id, "Metric sparkline", 4, .{ .logical = .{ .left = detail_left + 18, .top = client.bottom - 102, .right = client.right - 18, .bottom = client.bottom - 70 } }, false, false) catch return; + if (selected_node.metric_sample_count >= 2) { + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-detail-sparkline", selected_node.id, "Metric sparkline", 4, .{ .logical = GraphCanvas.loopDetailSparklineBounds(client.right, client.bottom) }, false, false) catch return; + } + const detail_footer = GraphCanvas.loopDetailFooterBounds(client.right, client.bottom); if (selected_node.created_at != null) { - self.appendAccessibilityElement(&elements, &owned_identities, "workspace-detail-start", selected_node.id, "Start time", 4, .{ .logical = .{ .left = detail_left + 18, .top = client.bottom - 64, .right = client.right - 18, .bottom = client.bottom - 44 } }, false, false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-detail-start", selected_node.id, "Start time", 4, .{ .logical = detail_footer }, false, false) catch return; } if (selected_node.token_usage) |tokens| { const usage_name = std.fmt.allocPrint(self.allocator, "{d} tokens", .{tokens}) catch return; @@ -7948,7 +7950,7 @@ pub const App = struct { self.allocator.free(usage_name); return; }; - self.appendAccessibilityElement(&elements, &owned_identities, "workspace-detail-usage", selected_node.id, usage_name, 4, .{ .logical = .{ .left = detail_left + 18, .top = client.bottom - 44, .right = client.right - 18, .bottom = client.bottom - 24 } }, false, false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-detail-usage", selected_node.id, usage_name, 4, .{ .logical = detail_footer }, false, false) catch return; } } } else if (self.selected_quick_chat) |chat_index| { @@ -17934,11 +17936,10 @@ test "opening quick chat resets the workspace chrome to the chat and hides the l \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"project":{"path":"C:\\loop-fixture","name":"Loop fixture"},"nodes":[{"id":"loop-a","title":"Loop A","loopType":"turnBased","state":{"idle":{}}}],"edges":[]}}} ); try std.testing.expect(app.model.setSelectedIndex(0)); - try app.model.quick_chats.append(.{ - .id = try allocator.dupe(u8, "chat-1"), - .title = try allocator.dupe(u8, "Chat 1"), - .backend = try allocator.dupe(u8, "claudeCode"), - }); + const listed = + \\{"version":2,"kind":"event","sequence":2,"event":{"quickChatsListed":[{"id":"chat-1","title":"Chat 1","backend":"claudeCode","createdAt":0,"activity":null}]}} + ; + app.onFrameWithAccessibilityPublish(listed, LoopBarProbe.publish); const layout_path = try std.fs.path.join(allocator, &.{ home, "layout.json" }); var workspace: TerminalWorkspace.Workspace = .{ .parent = null, @@ -18010,6 +18011,76 @@ test "quick chat titles include the creation date" { try std.testing.expectEqualStrings("Chat — Jan 1", try App.quickChatTitleFromSystemTime(&title_buffer, local_time)); } +test "opening a quick chat after a loop workspace selects the chat bar through its UIA item" { + const allocator = std.testing.allocator; + const user_profile_key = std.unicode.utf8ToUtf16LeStringLiteral("USERPROFILE"); + const uia_gate_key = std.unicode.utf8ToUtf16LeStringLiteral("GRAPHCODE_UIA_GATE"); + var original_environment = try std.process.getEnvMap(allocator); + defer original_environment.deinit(); + defer setWorkspaceTestEnvironment(user_profile_key, original_environment.get("USERPROFILE")) catch @panic("USERPROFILE restore failed"); + defer setWorkspaceTestEnvironment(uia_gate_key, original_environment.get("GRAPHCODE_UIA_GATE")) catch @panic("GRAPHCODE_UIA_GATE restore failed"); + var temporary = std.testing.tmpDir(.{}); + defer temporary.cleanup(); + const home = try temporary.dir.realpathAlloc(allocator, "."); + defer allocator.free(home); + try setWorkspaceTestEnvironment(user_profile_key, home); + try setWorkspaceTestEnvironment(uia_gate_key, null); + + var app = try overviewTestApp(Dpi.base_dpi); + defer deinitOverviewTestApp(&app); + defer app.allocator.free(app.pending_project_path); + _ = try app.model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"project":{"path":"C:\\loop-fixture","name":"Loop fixture"},"nodes":[{"id":"loop-a","title":"Loop A","loopType":"turnBased","state":{"idle":{}}}],"edges":[]}}} + ); + try std.testing.expect(app.model.setSelectedIndex(0)); + const listed = + \\{"version":2,"kind":"event","sequence":2,"event":{"quickChatsListed":[{"id":"chat-1","title":"Chat 1","backend":"claudeCode","createdAt":0,"activity":null}]}} + ; + app.onFrameWithAccessibilityPublish(listed, LoopBarProbe.publish); + const layout_path = try std.fs.path.join(allocator, &.{ home, "layout.json" }); + var workspace: TerminalWorkspace.Workspace = .{ + .parent = null, + .allocator = allocator, + .zmx_path = &.{}, + .cwd = &.{}, + .input_queue = .{ .allocator = allocator }, + .layout = try @import("WorkspaceLayout.zig").Layout.init(allocator, home), + .layout_path = layout_path, + .project_key = try allocator.dupe(u8, home), + .project_path = try allocator.dupe(u8, home), + .legacy_directory = try allocator.dupe(u8, home), + }; + defer workspace.deinit(); + try workspace.layout.addTab("loop-a", true); + try workspace.layout.addTab("shell", false); + app.workspace = &workspace; + app.workspace_controls = .{ .rail_visible = true, .panel_visible = true, .activity_enabled = false }; + app.surface = .workspace; + app.workspace_is_quick_chat = false; + + LoopBarProbe.publish(&app); + try std.testing.expect(LoopBarProbe.has_bar); + try std.testing.expectEqualStrings("loop-a", LoopBarProbe.barLoop()); + + const outbound_before = app.client.outbound_count; + try std.testing.expect(app.applyUiaDynamicInvoke( + Accessibility.worktreeIdentityPayload("quick-chat-row:chat-1"), + )); + try std.testing.expectEqual(outbound_before + 1, app.client.outbound_count); + const opened = + \\{"version":2,"kind":"event","sequence":3,"event":{"quickChatChanged":{"id":"chat-1","title":"Chat 1","backend":"claudeCode","createdAt":0,"activity":null}}} + ; + app.onFrameWithAccessibilityPublish(opened, LoopBarProbe.publish); + LoopBarProbe.publish(&app); + + try std.testing.expect(app.workspace_is_quick_chat); + try std.testing.expectEqual(@as(?usize, 0), app.selected_quick_chat); + try std.testing.expect(LoopBarProbe.has_bar); + try std.testing.expectEqualStrings("chat-1", LoopBarProbe.barLoop()); + try std.testing.expectEqual(GraphCanvas.Surface.workspace, app.surface); + try std.testing.expect(!app.workspace_controls.panel_visible); +} + test "creating a quick chat uses the default backend and a date-stamped title" { var app = try overviewTestApp(Dpi.base_dpi); defer deinitOverviewTestApp(&app); From d198f71fa82a26c9f4a7c84d00daffe76902ec8d Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Wed, 14 Oct 2026 04:39:17 -0700 Subject: [PATCH 08/11] Expose Quick Chat restart UIA identity Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3072b347-66bd-4d89-8c59-71a1f6f4a33d Signed-off-by: Colin Neilens --- graphcode-windows/src/AccessibilityProvider.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/graphcode-windows/src/AccessibilityProvider.cpp b/graphcode-windows/src/AccessibilityProvider.cpp index b5bbae9c..7dface82 100644 --- a/graphcode-windows/src/AccessibilityProvider.cpp +++ b/graphcode-windows/src/AccessibilityProvider.cpp @@ -1244,6 +1244,7 @@ class Node final : public IRawElementProviderSimple, row.identity.rfind("workspace-toolbar:", 0) == 0 ? L"workspace-toolbar-" : row.identity.rfind("workspace-loop-bar:", 0) == 0 ? L"workspace-loop-bar-" : row.identity.rfind("workspace-show-graph:", 0) == 0 ? L"workspace-show-graph-" : + row.identity.rfind("workspace-restart-session:", 0) == 0 ? L"workspace-restart-session-" : row.identity.rfind("workspace-stop:", 0) == 0 ? L"workspace-stop-" : row.identity.rfind("workspace-toggle-panel:", 0) == 0 ? L"workspace-toggle-panel-" : row.identity.rfind("workspace-detail-sparkline:", 0) == 0 ? L"workspace-detail-sparkline-" : From 227eaef5444143485b6b7d98d5f8d359257fdf63 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Wed, 14 Oct 2026 05:35:09 -0700 Subject: [PATCH 09/11] Fix Quick Chat workspace toggle state Limit Quick Chat panel suppression to the active chat workspace so returning to Graph can reopen the loop panel. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3072b347-66bd-4d89-8c59-71a1f6f4a33d Signed-off-by: Colin Neilens --- graphcode-windows/src/App.zig | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 6037c167..b74bd204 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -7014,7 +7014,7 @@ pub const App = struct { } fn toggleWorkspacePanelState(self: *App) void { - if (self.workspace_is_quick_chat) { + if (self.surface == .workspace and self.workspace_is_quick_chat) { self.workspace_controls.panel_visible = false; return; } @@ -7028,7 +7028,7 @@ pub const App = struct { } fn toggleWorkspaceDetailPanelState(self: *App) void { - if (self.workspace_is_quick_chat) { + if (self.surface == .workspace and self.workspace_is_quick_chat) { self.workspace_controls.panel_visible = false; return; } @@ -17994,6 +17994,17 @@ test "opening quick chat resets the workspace chrome to the chat and hides the l try std.testing.expectEqualDeep([4]i32{ 220, 34, 1200, 900 }, [4]i32{ canvas.left, canvas.top, canvas.right, canvas.bottom }); const workspace_element = accessibility.workspace orelse return error.MissingQuickChatWorkspace; try std.testing.expectEqualDeep([4]i32{ 220, 34, 1200, 900 }, [4]i32{ workspace_element.left, workspace_element.top, workspace_element.right, workspace_element.bottom }); + + const ShowGraphCallbacks = struct { + fn finish(_: *App) void {} + fn publish(_: *App) void {} + }; + app.showInGraph(ShowGraphCallbacks.finish, ShowGraphCallbacks.publish); + try std.testing.expectEqual(GraphCanvas.Surface.project, app.surface); + app.toggleWorkspacePanelState(); + try std.testing.expectEqual(true, app.workspace_controls.panel_visible); + try std.testing.expectEqual(GraphCanvas.Surface.workspace, app.surface); + try std.testing.expect(!app.workspace_is_quick_chat); } test "quick chat titles include the creation date" { From 791c5a4276bc2ba2a07195b0abf130fff856fcd7 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Wed, 14 Oct 2026 05:46:15 -0700 Subject: [PATCH 10/11] Publish Quick Chat workspace controls without a graph Publish the chat bar and its actions independently of a project graph, with a regression covering the graph-less workspace path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3072b347-66bd-4d89-8c59-71a1f6f4a33d Signed-off-by: Colin Neilens --- graphcode-windows/src/App.zig | 54 +++++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index b74bd204..6e9df38b 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -7953,14 +7953,6 @@ pub const App = struct { self.appendAccessibilityElement(&elements, &owned_identities, "workspace-detail-usage", selected_node.id, usage_name, 4, .{ .logical = detail_footer }, false, false) catch return; } } - } else if (self.selected_quick_chat) |chat_index| { - if (chat_index < self.model.quick_chats.items.len) { - const chat = self.model.quick_chats.items[chat_index]; - const loop_bar = workspaceQuickChatBar(self.workspace_controls, client.right); - self.appendAccessibilityElement(&elements, &owned_identities, "workspace-loop-bar", chat.id, chat.title, 4, .{ .logical = loopBarRect(loop_bar.bar) }, false, false) catch return; - self.appendAccessibilityElement(&elements, &owned_identities, "workspace-show-graph", "show-graph", "Show in Graph", 4, .{ .logical = loopBarRect(loop_bar.show_graph) }, false, false) catch return; - self.appendAccessibilityElement(&elements, &owned_identities, "workspace-restart-session", "control", "Restart session", 4, .{ .logical = loopBarRect(loop_bar.restart_session) }, false, false) catch return; - } } for (workspace.layout.tabs.items, 0..) |tab, tab_index| { const tab_key = std.fmt.allocPrint(self.allocator, "{d}", .{tab_index}) catch return; @@ -8000,6 +7992,15 @@ pub const App = struct { self.appendAccessibilityElement(&elements, &owned_identities, "quick-chat-card", chat.id, chat.title, 4, .{ .logical = GraphCanvas.quickChatCardBounds(index, canvas_rect, &self.canvas) }, false, false) catch return; }, } + if (self.surface == .workspace and self.workspace_is_quick_chat) if (self.selected_quick_chat) |chat_index| { + if (chat_index < self.model.quick_chats.items.len) { + const chat = self.model.quick_chats.items[chat_index]; + const loop_bar = workspaceQuickChatBar(self.workspace_controls, client.right); + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-loop-bar", chat.id, chat.title, 4, .{ .logical = loopBarRect(loop_bar.bar) }, false, false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-show-graph", "show-graph", "Show in Graph", 4, .{ .logical = loopBarRect(loop_bar.show_graph) }, false, false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-restart-session", "control", "Restart session", 4, .{ .logical = loopBarRect(loop_bar.restart_session) }, false, false) catch return; + } + }; const canvas_alert = if (self.ingress_error.len != 0) self.ingress_error else if (self.connectionFailureVisible()) @@ -18007,6 +18008,43 @@ test "opening quick chat resets the workspace chrome to the chat and hides the l try std.testing.expect(!app.workspace_is_quick_chat); } +test "quick chat workspace publishes its bar controls without a project graph" { + var app = try overviewTestApp(Dpi.base_dpi); + defer deinitOverviewTestApp(&app); + defer app.allocator.free(app.pending_project_path); + const listed = + \\{"version":2,"kind":"event","sequence":1,"event":{"quickChatsListed":[{"id":"chat-1","title":"Chat 1","backend":"claudeCode","createdAt":0,"activity":null}]}} + ; + _ = try app.model.updateFromFrame(listed); + app.selected_quick_chat = 0; + app.workspace_is_quick_chat = true; + app.surface = .workspace; + + const Probe = struct { + workspace: bool = false, + bar: bool = false, + show_graph: bool = false, + restart_session: bool = false, + + fn syncCanvasBounds(_: *@This(), _: c.RECT) void {} + + fn syncElements(self: *@This(), _: []const u8, elements: []const Accessibility.DynamicElement, _: WorktreeStatus.Policy, _: Accessibility.WorktreeCapabilities) void { + for (elements) |element| { + if (std.mem.eql(u8, element.identity, "quick-chat-workspace:chat-1")) self.workspace = true; + if (std.mem.eql(u8, element.identity, "workspace-loop-bar:chat-1")) self.bar = true; + if (std.mem.eql(u8, element.identity, "workspace-show-graph:show-graph")) self.show_graph = true; + if (std.mem.eql(u8, element.identity, "workspace-restart-session:control")) self.restart_session = true; + } + } + }; + var probe: Probe = .{}; + app.syncAccessibilityTo(&probe, .{ .left = 0, .top = 0, .right = 1200, .bottom = 900 }); + try std.testing.expectEqual(true, probe.workspace); + try std.testing.expectEqual(true, probe.bar); + try std.testing.expectEqual(true, probe.show_graph); + try std.testing.expectEqual(true, probe.restart_session); +} + test "quick chat titles include the creation date" { var title_buffer: [32]u8 = undefined; const local_time = c.SYSTEMTIME{ From 29b4e3ebdedc986ad9cce9101785e32ce2465aa7 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Wed, 14 Oct 2026 06:56:31 -0700 Subject: [PATCH 11/11] Reset Quick Chat workspace after dismissal Clear the chat-scoped workspace when returning to the graph and prevent toggles from restoring it until the user selects a project or loop. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3072b347-66bd-4d89-8c59-71a1f6f4a33d Signed-off-by: Colin Neilens --- graphcode-windows/src/App.zig | 126 +++++++++++++++++++++- graphcode-windows/src/TerminalSurface.zig | 25 +++++ 2 files changed, 146 insertions(+), 5 deletions(-) diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 6e9df38b..d5b81915 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -1415,6 +1415,7 @@ pub const App = struct { dpi: u32 = Dpi.base_dpi, surface: GraphCanvas.Surface = .project, workspace_is_quick_chat: bool = false, + quick_chat_workspace_dismissed: bool = false, header_focus: ?GraphCanvas.HeaderAction = null, header_return_focus: c.HWND = null, header_focus_transition: bool = false, @@ -2573,6 +2574,7 @@ pub const App = struct { fn openProjectWithLayout(self: *App, path: []const u8, comptime layout: fn (*App) void) void { if (path.len == 0) return; + self.quick_chat_workspace_dismissed = false; self.surface = .project; self.workspace_controls.panel_visible = false; layout(self); @@ -2666,6 +2668,7 @@ pub const App = struct { } pub fn openGlobalOverview(self: *App) void { + self.quick_chat_workspace_dismissed = false; self.surface = .overview; self.workspace_controls.panel_visible = false; self.layoutWorkspace(); @@ -2680,6 +2683,7 @@ pub const App = struct { if (action == .inspect_worktrees) { if (self.selectProject(self.model.graphs.items[graph_index].project.path)) self.inspectWorktrees(); } else if (self.selectProject(self.model.graphs.items[graph_index].project.path)) { + self.quick_chat_workspace_dismissed = false; self.surface = .project; self.workspace_controls.panel_visible = false; self.layoutWorkspace(); @@ -2750,6 +2754,7 @@ pub const App = struct { true; const selected = self.model.selectProject(path); if (selected) { + self.quick_chat_workspace_dismissed = false; self.client.setSubgraphAddress(null); if (changed) { const cancelled_inspection = self.worktree_inspection_thread != null or self.worktree_inspection_pending != null; @@ -2939,6 +2944,7 @@ pub const App = struct { if (index >= graph.nodes.items.len) return false; if (!self.replaceSelectionID(&self.selected_node_id, graph.nodes.items[index].id)) return false; if (!self.model.setSelectedIndex(index)) return false; + self.quick_chat_workspace_dismissed = false; self.selection_initialized = true; self.clearEdgeSelection(); return true; @@ -3078,6 +3084,7 @@ pub const App = struct { for (self.model.quick_chats.items, 0..) |chat, index| { if (!std.mem.eql(u8, chat.id, id)) continue; self.selected_quick_chat = index; + self.quick_chat_workspace_dismissed = false; self.workspace_is_quick_chat = true; self.surface = .workspace; self.workspace_controls.panel_visible = false; @@ -4323,6 +4330,7 @@ pub const App = struct { return false; } self.workspace_is_quick_chat = false; + self.quick_chat_workspace_dismissed = false; self.surface = .workspace; self.workspace_controls.panel_visible = true; self.layoutWorkspace(); @@ -6871,6 +6879,7 @@ pub const App = struct { } fn showInGraph(self: *App, comptime finish_native: fn (*App) void, comptime publish: fn (*App) void) void { + self.dismissQuickChatWorkspace(); self.surface = .project; self.workspace_controls.panel_visible = false; self.workspace_controls.apply(.show_graph); @@ -6878,6 +6887,25 @@ pub const App = struct { publish(self); } + fn dismissQuickChatWorkspace(self: *App) void { + if (!self.workspace_is_quick_chat) return; + self.quick_chat_workspace_dismissed = true; + if (self.workspace) |workspace| { + if (self.model.currentGraph()) |graph| { + const project = graph.project.path; + _ = workspace.rebindProject(project) catch { + self.setStatus("Unable to reset Quick Chat workspace"); + return; + }; + } + workspace.unbindLoop() catch { + self.setStatus("Unable to reset Quick Chat workspace"); + return; + }; + } + self.workspace_is_quick_chat = false; + } + fn finishShowGraphNative(self: *App) void { self.layoutWorkspace(); self.layoutEmptyStateControls(); @@ -7014,7 +7042,7 @@ pub const App = struct { } fn toggleWorkspacePanelState(self: *App) void { - if (self.surface == .workspace and self.workspace_is_quick_chat) { + if (self.workspace_is_quick_chat or self.quick_chat_workspace_dismissed) { self.workspace_controls.panel_visible = false; return; } @@ -7028,7 +7056,7 @@ pub const App = struct { } fn toggleWorkspaceDetailPanelState(self: *App) void { - if (self.surface == .workspace and self.workspace_is_quick_chat) { + if (self.workspace_is_quick_chat or self.quick_chat_workspace_dismissed) { self.workspace_controls.panel_visible = false; return; } @@ -8410,6 +8438,7 @@ pub const App = struct { for (self.model.quick_chats.items, 0..) |chat, index| { if (!std.mem.eql(u8, chat.id, id)) continue; self.selected_quick_chat = index; + self.quick_chat_workspace_dismissed = false; self.workspace_is_quick_chat = true; self.surface = .workspace; self.workspace_controls.panel_visible = false; @@ -17902,17 +17931,22 @@ test "workspace surface: canvas New Loop and Open Folder buttons follow the proj try expectNewLoopAtTopRight(&app); } -test "opening quick chat resets the workspace chrome to the chat and hides the loop panel" { +test "quick chat workspace stays hidden after Show in Graph and workspace toggles" { const allocator = std.testing.allocator; const user_profile_key = std.unicode.utf8ToUtf16LeStringLiteral("USERPROFILE"); + const workspace_layout_key = std.unicode.utf8ToUtf16LeStringLiteral("GRAPHCODE_WORKSPACE_LAYOUT"); var original_environment = try std.process.getEnvMap(allocator); defer original_environment.deinit(); defer setWorkspaceTestEnvironment(user_profile_key, original_environment.get("USERPROFILE")) catch @panic("USERPROFILE restore failed"); + defer setWorkspaceTestEnvironment(workspace_layout_key, original_environment.get("GRAPHCODE_WORKSPACE_LAYOUT")) catch @panic("workspace layout restore failed"); var temporary = std.testing.tmpDir(.{}); defer temporary.cleanup(); const home = try temporary.dir.realpathAlloc(allocator, "."); defer allocator.free(home); try setWorkspaceTestEnvironment(user_profile_key, home); + const workspace_layout = try std.fs.path.join(allocator, &.{ home, "workspace-layout.json" }); + defer allocator.free(workspace_layout); + try setWorkspaceTestEnvironment(workspace_layout_key, workspace_layout); const Probe = struct { canvas: ?c.RECT = null, @@ -18002,10 +18036,72 @@ test "opening quick chat resets the workspace chrome to the chat and hides the l }; app.showInGraph(ShowGraphCallbacks.finish, ShowGraphCallbacks.publish); try std.testing.expectEqual(GraphCanvas.Surface.project, app.surface); + try std.testing.expect(!app.workspace_controls.panel_visible); + try std.testing.expect(!app.workspace_is_quick_chat); + try std.testing.expect(app.quick_chat_workspace_dismissed); + try std.testing.expectEqualStrings("C:\\loop-fixture", workspace.projectPath()); + try std.testing.expectEqual(@as(usize, 0), workspace.loop_id.len); + for (workspace.layout.tabs.items) |tab| { + for (tab.panes.items) |pane| { + try std.testing.expect(!std.mem.eql(u8, "chat-1", pane.id)); + } + } + LoopBarProbe.publish(&app); + try std.testing.expect(!LoopBarProbe.has_bar); + app.toggleWorkspacePanelState(); - try std.testing.expectEqual(true, app.workspace_controls.panel_visible); - try std.testing.expectEqual(GraphCanvas.Surface.workspace, app.surface); + try std.testing.expectEqual(GraphCanvas.Surface.project, app.surface); + try std.testing.expect(!app.workspace_controls.panel_visible); try std.testing.expect(!app.workspace_is_quick_chat); + try std.testing.expect(app.quick_chat_workspace_dismissed); + try std.testing.expectEqualStrings("C:\\loop-fixture", workspace.projectPath()); + try std.testing.expectEqual(@as(usize, 0), workspace.loop_id.len); + LoopBarProbe.publish(&app); + try std.testing.expect(!LoopBarProbe.has_bar); + try std.testing.expect(LoopBarProbe.stop == null); + try std.testing.expect(LoopBarProbe.panel_toggle == null); + + app.toggleWorkspaceDetailPanelState(); + try std.testing.expectEqual(GraphCanvas.Surface.project, app.surface); + try std.testing.expect(!app.workspace_controls.panel_visible); + try std.testing.expect(!app.workspace_is_quick_chat); + try std.testing.expect(app.quick_chat_workspace_dismissed); + try std.testing.expectEqualStrings("C:\\loop-fixture", workspace.projectPath()); + try std.testing.expectEqual(@as(usize, 0), workspace.loop_id.len); + LoopBarProbe.publish(&app); + try std.testing.expect(!LoopBarProbe.has_bar); + try std.testing.expect(LoopBarProbe.stop == null); + try std.testing.expect(LoopBarProbe.panel_toggle == null); + + const quoted_home = try std.json.Stringify.valueAlloc(allocator, home, .{}); + defer allocator.free(quoted_home); + const home_graph_template = + \\{"version":2,"kind":"event","sequence":3,"event":{"graphChanged":{"project":{"path":__HOME__,"name":"Loop fixture"},"nodes":[{"id":"loop-a","title":"Loop A","loopType":"turnBased","state":{"idle":{}}}],"edges":[]}}} + ; + const home_graph = try std.mem.replaceOwned(u8, allocator, home_graph_template, "__HOME__", quoted_home); + defer allocator.free(home_graph); + _ = try app.model.updateFromFrame(home_graph); + try std.testing.expect(app.selectProject(home)); + try std.testing.expect(app.model.setSelectedIndex(0)); + try std.testing.expectEqualStrings(home, app.currentProject().?); + app.openQuickChat("chat-1"); + try std.testing.expectEqualStrings("chat-1", workspace.loop_id); + app.showInGraph(ShowGraphCallbacks.finish, ShowGraphCallbacks.publish); + try std.testing.expectEqual(GraphCanvas.Surface.project, app.surface); + try std.testing.expect(!app.workspace_is_quick_chat); + try std.testing.expect(app.quick_chat_workspace_dismissed); + try std.testing.expectEqualStrings(home, workspace.projectPath()); + try std.testing.expectEqual(@as(usize, 0), workspace.loop_id.len); + for (workspace.layout.tabs.items) |tab| { + for (tab.panes.items) |pane| { + try std.testing.expect(!std.mem.eql(u8, "chat-1", pane.id)); + } + } + try std.testing.expect(app.selectNodeIndex(0)); + try std.testing.expect(!app.quick_chat_workspace_dismissed); + app.toggleWorkspacePanelState(); + try std.testing.expectEqual(GraphCanvas.Surface.workspace, app.surface); + try std.testing.expect(app.workspace_controls.panel_visible); } test "quick chat workspace publishes its bar controls without a project graph" { @@ -18045,6 +18141,26 @@ test "quick chat workspace publishes its bar controls without a project graph" { try std.testing.expectEqual(true, probe.restart_session); } +test "workspace toggles do not resurface a chat-bound workspace after dismissal failure" { + var app = try overviewTestApp(Dpi.base_dpi); + defer deinitOverviewTestApp(&app); + defer app.allocator.free(app.pending_project_path); + app.surface = .project; + app.workspace_is_quick_chat = true; + app.workspace_controls.panel_visible = true; + + app.toggleWorkspacePanelState(); + try std.testing.expectEqual(GraphCanvas.Surface.project, app.surface); + try std.testing.expect(!app.workspace_controls.panel_visible); + try std.testing.expect(app.workspace_is_quick_chat); + + app.workspace_controls.panel_visible = true; + app.toggleWorkspaceDetailPanelState(); + try std.testing.expectEqual(GraphCanvas.Surface.project, app.surface); + try std.testing.expect(!app.workspace_controls.panel_visible); + try std.testing.expect(app.workspace_is_quick_chat); +} + test "quick chat titles include the creation date" { var title_buffer: [32]u8 = undefined; const local_time = c.SYSTEMTIME{ diff --git a/graphcode-windows/src/TerminalSurface.zig b/graphcode-windows/src/TerminalSurface.zig index 08cbb1cb..0f8686dd 100644 --- a/graphcode-windows/src/TerminalSurface.zig +++ b/graphcode-windows/src/TerminalSurface.zig @@ -808,6 +808,31 @@ pub const Workspace = struct { return true; } + pub fn unbindLoop(self: *Workspace) !void { + if (self.loop_id.len == 0) return; + const new_layout_path = try self.layoutPathForProject(self.project_key); + errdefer self.allocator.free(new_layout_path); + var new_layout = try WorkspaceLayout.Layout.init(self.allocator, self.project_key); + errdefer new_layout.deinit(); + if (try loadSavedLayout(self.allocator, new_layout_path, self.project_key)) |restored| { + new_layout.deinit(); + new_layout = restored; + } + + var old_layout = self.layout; + const old_layout_path = self.layout_path; + self.layout = new_layout; + self.layout_path = new_layout_path; + for (self.surfaces, 0..) |_, index| self.destroySurface(index); + self.clearAllRecreateState(); + old_layout.deinit(); + self.allocator.free(old_layout_path); + self.clearLoopScope(); + for (&self.recreate_due_ms) |*due| due.* = 0; + for (&self.recreate_delay_ms) |*delay| delay.* = 100; + self.restorePersistedSurfaces("", 0); + } + fn clearLoopScope(self: *Workspace) void { if (self.loop_id.len != 0) self.allocator.free(self.loop_id); self.loop_id = &.{};