diff --git a/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp b/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp index 6b3f8dd7..9d755a11 100644 --- a/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp +++ b/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp @@ -369,6 +369,95 @@ static void nativeBannerInvokeChecks(IRawElementProviderSimple *provider, HWND h if (projects) projects->Release(); } +static void nativeQuickChatRestartChecks(IRawElementProviderSimple *provider, HWND hwnd, + IRawElementProviderFragment *root) { + check(!IsWindowVisible(hwnd), "Quick Chat restart is tested against a hidden provider window"); + const char *identities[] = {"workspace-restart-session:chat-b"}; + const char *names[] = {"Restart session"}; + const int parents[] = {4}; + const int none[] = {0}; + const int actions[] = {1}; + const int bounds[] = {732, 44, 864, 70}; + auto update = reinterpret_cast(&gc_uia_update); + check(update(provider, "Quick Chat", identities, names, parents, none, none, actions, bounds, + 1, 0, 1, 0, 0, 0, 0) == S_OK, + "Quick Chat restart publication succeeds"); + + auto *projects = child(root, NavigateDirection_FirstChild); + auto *loops = child(projects, NavigateDirection_NextSibling); + auto *worktrees = child(loops, NavigateDirection_NextSibling); + auto *graph = child(worktrees, NavigateDirection_NextSibling); + auto *restart = child(graph, NavigateDirection_FirstChild); + const std::wstring automation_id = stringProperty(restart, UIA_AutomationIdPropertyId); + check(automation_id.rfind(L"workspace-restart-session-", 0) == 0, + "Quick Chat restart uses its distinct AutomationId prefix"); + checkName(restart, L"Restart session"); + POINT origin{0, 0}; + ClientToScreen(hwnd, &origin); + checkRect(restart, origin, 732, 44, 132, 26, + "Quick Chat restart bounds match the published rectangle"); + auto *invoke = static_cast(pattern(restart, UIA_InvokePatternId)); + check(invoke != nullptr, "Quick Chat restart exposes IInvokeProvider"); + posted_commands.clear(); + if (invoke) { + check(invoke->Invoke() == S_OK, "Quick Chat restart Invoke succeeds"); + check(posted_commands.empty(), "Quick Chat restart posts outside the UIA call"); + drainMessages(hwnd); + const uint64_t expected = 0x8000000000000000ULL | + identityPayload("workspace-restart-session:chat-b"); + check(posted_commands.size() == 1 && posted_commands[0] == expected, + "Quick Chat restart posts its exact hashed identity once"); + } + + const char *replacement_identities[] = {"workspace-restart-session:chat-c"}; + const char *replacement_names[] = {"Replacement restart"}; + check(update(provider, "Replacement", replacement_identities, replacement_names, parents, + none, none, actions, bounds, 1, 0, 1, 0, 0, 0, 0) == S_OK, + "replacement Quick Chat restart publication succeeds"); + posted_commands.clear(); + if (invoke) { + check(invoke->Invoke() != S_OK, "a retained restart fragment is disabled after replacement"); + drainMessages(hwnd); + check(posted_commands.empty(), "a stale restart fragment cannot invoke the replacement row"); + invoke->Release(); + } + if (restart) restart->Release(); + if (graph) graph->Release(); + if (worktrees) worktrees->Release(); + if (loops) loops->Release(); + if (projects) projects->Release(); +} + +static void nativeQuickChatWorkspaceBarChecks(IRawElementProviderSimple *provider, + IRawElementProviderFragment *root) { + const char *identities[] = {"workspace-quick-chat-bar:chat-b"}; + const char *names[] = {"Quick Chat title"}; + const int parents[] = {4}; + const int none[] = {0}; + const int actions[] = {0}; + const int bounds[] = {0, 0, 0, 0}; + auto update = reinterpret_cast(&gc_uia_update); + check(update(provider, "Quick Chat", identities, names, parents, none, none, actions, bounds, + 1, 0, 1, 0, 0, 0, 0) == S_OK, + "Quick Chat workspace bar publication succeeds"); + + auto *projects = child(root, NavigateDirection_FirstChild); + auto *loops = child(projects, NavigateDirection_NextSibling); + auto *worktrees = child(loops, NavigateDirection_NextSibling); + auto *graph = child(worktrees, NavigateDirection_NextSibling); + auto *bar = child(graph, NavigateDirection_FirstChild); + const std::wstring automation_id = stringProperty(bar, UIA_AutomationIdPropertyId); + check(automation_id.rfind(L"workspace-quick-chat-bar-", 0) == 0, + "Quick Chat workspace bar uses its distinct AutomationId prefix"); + checkName(bar, L"Quick Chat title"); + check(!invokable(bar), "Quick Chat workspace bar remains non-invokable"); + if (bar) bar->Release(); + if (graph) graph->Release(); + if (worktrees) worktrees->Release(); + if (loops) loops->Release(); + if (projects) projects->Release(); +} + // The sidebar lists scroll as one viewport, and every row that scrolls with it can be brought // into view through UIA, so keyboard and assistive clients can reach rows that are offscreen. static void nativeScrollChecks(IRawElementProviderSimple *provider, HWND hwnd, @@ -512,6 +601,8 @@ static void nativeCommandAndScrollChecks() { check(provider->QueryInterface(IID_PPV_ARGS(&root)) == S_OK, "capture root fragment exists"); if (root) { nativeBannerInvokeChecks(provider, capture, root); + nativeQuickChatRestartChecks(provider, capture, root); + nativeQuickChatWorkspaceBarChecks(provider, root); nativeScrollChecks(provider, capture, root); root->Release(); } diff --git a/graphcode-windows/src/AccessibilityProvider.cpp b/graphcode-windows/src/AccessibilityProvider.cpp index b5bbae9c..8852da53 100644 --- a/graphcode-windows/src/AccessibilityProvider.cpp +++ b/graphcode-windows/src/AccessibilityProvider.cpp @@ -1243,6 +1243,8 @@ class Node final : public IRawElementProviderSimple, row.identity.rfind("attention-action:", 0) == 0 ? L"attention-action-" : 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-quick-chat-bar:", 0) == 0 ? L"workspace-quick-chat-bar-" : + row.identity.rfind("workspace-restart-session:", 0) == 0 ? L"workspace-restart-session-" : row.identity.rfind("workspace-show-graph:", 0) == 0 ? L"workspace-show-graph-" : row.identity.rfind("workspace-stop:", 0) == 0 ? L"workspace-stop-" : row.identity.rfind("workspace-toggle-panel:", 0) == 0 ? L"workspace-toggle-panel-" : diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index b9054ec4..c1125bbb 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -25,6 +25,7 @@ const Sidebar = @import("Sidebar.zig"); const GraphModel = @import("GraphModel.zig"); const InputRouter = @import("InputRouter.zig"); const MainWindow = @import("MainWindow.zig"); +const TerminalSurface = @import("TerminalSurface.zig"); const TerminalWorkspace = @import("TerminalWorkspace.zig"); const LoopBarLayout = @import("LoopBarLayout.zig"); const Clipboard = @import("Clipboard.zig"); @@ -1246,6 +1247,13 @@ fn workspaceLoopBar(controls: WorkspaceControls.State, client_right: i32, resolv ); } +fn quickChatByID(model: *const GraphModel.Model, id: []const u8) ?*const GraphModel.QuickChat { + for (model.quick_chats.items) |*chat| { + if (std.mem.eql(u8, chat.id, id)) return chat; + } + return null; +} + 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| { @@ -1342,6 +1350,7 @@ const UiaDynamicTarget = union(enum) { workspace_tab: usize, workspace_tab_close: usize, workspace_switch: usize, + workspace_restart_session: []const u8, header_attention, header_worktree, header_jump, @@ -1405,7 +1414,6 @@ pub const App = struct { workspace_controls: WorkspaceControls.State = .{ .panel_visible = false }, dpi: u32 = Dpi.base_dpi, surface: GraphCanvas.Surface = .project, - workspace_is_quick_chat: bool = false, header_focus: ?GraphCanvas.HeaderAction = null, header_return_focus: c.HWND = null, header_focus_transition: bool = false, @@ -1998,12 +2006,21 @@ pub const App = struct { if (event == .quick_chat_changed) { if (Wire.jsonString(frame, "id")) |id| self.openQuickChat(id); } + var deleted_bound_chat = false; if (event == .quick_chat_deleted and !self.smoke) { if (Wire.jsonString(frame, "quickChatDeleted")) |id| { + if (self.workspaceQuickChatID()) |bound_id| { + deleted_bound_chat = std.mem.eql(u8, bound_id, id); + } if (self.workspace) |workspace| _ = workspace.retireLoop(id, TerminalWorkspace.quick_chat_scope_project); } } + if (deleted_bound_chat) { + self.routeQuickChatWorkspaceAway("Quick Chat is no longer available"); + } else { + self.routeUnavailableQuickChatWorkspace(); + } _ = c.InvalidateRect(self.window.hwnd, null, 0); }, .error_occurred => { @@ -2483,7 +2500,7 @@ pub const App = struct { fn refreshWorkspace(self: *App) void { const workspace = if (self.workspace) |value| value else return; if (self.smoke) return self.refreshSmokeTerminals(workspace); - if (self.workspace_is_quick_chat) return; + if (self.workspaceQuickChatID() != null) return; const project = self.currentProject() orelse return; if (!std.mem.eql(u8, workspace.projectPath(), project)) return; if (workspace.hasSurface(0) or workspace.hasAttach(0) or workspace.isAwaitingLaunch(0)) return; @@ -2492,6 +2509,52 @@ pub const App = struct { self.setStatus("Unable to attach selected loop"); } + fn workspaceQuickChatID(self: *const App) ?[]const u8 { + if (self.surface != .workspace) return null; + const workspace = self.workspace orelse return null; + return workspace.boundQuickChatID(); + } + + fn isQuickChatWorkspace(self: *const App) bool { + return self.workspaceQuickChatID() != null; + } + + fn hasQuickChatWorkspaceScope(self: *const App) bool { + const workspace = self.workspace orelse return false; + return std.mem.eql(u8, workspace.layout.project_key, TerminalWorkspace.quick_chat_scope_project); + } + + fn boundQuickChat(self: *const App) ?*const GraphModel.QuickChat { + const id = self.workspaceQuickChatID() orelse return null; + return quickChatByID(&self.model, id); + } + + fn routeQuickChatWorkspaceAway(self: *App, status_text: []const u8) void { + self.surface = .quick_chats; + self.workspace_controls.panel_visible = false; + self.selected_quick_chat = null; + self.layoutWorkspace(); + self.layoutEmptyStateControls(); + self.setStatus(status_text); + self.syncAccessibility(); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + } + + fn routeUnavailableQuickChatWorkspace(self: *App) void { + const id = self.workspaceQuickChatID() orelse return; + if (quickChatByID(&self.model, id) != null) return; + self.routeQuickChatWorkspaceAway("Quick Chat is no longer available"); + } + + fn routeFromFailedQuickChatOpen(self: *App) void { + self.surface = .quick_chats; + self.workspace_controls.panel_visible = false; + self.layoutWorkspace(); + self.layoutEmptyStateControls(); + self.syncAccessibility(); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + } + /// The smoke gate's two-terminal contract: terminals A and B show the graph's first two /// loops once they run. Product windows never use it (see `refreshWorkspace`). fn refreshSmokeTerminals(self: *App, workspace: *TerminalWorkspace.Workspace) void { @@ -2511,6 +2574,12 @@ pub const App = struct { const workspace = self.workspace orelse return; self.bindLoopShellDirectory(workspace, project_path, node_id); workspace.scopeToLoop(node_id, project_path, true) catch { + if (workspace.boundQuickChatID() != null) { + self.surface = .project; + self.workspace_controls.panel_visible = false; + self.layoutWorkspace(); + self.layoutEmptyStateControls(); + } self.setStatus("Unable to open selected loop"); return; }; @@ -3039,24 +3108,36 @@ pub const App = struct { fn openQuickChat(self: *App, id: []const u8) void { for (self.model.quick_chats.items, 0..) |chat, index| { if (!std.mem.eql(u8, chat.id, id)) continue; + const workspace = self.workspace orelse { + self.setStatus("Quick Chat workspace is unavailable"); + self.routeFromFailedQuickChatOpen(); + return; + }; 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| { - if (std.process.getEnvVarOwned(self.allocator, "USERPROFILE")) |home| { - defer self.allocator.free(home); - _ = workspace.rebindProject(home) catch {}; - } else |_| {} - workspace.scopeToLoop(chat.id, TerminalWorkspace.quick_chat_scope_project, false) catch { - self.setStatus("Unable to open quick chat workspace"); - }; - workspace.openNode(0, chat.id) catch { - self.setStatus("Unable to open quick chat workspace"); - }; + if (std.process.getEnvVarOwned(self.allocator, "USERPROFILE")) |home| { + defer self.allocator.free(home); + _ = workspace.rebindProject(home) catch {}; + } else |_| {} + workspace.scopeToLoop(chat.id, TerminalWorkspace.quick_chat_scope_project, false) catch { + self.setStatus("Unable to open quick chat workspace"); + self.routeFromFailedQuickChatOpen(); + return; + }; + const bound_id = workspace.boundQuickChatID() orelse { + self.setStatus("Unable to bind quick chat workspace"); + self.routeFromFailedQuickChatOpen(); + return; + }; + if (!std.mem.eql(u8, bound_id, chat.id)) { + self.setStatus("Unable to bind quick chat workspace"); + self.routeFromFailedQuickChatOpen(); + return; } + workspace.openNode(0, chat.id) catch self.setStatus("Unable to open quick chat workspace"); self.syncAccessibility(); return; } @@ -4279,7 +4360,6 @@ pub const App = struct { self.setStatus("Composite templates have no terminal until the group is piloted"); return false; } - self.workspace_is_quick_chat = false; self.surface = .workspace; self.workspace_controls.panel_visible = true; self.layoutWorkspace(); @@ -6801,7 +6881,7 @@ pub const App = struct { self.setStatus(if (self.workspace_controls.rail_visible) "Workspace rail shown" else "Workspace rail hidden"); }, .toggle_panel => { - self.toggleWorkspacePanelState(); + if (!self.toggleWorkspacePanelState()) return; self.layoutWorkspace(); _ = c.InvalidateRect(self.window.hwnd, null, 0); self.setStatus(if (self.workspace_controls.panel_visible) "Workspace panel shown" else "Workspace panel hidden"); @@ -6847,7 +6927,10 @@ pub const App = struct { } fn showInGraph(self: *App, comptime finish_native: fn (*App) void, comptime publish: fn (*App) void) void { - self.surface = .project; + self.surface = if (self.isQuickChatWorkspace() and self.model.currentGraph() == null) + .quick_chats + else + .project; self.workspace_controls.panel_visible = false; self.workspace_controls.apply(.show_graph); finish_native(self); @@ -6989,22 +7072,25 @@ pub const App = struct { }; } - fn toggleWorkspacePanelState(self: *App) void { + fn toggleWorkspacePanelState(self: *App) bool { + if (self.hasQuickChatWorkspaceScope()) return false; self.workspace_controls.apply(.toggle_panel); if (self.workspace_controls.panel_visible) { - self.workspace_is_quick_chat = false; self.surface = .workspace; } else if (self.surface == .workspace) { self.surface = .project; } + return true; } fn toggleWorkspaceDetailPanelState(self: *App) void { + if (self.hasQuickChatWorkspaceScope()) return; self.workspace_controls.panel_visible = !self.workspace_controls.panel_visible; if (self.workspace_controls.panel_visible) self.surface = .workspace; } fn toggleWorkspaceDetailPanel(self: *App) void { + if (self.hasQuickChatWorkspaceScope()) return; self.toggleWorkspaceDetailPanelState(); self.layoutWorkspace(); self.syncAccessibility(); @@ -7330,11 +7416,9 @@ pub const App = struct { fn headerPresentation(self: *const App) GraphCanvas.Header { var header = GraphCanvas.Header{ .attention_count = self.model.attentionCount() }; - if (self.surface == .workspace and self.workspace_is_quick_chat) { + if (self.isQuickChatWorkspace()) { header.context = "Quick Chat workspace"; - if (self.selected_quick_chat) |index| if (index < self.model.quick_chats.items.len) { - header.title = self.model.quick_chats.items[index].title; - }; + header.title = if (self.boundQuickChat()) |chat| chat.title else "Quick Chat unavailable"; } else if (self.surface == .overview) { header.title = "Graph"; header.context = "All projects"; @@ -7347,7 +7431,7 @@ pub const App = struct { else (if (graph.project.isRemote()) "Remote" else if (graph.project.isGlobal()) "Global" else "Local folder"); } - if (GraphCanvas.loopPanelHasContent(&self.model, self.surface, self.workspace_is_quick_chat)) { + if (GraphCanvas.loopPanelHasContent(&self.model, self.surface, self.isQuickChatWorkspace())) { header.panel_visible = self.workspace_controls.panel_visible; } if (self.worktreeHeaderOwner()) |owner| { @@ -7723,34 +7807,31 @@ pub const App = struct { const banner = Sidebar.updateBannerRect(footer_viewport_bottom, footer.ingress); self.appendAccessibilityElement(&elements, &owned_identities, "sidebar-update-banner", "update", "Update available", 1, .{ .logical = banner }, 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 identity = std.fmt.allocPrint(self.allocator, "quick-chat-workspace:{s}", .{chat.id}) catch return; - owned_identities.append(identity) catch { - self.allocator.free(identity); - return; - }; - const workspace_bounds = (AccessibilityBounds{ .logical = .{ - .left = canvas_rect.left, - .top = inputBounds(client.right, client.bottom, self.workspace_controls).workspace_top, - .right = canvas_rect.right, - .bottom = client.bottom, - } }).physicalRect(self.dpi); - elements.append(.{ - .identity = identity, - .name = "Quick Chat terminal workspace", - .parent = 4, - .selected = true, - .eligible = false, - .invokable = false, - .left = workspace_bounds.left, - .top = workspace_bounds.top, - .right = workspace_bounds.right, - .bottom = workspace_bounds.bottom, - }) catch return; - } - }; + if (self.workspaceQuickChatID()) |chat_id| { + const identity = std.fmt.allocPrint(self.allocator, "quick-chat-workspace:{s}", .{chat_id}) catch return; + owned_identities.append(identity) catch { + self.allocator.free(identity); + return; + }; + const workspace_bounds = (AccessibilityBounds{ .logical = .{ + .left = canvas_rect.left, + .top = Tokens.header_height + Tokens.loop_bar_height, + .right = canvas_rect.right, + .bottom = client.bottom, + } }).physicalRect(self.dpi); + elements.append(.{ + .identity = identity, + .name = "Quick Chat terminal workspace", + .parent = 4, + .selected = true, + .eligible = false, + .invokable = false, + .left = workspace_bounds.left, + .top = workspace_bounds.top, + .right = workspace_bounds.right, + .bottom = workspace_bounds.bottom, + }) catch return; + } if (self.model.attention_entries.items.len != 0) { const section = Sidebar.sidebarSectionBottom(&self.model, current_inspection, &self.sidebar_state) - self.sidebar_scroll; self.appendAccessibilityElement(&elements, &owned_identities, "needs-you-header", "needs-you", "Needs you", 1, .{ .logical = clipSidebarAccessibilityBounds(.{ .left = 12, .top = section + 4, .right = 232, .bottom = section + 28 }, sidebar_bottom) }, false, true) catch return; @@ -7878,59 +7959,45 @@ 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) { + if (self.workspace != null) { + if (!self.isQuickChatWorkspace()) { + 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; - } - 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; + 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; } - 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; + 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; + } + 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; + } + 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; + } } } - for (workspace.layout.tabs.items, 0..) |tab, tab_index| { - const tab_key = std.fmt.allocPrint(self.allocator, "{d}", .{tab_index}) catch return; - defer self.allocator.free(tab_key); - const tab_bounds = TerminalWorkspace.tabBounds(workspace.layout_origin_x, workspace.layout_origin_y, tab_index); - self.appendAccessibilityElement(&elements, &owned_identities, "workspace-tab", tab_key, if (tab.panes.items.len > 1) "Split tab" else if (tab_index == 0) "Agent tab" else "Shell tab", 4, .{ .physical = tab_bounds }, tab_index == workspace.layout.selected_tab, true) catch return; - const close_key = std.fmt.allocPrint(self.allocator, "{d}", .{tab_index}) catch return; - defer self.allocator.free(close_key); - self.appendAccessibilityElement(&elements, &owned_identities, "workspace-tab-close", close_key, "Close tab", 4, .{ .physical = .{ .left = tab_bounds.right - 24, .top = tab_bounds.top, .right = tab_bounds.right, .bottom = tab_bounds.bottom } }, false, workspace.canCloseTab()) catch return; - } - for ([_][]const u8{ "New Tab", "Split Right", "Split Down" }, 0..) |label, control_index| { - const control_bounds = TerminalWorkspace.chromeControlBounds(workspace.layout_origin_x, workspace.layout_origin_y, workspace.layout_width, control_index); - const kind = if (control_index == 0) "workspace-new-tab" else if (control_index == 1) "workspace-split-right" else "workspace-split-down"; - self.appendAccessibilityElement(&elements, &owned_identities, kind, "control", label, 4, .{ .physical = control_bounds }, false, true) catch return; - } } } }, @@ -7955,6 +8022,48 @@ 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) { + if (self.workspace) |workspace| { + if (self.workspaceQuickChatID()) |chat_id| { + if (quickChatByID(&self.model, chat_id)) |chat| { + const bar = TerminalSurface.quickChatBarLayout( + if (self.workspace_controls.rail_visible) Tokens.sidebar_width else 0, + client.right, + ); + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-quick-chat-bar", chat_id, chat.title, 4, .{ .logical = loopBarRect(bar.bar) }, false, false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-restart-session", chat_id, "Restart session", 4, .{ .logical = loopBarRect(bar.restart_session) }, false, true) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-show-graph", "show-graph", "Show in Graph", 4, .{ .logical = loopBarRect(bar.show_graph) }, false, true) catch return; + } + } + for (workspace.layout.tabs.items, 0..) |tab, tab_index| { + const tab_key = std.fmt.allocPrint(self.allocator, "{d}", .{tab_index}) catch return; + defer self.allocator.free(tab_key); + const tab_bounds = TerminalWorkspace.tabBounds(workspace.layout_origin_x, workspace.layout_origin_y, tab_index); + var launches_agent = false; + for (tab.panes.items) |pane| { + if (pane.launches_agent) { + launches_agent = true; + break; + } + } + const tab_name = if (tab.panes.items.len > 1) + "Split tab" + else if (launches_agent) + "Agent tab" + else + "Shell tab"; + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-tab", tab_key, tab_name, 4, .{ .physical = tab_bounds }, tab_index == workspace.layout.selected_tab, true) catch return; + const close_key = std.fmt.allocPrint(self.allocator, "{d}", .{tab_index}) catch return; + defer self.allocator.free(close_key); + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-tab-close", close_key, "Close tab", 4, .{ .physical = .{ .left = tab_bounds.right - 24, .top = tab_bounds.top, .right = tab_bounds.right, .bottom = tab_bounds.bottom } }, false, workspace.canCloseTab()) catch return; + } + for ([_][]const u8{ "New Tab", "Split Right", "Split Down" }, 0..) |label, control_index| { + const control_bounds = TerminalWorkspace.chromeControlBounds(workspace.layout_origin_x, workspace.layout_origin_y, workspace.layout_width, control_index); + const kind = if (control_index == 0) "workspace-new-tab" else if (control_index == 1) "workspace-split-right" else "workspace-split-down"; + self.appendAccessibilityElement(&elements, &owned_identities, kind, "control", label, 4, .{ .physical = control_bounds }, false, true) catch return; + } + } + } const canvas_alert = if (self.ingress_error.len != 0) self.ingress_error else if (self.connectionFailureVisible()) @@ -8216,7 +8325,16 @@ pub const App = struct { } } if (self.surface == .workspace) { - if (self.model.selectedNodeID()) |node_id| { + if (self.workspaceQuickChatID()) |chat_id| { + if (quickChatByID(&self.model, chat_id) != null) { + const identity = std.fmt.allocPrint(self.allocator, "workspace-restart-session:{s}", .{chat_id}) catch return false; + defer self.allocator.free(identity); + if (Accessibility.worktreeIdentityPayload(identity) == payload) { + if (target != null) return false; + target = .{ .workspace_restart_session = chat_id }; + } + } + } else if (self.model.selectedNodeID()) |node_id| { const identity = std.fmt.allocPrint(self.allocator, "workspace-stop:{s}", .{node_id}) catch return false; defer self.allocator.free(identity); if (Accessibility.worktreeIdentityPayload(identity) == payload) { @@ -8360,17 +8478,7 @@ pub const App = struct { .quick_chat => |id| { self.setStatus("Opening quick chat..."); if (envFlag("GRAPHCODE_UIA_GATE")) { - for (self.model.quick_chats.items, 0..) |chat, index| { - if (!std.mem.eql(u8, chat.id, id)) continue; - self.selected_quick_chat = index; - self.workspace_is_quick_chat = true; - self.surface = .workspace; - self.workspace_controls.panel_visible = true; - self.layoutWorkspace(); - self.layoutEmptyStateControls(); - self.syncAccessibility(); - break; - } + self.openQuickChat(id); } else { self.client.sendOpenQuickChat(id); } @@ -8381,6 +8489,13 @@ pub const App = struct { .workspace_split_right => self.handleAction(.split_horizontal), .workspace_split_down => self.handleAction(.split_vertical), .workspace_toggle_panel => self.toggleWorkspaceDetailPanel(), + .workspace_restart_session => |chat_id| { + if (!std.mem.eql(u8, self.workspaceQuickChatID() orelse return false, chat_id) or + quickChatByID(&self.model, chat_id) == null) + { + return false; + } + }, .workspace_tab => |index| if (self.workspace) |workspace| workspace.selectTab(index) catch return false, .workspace_tab_close => |index| if (self.workspace) |workspace| workspace.closeTab(index) catch return false, .workspace_switch => |index| { @@ -9147,7 +9262,24 @@ 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| { + if (app.workspaceQuickChatID()) |chat_id| { + if (quickChatByID(&app.model, chat_id)) |chat| { + var age_buffer: [32]u8 = undefined; + const now_timestamp = @max(std.time.timestamp(), @as(i64, 0)); + const presentation = TerminalSurface.quickChatBarPresentation( + chat.title, + chat.created_at, + @intCast(now_timestamp), + &age_buffer, + ); + TerminalWorkspace.Workspace.paintQuickChatBar( + hdc, + if (app.workspace_controls.rail_visible) Tokens.sidebar_width else 0, + logical_right, + presentation, + ); + } + } else if (workspaceGraph(&app.model)) |graph| { const workspace_right = logical_right - (if (app.workspace_controls.panel_visible) Tokens.loop_detail_width else 0); const index = app.model.selectedIndex() orelse 0; if (index < graph.nodes.items.len) { @@ -9179,7 +9311,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.isQuickChatWorkspace()) { if (workspaceGraph(&app.model)) |graph| { const index = app.model.selectedIndex() orelse graph.nodes.items.len; GraphCanvas.paintLoopDetailRail( @@ -9519,24 +9651,37 @@ 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)) { - app.toggleWorkspaceDetailPanel(); - _ = c.InvalidateRect(hwnd, null, 0); - result.* = 0; - return true; - } - 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]; - if (workspaceLoopBar(app.workspace_controls, client.right, isResolvedLoopState(node.state)).actionAt(x, y)) |action| { - switch (action) { - .stop => app.stopSelectedNode(), - .show_graph => app.handleAction(.show_graph), + if (app.isQuickChatWorkspace()) { + const bar = TerminalSurface.quickChatBarLayout(rail_left, client.right); + if (bar.actionAt(x, y)) |action| { + switch (action) { + .restart_session => {}, + .show_graph => app.handleAction(.show_graph), + } + _ = c.InvalidateRect(hwnd, null, 0); + result.* = 0; + return true; + } + } else { + if (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| { + const index = app.model.selectedIndex() orelse graph.nodes.items.len; + if (index < graph.nodes.items.len) { + const node = graph.nodes.items[index]; + if (workspaceLoopBar(app.workspace_controls, client.right, isResolvedLoopState(node.state)).actionAt(x, y)) |action| { + switch (action) { + .stop => app.stopSelectedNode(), + .show_graph => app.handleAction(.show_graph), + } + _ = c.InvalidateRect(hwnd, null, 0); + result.* = 0; + return true; } - _ = c.InvalidateRect(hwnd, null, 0); - result.* = 0; - return true; } } } @@ -18048,6 +18193,7 @@ const DpiExpectedElement = struct { const DpiAccessibilitySink = struct { expected: []const DpiExpectedElement, dpi_index: usize, + client_bounds: ?c.RECT = null, canvas: ?c.RECT = null, checked: bool = false, failure: ?anyerror = null, @@ -18066,15 +18212,21 @@ const DpiAccessibilitySink = struct { fn checkElements(self: *@This(), elements: []const Accessibility.DynamicElement) !void { for (self.expected) |expected| { var found = false; + var matches: usize = 0; const required = expected.present orelse (expected.bounds != null); for (elements) |element| { if (!std.mem.eql(u8, expected.identity, element.identity)) continue; found = true; + matches += 1; if (!required) { std.debug.print("Unexpected UIA element: {s}\n", .{expected.identity}); return error.UnexpectedAccessibilityElement; } const actual = [4]i32{ element.left, element.top, element.right, element.bottom }; + if (self.client_bounds) |client| { + try std.testing.expect(element.left >= client.left and element.top >= client.top); + try std.testing.expect(element.right <= client.right and element.bottom <= client.bottom); + } if (expected.bounds) |bounds| { std.testing.expectEqualDeep(bounds[self.dpi_index], actual) catch |err| { std.debug.print("UIA bounds mismatch: {s}, DPI index {d}\n", .{ expected.identity, self.dpi_index }); @@ -18087,6 +18239,7 @@ const DpiAccessibilitySink = struct { } if (!found and required) std.debug.print("Missing UIA element: {s}\n", .{expected.identity}); try std.testing.expectEqual(required, found); + try std.testing.expectEqual(if (required) @as(usize, 1) else 0, matches); } } }; @@ -19633,11 +19786,11 @@ test "worktree notice App UIA data shares per-lane labels geometry and once-only try std.testing.expectEqual(WorktreeStatus.NoticeState.indeterminate, GraphCanvas.overviewWorktreeNotice(app.model.graphFor("C:\\notice-b").?).?.state); } -fn expectDpiAccessibility(surface: GraphCanvas.Surface, canvas: ?[3][4]i32, expected: []const DpiExpectedElement, quick_chat: bool) !void { - try expectDpiAccessibilityWithMetrics(surface, canvas, expected, quick_chat, 2); +fn expectDpiAccessibility(surface: GraphCanvas.Surface, canvas: ?[3][4]i32, expected: []const DpiExpectedElement) !void { + try expectDpiAccessibilityWithMetrics(surface, canvas, expected, 2); } -fn expectDpiAccessibilityWithMetrics(surface: GraphCanvas.Surface, canvas: ?[3][4]i32, expected: []const DpiExpectedElement, quick_chat: bool, metric_samples: u8) !void { +fn expectDpiAccessibilityWithMetrics(surface: GraphCanvas.Surface, canvas: ?[3][4]i32, expected: []const DpiExpectedElement, metric_samples: u8) !void { const allocator = std.testing.allocator; var app: App = .{ .allocator = allocator, @@ -19651,7 +19804,6 @@ fn expectDpiAccessibilityWithMetrics(surface: GraphCanvas.Surface, canvas: ?[3][ .declared_entry_ids = std.array_list.Managed([]u8).init(allocator), .kept_worktree_paths = std.array_list.Managed([]u8).init(allocator), .surface = surface, - .workspace_is_quick_chat = quick_chat, .workspace_controls = .{ .rail_visible = true, .panel_visible = true, .activity_enabled = false }, }; defer app.client.deinit(); @@ -19722,7 +19874,7 @@ fn expectDpiAccessibilityWithMetrics(surface: GraphCanvas.Surface, canvas: ?[3][ } test "DPI UIA fixed graph uses physical client bounds" { - try expectDpiAccessibility(.project, .{ .{ 220, 34, 1200, 650 }, .{ 330, 51, 1800, 975 }, .{ 440, 68, 2400, 1300 } }, &.{}, false); + try expectDpiAccessibility(.project, .{ .{ 220, 34, 1200, 650 }, .{ 330, 51, 1800, 975 }, .{ 440, 68, 2400, 1300 } }, &.{}); } test "DPI UIA logical cards headers sidebar and direct inserts scale once" { @@ -19745,39 +19897,34 @@ test "DPI UIA logical cards headers sidebar and direct inserts scale once" { }; for ([_]GraphCanvas.Surface{ .project, .overview, .quick_chats, .workspace }) |surface| { const footer = if (surface == .workspace) footer_full_height else footer_above_panel; - try expectDpiAccessibility(surface, null, &(common ++ [_]DpiExpectedElement{footer}), false); + try expectDpiAccessibility(surface, null, &(common ++ [_]DpiExpectedElement{footer})); if (surface != .workspace) try expectDpiAccessibility(surface, null, &.{ .{ .identity = "header-toggle-panel:control" }, .{ .identity = "workspace-toolbar:A" }, - }, false); + }); } try expectDpiAccessibility(.project, null, &.{ .{ .identity = "project-card:A:loop", .bounds = .{ .{ 252, 84, 502, 180 }, .{ 378, 126, 753, 270 }, .{ 504, 168, 1004, 360 } } }, .{ .identity = "canvas-alert:ingress", .bounds = .{ .{ 244, 556, 1176, 612 }, .{ 366, 834, 1764, 918 }, .{ 488, 1112, 2352, 1224 } } }, - }, false); + }); try expectDpiAccessibility(.overview, null, &.{ .{ .identity = "overview-card:A:loop", .bounds = .{ .{ 262, 118, 512, 214 }, .{ 393, 177, 768, 321 }, .{ 524, 236, 1024, 428 } } }, - }, false); + }); try expectDpiAccessibility(.quick_chats, null, &.{ .{ .identity = "quick-chat-card:chat", .bounds = .{ .{ 262, 88, 482, 152 }, .{ 393, 132, 723, 228 }, .{ 524, 176, 964, 304 } } }, - }, false); + }); try expectDpiAccessibility(.workspace, null, &.{ .{ .identity = "header-toggle-panel:control", .bounds = .{ .{ 768, 5, 904, 29 }, .{ 1152, 8, 1356, 44 }, .{ 1536, 10, 1808, 58 } } }, .{ .identity = "workspace-toolbar:A", .bounds = .{ .{ 8, 1, 280, 33 }, .{ 12, 2, 420, 50 }, .{ 16, 2, 560, 66 } } }, .{ .identity = "workspace-loop-bar:loop", .bounds = .{ .{ 220, 34, 928, 80 }, .{ 330, 51, 1392, 120 }, .{ 440, 68, 1856, 160 } } }, - .{ .identity = "workspace-show-graph:show-graph", .bounds = .{ .{ 824, 44, 916, 70 }, .{ 1236, 66, 1374, 105 }, .{ 1648, 88, 1832, 140 } } }, - .{ .identity = "workspace-stop:loop", .bounds = .{ .{ 732, 44, 816, 70 }, .{ 1098, 66, 1224, 105 }, .{ 1464, 88, 1632, 140 } } }, + .{ .identity = "workspace-show-graph:show-graph", .bounds = .{ .{ 824, 44, 916, 70 }, .{ 1236, 66, 1374, 105 }, .{ 1648, 88, 1832, 140 } }, .eligible = false }, + .{ .identity = "workspace-stop:loop", .bounds = .{ .{ 732, 44, 816, 70 }, .{ 1098, 66, 1224, 105 }, .{ 1464, 88, 1632, 140 } }, .eligible = false }, .{ .identity = "workspace-toggle-panel:control", .bounds = .{ .{ 1100, 46, 1182, 68 }, .{ 1650, 69, 1773, 102 }, .{ 2200, 92, 2364, 136 } } }, .{ .identity = "workspace-detail-sparkline:loop", .bounds = .{ .{ 940, 798, 1188, 830 }, .{ 1410, 1197, 1782, 1245 }, .{ 1880, 1596, 2376, 1660 } } }, .{ .identity = "workspace-detail-start:loop", .bounds = .{ .{ 940, 872, 1188, 890 }, .{ 1410, 1308, 1782, 1335 }, .{ 1880, 1744, 2376, 1780 } } }, .{ .identity = "workspace-detail-usage:loop", .bounds = .{ .{ 940, 872, 1188, 890 }, .{ 1410, 1308, 1782, 1335 }, .{ 1880, 1744, 2376, 1780 } } }, .{ .identity = "quick-chat-workspace:chat" }, - }, false); - try expectDpiAccessibility(.workspace, null, &.{ - .{ .identity = "quick-chat-workspace:chat", .bounds = .{ .{ 220, 650, 1200, 900 }, .{ 330, 975, 1800, 1350 }, .{ 440, 1300, 2400, 1800 } } }, - .{ .identity = "header-toggle-panel:control" }, - .{ .identity = "workspace-toolbar:A" }, - }, true); + }); } test "loop detail UIA rectangles equal painted bounds and omit empty sparklines" { @@ -19791,20 +19938,21 @@ test "loop detail UIA rectangles equal painted bounds and omit empty sparklines" usage.bounds.?[index] = start.bounds.?[index]; sparkline.bounds.?[index] = .{ metric.left, metric.top, metric.right, metric.bottom }; } - try expectDpiAccessibilityWithMetrics(.workspace, null, &.{ start, usage, sparkline }, false, 2); + try expectDpiAccessibilityWithMetrics(.workspace, null, &.{ start, usage, sparkline }, 2); for ([_]u8{ 0, 1 }) |count| { - try expectDpiAccessibilityWithMetrics(.workspace, null, &.{ start, usage, .{ .identity = sparkline.identity } }, false, count); + try expectDpiAccessibilityWithMetrics(.workspace, null, &.{ start, usage, .{ .identity = sparkline.identity } }, count); } } test "DPI UIA terminal tab close and controls retain physical geometry" { try expectDpiAccessibility(.workspace, null, &.{ - .{ .identity = "workspace-tab:1", .bounds = .{ .{ 340, 84, 452, 106 }, .{ 450, 124, 562, 146 }, .{ 560, 164, 672, 186 } } }, + .{ .identity = "workspace-tab:0", .name = "Agent tab", .bounds = .{ .{ 220, 84, 332, 106 }, .{ 330, 124, 442, 146 }, .{ 440, 164, 552, 186 } } }, + .{ .identity = "workspace-tab:1", .name = "Shell tab", .bounds = .{ .{ 340, 84, 452, 106 }, .{ 450, 124, 562, 146 }, .{ 560, 164, 672, 186 } } }, .{ .identity = "workspace-tab-close:1", .bounds = .{ .{ 428, 84, 452, 106 }, .{ 538, 124, 562, 146 }, .{ 648, 164, 672, 186 } } }, .{ .identity = "workspace-new-tab:control", .bounds = .{ .{ 708, 83, 776, 107 }, .{ 1172, 123, 1240, 147 }, .{ 1636, 163, 1704, 187 } } }, .{ .identity = "workspace-split-right:control", .bounds = .{ .{ 780, 83, 848, 107 }, .{ 1244, 123, 1312, 147 }, .{ 1708, 163, 1776, 187 } } }, .{ .identity = "workspace-split-down:control", .bounds = .{ .{ 852, 83, 920, 107 }, .{ 1316, 123, 1384, 147 }, .{ 1780, 163, 1848, 187 } } }, - }, false); + }); } const OverviewAccessibilitySink = struct { @@ -22117,7 +22265,7 @@ test "header detail toggle preserves workspace instead of generic panel navigati try std.testing.expect(app.workspace_controls.panel_visible); try std.testing.expectEqual(sidebar_visible, app.workspace_controls.rail_visible); - app.toggleWorkspacePanelState(); + try std.testing.expect(app.toggleWorkspacePanelState()); try std.testing.expectEqual(GraphCanvas.Surface.project, app.surface); try std.testing.expect(!app.workspace_controls.panel_visible); try std.testing.expectEqual(sidebar_visible, app.workspace_controls.rail_visible); @@ -22241,9 +22389,298 @@ test "header presentation follows destinations and keeps sidebar independent" { } } } - app.workspace_is_quick_chat = true; - try std.testing.expect(!app.headerPresentation().contains(.toggle_panel)); +} + +test "Quick Chat parity bound identity ignores previous loop and chat list order" { + const allocator = std.testing.allocator; + var app = try overviewTestApp(Dpi.base_dpi); + defer deinitOverviewTestApp(&app); + _ = try app.model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"project":{"path":"C:\\quick-chat-parity","name":"Previous project"},"nodes":[{"id":"loop-a","title":"Previous loop","state":{"idle":{}}}],"edges":[]}}} + ); + try std.testing.expect(app.model.setSelectedIndex(0)); + for ([_][]const u8{ "chat-a", "chat-b" }, 0..) |id, index| { + try app.model.quick_chats.append(.{ + .id = try allocator.dupe(u8, id), + .title = try allocator.dupe(u8, if (index == 0) "Chat A" else "Chat B"), + .backend = try allocator.dupe(u8, "copilot"), + }); + } + + var temporary = std.testing.tmpDir(.{}); + defer temporary.cleanup(); + const directory = try temporary.dir.realpathAlloc(allocator, "."); + defer allocator.free(directory); + const layout_path = try std.fs.path.join(allocator, &.{ directory, "layout.json" }); + defer allocator.free(layout_path); + var workspace = try reopenTestWorkspace(allocator, layout_path); + defer workspace.deinit(); + try workspace.scopeToLoop("chat-b", TerminalWorkspace.quick_chat_scope_project, false); + app.workspace = &workspace; + defer app.workspace = null; + app.surface = .workspace; + app.selected_quick_chat = 1; + try std.testing.expectEqualStrings("chat-b", app.workspaceQuickChatID().?); + try std.testing.expectEqualStrings("Chat B", app.headerPresentation().title); try std.testing.expectEqualStrings("Quick Chat workspace", app.headerPresentation().context); + try std.testing.expect(!app.headerPresentation().contains(.toggle_panel)); + try std.testing.expect(!app.workspace_controls.panel_visible); + + std.mem.swap(GraphModel.QuickChat, &app.model.quick_chats.items[0], &app.model.quick_chats.items[1]); + allocator.free(app.model.quick_chats.items[0].title); + app.model.quick_chats.items[0].title = try allocator.dupe(u8, "Renamed chat B"); + try std.testing.expectEqualStrings("Renamed chat B", app.headerPresentation().title); + try std.testing.expectEqualStrings("loop-a", app.model.selected().?.id); + const expected = [_]DpiExpectedElement{ + .{ .identity = "workspace-quick-chat-bar:chat-b", .present = true, .name = "Renamed chat B" }, + .{ .identity = "workspace-restart-session:chat-b", .present = true, .name = "Restart session", .invokable = true }, + .{ .identity = "workspace-show-graph:show-graph", .present = true, .name = "Show in Graph", .invokable = true }, + .{ .identity = "workspace-loop-bar:loop-a", .present = false }, + .{ .identity = "workspace-stop:loop-a", .present = false }, + .{ .identity = "workspace-toggle-panel:control", .present = false }, + .{ .identity = "header-toggle-panel:control", .present = false }, + }; + var sink = DpiAccessibilitySink{ + .expected = &expected, + .dpi_index = 0, + .client_bounds = .{ .left = 0, .top = 0, .right = 1200, .bottom = 900 }, + }; + app.syncAccessibilityTo(&sink, .{ .left = 0, .top = 0, .right = 1200, .bottom = 900 }); + try std.testing.expect(sink.checked); + if (sink.failure) |err| return err; +} + +test "Quick Chat parity UIA and Show in Graph preserve chat-only workspace actions" { + const allocator = std.testing.allocator; + var app = try overviewTestApp(Dpi.base_dpi); + defer deinitOverviewTestApp(&app); + try app.model.quick_chats.append(.{ + .id = try allocator.dupe(u8, "chat-b"), + .title = try allocator.dupe(u8, "Chat B"), + .backend = try allocator.dupe(u8, "copilot"), + .created_at = 1_791_219_880, + }); + var temporary = std.testing.tmpDir(.{}); + defer temporary.cleanup(); + const directory = try temporary.dir.realpathAlloc(allocator, "."); + defer allocator.free(directory); + const layout_path = try std.fs.path.join(allocator, &.{ directory, "layout.json" }); + defer allocator.free(layout_path); + var workspace = try reopenTestWorkspace(allocator, layout_path); + defer workspace.deinit(); + try workspace.scopeToLoop("chat-b", TerminalWorkspace.quick_chat_scope_project, false); + app.workspace = &workspace; + defer app.workspace = null; + app.surface = .workspace; + try workspace.layout.addTab("chat-shell", false); + try workspace.layout.addTab("chat-agent", true); + try workspace.layout.addTab("chat-split-agent", true); + try workspace.layout.splitFocused(.horizontal, "chat-split-shell"); + + const expected = [_]DpiExpectedElement{ + .{ + .identity = "quick-chat-workspace:chat-b", + .present = true, + .name = "Quick Chat terminal workspace", + .bounds = .{ .{ 220, 80, 1200, 900 }, .{ 330, 120, 1800, 1350 }, .{ 440, 160, 2400, 1800 } }, + }, + .{ + .identity = "workspace-quick-chat-bar:chat-b", + .present = true, + .name = "Chat B", + .bounds = .{ .{ 220, 34, 1200, 80 }, .{ 330, 51, 1800, 120 }, .{ 440, 68, 2400, 160 } }, + }, + .{ + .identity = "workspace-tab:0", + .present = true, + .name = "Shell tab", + .bounds = .{ .{ 0, 4, 112, 26 }, .{ 0, 4, 112, 26 }, .{ 0, 4, 112, 26 } }, + }, + .{ + .identity = "workspace-tab:1", + .present = true, + .name = "Agent tab", + .bounds = .{ .{ 120, 4, 232, 26 }, .{ 120, 4, 232, 26 }, .{ 120, 4, 232, 26 } }, + }, + .{ + .identity = "workspace-tab:2", + .present = true, + .name = "Split tab", + .bounds = .{ .{ 240, 4, 352, 26 }, .{ 240, 4, 352, 26 }, .{ 240, 4, 352, 26 } }, + }, + .{ + .identity = "workspace-restart-session:chat-b", + .present = true, + .name = "Restart session", + .bounds = .{ .{ 976, 44, 1088, 70 }, .{ 1464, 66, 1632, 105 }, .{ 1952, 88, 2176, 140 } }, + .invokable = true, + }, + .{ + .identity = "workspace-show-graph:show-graph", + .present = true, + .name = "Show in Graph", + .bounds = .{ .{ 1096, 44, 1188, 70 }, .{ 1644, 66, 1782, 105 }, .{ 2192, 88, 2376, 140 } }, + .invokable = true, + }, + .{ .identity = "workspace-loop-bar:loop-a", .present = false }, + .{ .identity = "workspace-stop:loop-a", .present = false }, + .{ .identity = "workspace-toggle-panel:control", .present = false }, + .{ .identity = "workspace-detail-sparkline:loop-a", .present = false }, + .{ .identity = "workspace-detail-start:loop-a", .present = false }, + .{ .identity = "workspace-detail-usage:loop-a", .present = false }, + .{ .identity = "workspace-toolbar:C:\\quick-chat-parity", .present = false }, + .{ .identity = "header-toggle-panel:control", .present = false }, + }; + for ([_]u32{ 96, 144, 192 }, 0..) |dpi, index| { + app.dpi = dpi; + var sink = DpiAccessibilitySink{ + .expected = &expected, + .dpi_index = index, + .client_bounds = .{ + .left = 0, + .top = 0, + .right = physicalCoordinate(1200, dpi), + .bottom = physicalCoordinate(900, dpi), + }, + }; + app.syncAccessibilityTo(&sink, .{ .left = 0, .top = 0, .right = 1200, .bottom = 900 }); + try std.testing.expect(sink.checked); + if (sink.failure) |err| return err; + } + const restart_payload = Accessibility.worktreeIdentityPayload("workspace-restart-session:chat-b"); + try std.testing.expect(app.applyUiaDynamicInvoke(restart_payload)); + try std.testing.expectEqual(@as(usize, 0), app.client.outbound_count); + try std.testing.expectEqual(GraphCanvas.Surface.workspace, app.surface); + try std.testing.expect(app.applyUiaDynamicInvoke(Accessibility.worktreeIdentityPayload("workspace-show-graph:show-graph"))); + try std.testing.expectEqual(GraphCanvas.Surface.quick_chats, app.surface); + try std.testing.expect(!app.toggleWorkspacePanelState()); + app.toggleWorkspaceDetailPanelState(); + try std.testing.expectEqual(GraphCanvas.Surface.quick_chats, app.surface); + try std.testing.expect(!app.workspace_controls.panel_visible); + + _ = try app.model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"graph","project":{"path":"C:\\quick-chat-project","name":"Project"},"nodes":[{"id":"loop","title":"Loop","state":"running"}],"edges":[]}}} + ); + app.surface = .workspace; + app.showInGraph(App.finishShowGraphNative, App.publishAccessibility); + try std.testing.expectEqual(GraphCanvas.Surface.project, app.surface); + try std.testing.expect(app.hasQuickChatWorkspaceScope()); + try std.testing.expect(!app.toggleWorkspacePanelState()); + app.toggleWorkspaceDetailPanelState(); + try std.testing.expectEqual(GraphCanvas.Surface.project, app.surface); + try std.testing.expect(!app.workspace_controls.panel_visible); + app.setStatus("Show in Graph remains the current status"); + app.handleAction(.toggle_panel); + try std.testing.expectEqualStrings("Show in Graph remains the current status", app.status_override); + try std.testing.expectEqual(GraphCanvas.Surface.project, app.surface); + try std.testing.expect(!app.workspace_controls.panel_visible); +} + +test "Quick Chat workspace routes away when its bound chat is deleted" { + const allocator = std.testing.allocator; + var app = try overviewTestApp(Dpi.base_dpi); + defer deinitOverviewTestApp(&app); + try app.model.quick_chats.append(.{ + .id = try allocator.dupe(u8, "chat-b"), + .title = try allocator.dupe(u8, "Chat B"), + .backend = try allocator.dupe(u8, "copilot"), + }); + var temporary = std.testing.tmpDir(.{}); + defer temporary.cleanup(); + const directory = try temporary.dir.realpathAlloc(allocator, "."); + defer allocator.free(directory); + const layout_path = try std.fs.path.join(allocator, &.{ directory, "layout.json" }); + defer allocator.free(layout_path); + var workspace = try reopenTestWorkspace(allocator, layout_path); + defer workspace.deinit(); + try workspace.scopeToLoop("chat-b", TerminalWorkspace.quick_chat_scope_project, false); + app.workspace = &workspace; + defer app.workspace = null; + app.surface = .workspace; + try std.testing.expectEqualStrings("chat-b", workspace.boundQuickChatID().?); + + const deleted_frame = + \\{"version":2,"kind":"event","sequence":1,"event":{"quickChatDeleted":"chat-b"}} + ; + app.onFrameWithAccessibilityPublish(deleted_frame, App.publishAccessibility); + + try std.testing.expectEqual(GraphCanvas.Surface.quick_chats, app.surface); + try std.testing.expect(!app.workspace_controls.panel_visible); + try std.testing.expect(app.workspaceQuickChatID() == null); +} + +test "failed loop rescope routes away from Quick Chat workspace chrome" { + const allocator = std.testing.allocator; + var app = try overviewTestApp(Dpi.base_dpi); + defer deinitOverviewTestApp(&app); + try app.model.quick_chats.append(.{ + .id = try allocator.dupe(u8, "chat-b"), + .title = try allocator.dupe(u8, "Chat B"), + .backend = try allocator.dupe(u8, "copilot"), + }); + var temporary = std.testing.tmpDir(.{}); + defer temporary.cleanup(); + const directory = try temporary.dir.realpathAlloc(allocator, "."); + defer allocator.free(directory); + const layout_path = try std.fs.path.join(allocator, &.{ directory, "layout.json" }); + defer allocator.free(layout_path); + var workspace = try reopenTestWorkspace(allocator, layout_path); + defer workspace.deinit(); + try workspace.scopeToLoop("chat-b", TerminalWorkspace.quick_chat_scope_project, false); + try workspace.layout.addTab("chat-shell", false); + try temporary.dir.makePath("loop.json"); + app.workspace = &workspace; + defer app.workspace = null; + app.surface = .workspace; + try std.testing.expect(app.isQuickChatWorkspace()); + + app.openGraphLoop("C:\\quick-chat-project", "loop"); + + try std.testing.expectEqual(GraphCanvas.Surface.project, app.surface); + try std.testing.expect(!app.workspace_controls.panel_visible); + try std.testing.expect(workspace.boundQuickChatID() != null); + try std.testing.expect(app.workspaceQuickChatID() == null); + + const expected = [_]DpiExpectedElement{ + .{ .identity = "workspace-quick-chat-bar:chat-b", .present = false }, + .{ .identity = "workspace-restart-session:chat-b", .present = false }, + .{ .identity = "workspace-tab:0", .present = false }, + }; + var sink = DpiAccessibilitySink{ .expected = &expected, .dpi_index = 0 }; + app.syncAccessibilityTo(&sink, .{ .left = 0, .top = 0, .right = 1200, .bottom = 900 }); + try std.testing.expect(sink.checked); + if (sink.failure) |err| return err; +} + +test "missing bound Quick Chat does not suppress remaining workspace UIA" { + const allocator = std.testing.allocator; + var app = try overviewTestApp(Dpi.base_dpi); + defer deinitOverviewTestApp(&app); + var temporary = std.testing.tmpDir(.{}); + defer temporary.cleanup(); + const directory = try temporary.dir.realpathAlloc(allocator, "."); + defer allocator.free(directory); + const layout_path = try std.fs.path.join(allocator, &.{ directory, "layout.json" }); + defer allocator.free(layout_path); + var workspace = try reopenTestWorkspace(allocator, layout_path); + defer workspace.deinit(); + try workspace.scopeToLoop("deleted-chat", TerminalWorkspace.quick_chat_scope_project, false); + try workspace.layout.addTab("chat-shell", false); + app.workspace = &workspace; + defer app.workspace = null; + app.surface = .workspace; + try std.testing.expectEqualStrings("deleted-chat", app.workspaceQuickChatID().?); + + const expected = [_]DpiExpectedElement{ + .{ .identity = "workspace-quick-chat-bar:deleted-chat", .present = false }, + .{ .identity = "workspace-restart-session:deleted-chat", .present = false }, + .{ .identity = "workspace-show-graph:show-graph", .present = false }, + .{ .identity = "workspace-tab:0", .present = true, .name = "Shell tab" }, + }; + var sink = DpiAccessibilitySink{ .expected = &expected, .dpi_index = 0 }; + app.syncAccessibilityTo(&sink, .{ .left = 0, .top = 0, .right = 1200, .bottom = 900 }); + try std.testing.expect(sink.checked); + if (sink.failure) |err| return err; } test "header UIA identities hash to distinct payloads" { diff --git a/graphcode-windows/src/GraphModel.zig b/graphcode-windows/src/GraphModel.zig index 5a41f071..211550f2 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,15 @@ 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(); 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 (fields.has("createdAt")) { + existing.created_at = unixSecondsFromReferenceDate(fields.get("createdAt")); + } self.allocator.free(id); return; } @@ -963,6 +969,7 @@ pub const Model = struct { .id = id, .title = try duplicateJsonString(self.allocator, object, "title"), .backend = try duplicateJsonStringOr(self.allocator, object, "backend", "claudeCode"), + .created_at = unixSecondsFromReferenceDate(fields.get("createdAt")), }; if (std.mem.indexOf(u8, object, "\"activity\"")) |activity_key| { if (indexOfByte(object, activity_key, '{')) |activity_open| { @@ -2895,6 +2902,44 @@ 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 Chat parity creation date survives list changed rename and reorder" { + const allocator = std.testing.allocator; + var model = Model.init(allocator); + defer model.deinit(); + + _ = try model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":1,"event":{"quickChatsListed":[{"id":"chat-a","title":"A","backend":"claudeCode"},{"id":"chat-b","title":"Chat — Oct 14","backend":"copilot","createdAt":812912680.0330639}]}} + ); + try std.testing.expectEqual(@as(usize, 2), model.quick_chats.items.len); + try std.testing.expectEqual(@as(?u64, 1_791_219_880), model.quick_chats.items[1].created_at); + + _ = try model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":2,"event":{"quickChatChanged":{"id":"chat-b","title":"Renamed chat","backend":"copilot"}}} + ); + try std.testing.expectEqualStrings("Renamed chat", model.quick_chats.items[1].title); + try std.testing.expectEqual(@as(?u64, 1_791_219_880), model.quick_chats.items[1].created_at); + + _ = try model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":3,"event":{"quickChatsListed":[{"id":"chat-b","title":"Renamed chat","backend":"copilot","createdAt":812912680.0330639},{"id":"chat-a","title":"A","backend":"claudeCode"}]}} + ); + try std.testing.expectEqualStrings("chat-b", model.quick_chats.items[0].id); + try std.testing.expectEqual(@as(?u64, 1_791_219_880), model.quick_chats.items[0].created_at); +} + +test "Quick Chat parity creation date rejects nested and invalid values" { + const allocator = std.testing.allocator; + var model = Model.init(allocator); + defer model.deinit(); + + _ = try model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":1,"event":{"quickChatsListed":[{"id":"valid","title":"Valid","createdAt":812912680.0330639},{"id":"nested","title":"Nested","activity":{"createdAt":812912680.0330639}},{"id":"missing","title":"Missing"},{"id":"null","title":"Null","createdAt":null},{"id":"zero","title":"Zero","createdAt":0},{"id":"negative","title":"Negative","createdAt":-978307200},{"id":"overflow","title":"Overflow","createdAt":1e300},{"id":"string","title":"String","createdAt":"812912680"}]}} + ); + try std.testing.expectEqual(@as(?u64, 1_791_219_880), model.quick_chats.items[0].created_at); + for (model.quick_chats.items[1..]) |chat| { + try std.testing.expectEqual(@as(?u64, null), chat.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/LoopBarLayout.zig b/graphcode-windows/src/LoopBarLayout.zig index 50614e13..e12140d4 100644 --- a/graphcode-windows/src/LoopBarLayout.zig +++ b/graphcode-windows/src/LoopBarLayout.zig @@ -25,6 +25,25 @@ pub const Rect = struct { }; pub const Action = enum { stop, show_graph }; +pub const QuickChatAction = enum { restart_session, show_graph }; + +pub const QuickChatLayout = struct { + bar: Rect, + stripe: ?Rect, + title: ?Rect, + state: ?Rect, + subtitle: ?Rect, + age: ?Rect, + restart_session: Rect, + show_graph: Rect, + + pub fn actionAt(self: QuickChatLayout, x: i32, y: i32) ?QuickChatAction { + if (!self.bar.contains(x, y)) return null; + if (self.restart_session.contains(x, y)) return .restart_session; + if (self.show_graph.contains(x, y)) return .show_graph; + return null; + } +}; // Every text run in the bar is drawn into a fixed 220px-wide rect when it has room. pub const natural_text_width: i32 = 220; @@ -92,6 +111,44 @@ pub fn compute(left: i32, top: i32, right: i32, resolved: bool, panel_toggle: bo }; } +pub fn computeQuickChat(left: i32, top: i32, right: i32) QuickChatLayout { + const show_graph = Rect{ + .left = right - trailing_inset - show_graph_width, + .top = top + 10, + .right = right - trailing_inset, + .bottom = top + 36, + }; + const restart_session = Rect{ + .left = show_graph.left - button_gap - 112, + .top = top + 10, + .right = show_graph.left - button_gap, + .bottom = top + 36, + }; + const text_limit = restart_session.left - button_gap; + const state_rect = Rect{ + .left = left + 255, + .top = top + 7, + .right = left + 303, + .bottom = top + 27, + }; + const show_state = state_rect.right <= text_limit; + 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 = textRect(left + 27, top + 5, 13, if (show_state) state_rect.left - button_gap else text_limit), + .state = if (show_state) state_rect else null, + .subtitle = textRect(left + 27, top + 24, 10, @min(left + 155, text_limit)), + .age = textRect(left + 163, top + 24, 10, text_limit), + .restart_session = restart_session, + .show_graph = show_graph, + }; +} + fn expectClearOfButtons(layout: Layout) !void { const runs = [_]?Rect{ layout.stripe, layout.title, layout.state, layout.activity, layout.detail }; for (runs) |maybe_run| { @@ -204,3 +261,50 @@ test "loop bar actions hit exactly the button rects" { try std.testing.expect(layout.actionAt(layout.stop.?.right, 50) == null); try std.testing.expect(compute(220, 34, 1200, true, false).actionAt(1010, 50) == null); } + +test "Quick Chat parity bar geometry has restart and no panel slot" { + const top = Tokens.header_height; + for ([_]i32{ 1440, 1200, 960, 480 }) |client_right| { + for ([_]bool{ false, true }) |sidebar_visible| { + const left = if (sidebar_visible) Tokens.sidebar_width else 0; + const layout = computeQuickChat(left, top, client_right); + try std.testing.expectEqual(left, layout.bar.left); + try std.testing.expectEqual(client_right, layout.bar.right); + try std.testing.expect(layout.restart_session.width() >= 112); + try std.testing.expect(layout.restart_session.left >= left); + try std.testing.expect(layout.restart_session.right <= client_right); + try std.testing.expect(layout.show_graph.width() > 0); + try std.testing.expect(layout.show_graph.left >= left); + try std.testing.expect(layout.show_graph.right <= client_right); + try std.testing.expect(!layout.restart_session.intersects(layout.show_graph)); + if (layout.title) |title| { + try std.testing.expect(title.left >= left); + try std.testing.expect(title.right <= layout.bar.right); + if (layout.state) |state| try std.testing.expect(!title.intersects(state)); + try std.testing.expect(!title.intersects(layout.restart_session)); + try std.testing.expect(!title.intersects(layout.show_graph)); + } + if (layout.state) |state| { + try std.testing.expect(!state.intersects(layout.restart_session)); + try std.testing.expect(!state.intersects(layout.show_graph)); + } + if (layout.subtitle) |subtitle| { + try std.testing.expect(!subtitle.intersects(layout.restart_session)); + try std.testing.expect(!subtitle.intersects(layout.show_graph)); + if (layout.age) |age| try std.testing.expect(!subtitle.intersects(age)); + } + try std.testing.expectEqual( + QuickChatAction.restart_session, + layout.actionAt(layout.restart_session.left + 1, top + 20).?, + ); + try std.testing.expectEqual( + QuickChatAction.show_graph, + layout.actionAt(layout.show_graph.left + 1, top + 20).?, + ); + try std.testing.expect(layout.actionAt(left -| 1, top + 20) == null); + try std.testing.expect(layout.actionAt(client_right, top + 20) == null); + try std.testing.expect(layout.actionAt(layout.restart_session.left - 1, top + 20) == null); + try std.testing.expect(layout.actionAt(layout.show_graph.left - 1, top + 20) == null); + } + } +} diff --git a/graphcode-windows/src/TerminalSurface.zig b/graphcode-windows/src/TerminalSurface.zig index 455465c3..d270c4b2 100644 --- a/graphcode-windows/src/TerminalSurface.zig +++ b/graphcode-windows/src/TerminalSurface.zig @@ -165,6 +165,31 @@ const max_surfaces: usize = 32; pub const ChromeAction = enum { new_tab, split_right, split_down }; pub const TabAction = enum { select, close }; pub const LoopBarAction = LoopBarLayout.Action; +pub const QuickChatBarAction = LoopBarLayout.QuickChatAction; + +pub const QuickChatBarPresentation = struct { + title: []const u8, + state: []const u8 = "IDLE", + subtitle: []const u8 = "Not piloted", + age: ?[]const u8, + accent: u32 = 0x00E98590, +}; + +pub fn quickChatBarPresentation( + title: []const u8, + created_at: ?u64, + now_seconds: u64, + age_buffer: []u8, +) QuickChatBarPresentation { + return .{ + .title = title, + .age = quickChatAgeLabel(created_at, now_seconds, age_buffer), + }; +} + +pub fn quickChatBarLayout(left: i32, right: i32) LoopBarLayout.QuickChatLayout { + return LoopBarLayout.computeQuickChat(left, Tokens.header_height, right); +} 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); @@ -865,6 +890,11 @@ pub const Workspace = struct { self.restorePersistedSurfaces(loop, 1); } + pub fn boundQuickChatID(self: *const Workspace) ?[]const u8 { + if (!std.mem.eql(u8, self.layout.project_key, quick_chat_scope_project) or self.loop_id.len == 0) return null; + return self.loop_id; + } + /// 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 { @@ -2076,7 +2106,7 @@ pub const Workspace = struct { // unintentional blue that did not correspond to any Theme.swift value. fillRect(hdc, bounds, if (index == self.layout.selected_tab) Tokens.tab_selected_background else 0x00262626); fillRect(hdc, .{ .left = bounds.left + 8, .top = bounds.top + 9, .right = bounds.left + 14, .bottom = bounds.top + 15 }, tabIndicatorColor(self, tab)); - drawUtf8(hdc, tabLabel(tab, index), bounds.left + 19, bounds.top + 4, 10, 0x00E6E6E6); + drawUtf8(hdc, tabLabel(tab), bounds.left + 19, bounds.top + 4, 10, 0x00E6E6E6); var shortcut: [16]u8 = undefined; const shortcut_text = std.fmt.bufPrint(&shortcut, "Ctrl+{d}", .{index + 1}) catch ""; drawUtf8(hdc, shortcut_text, bounds.left + 19, bounds.top + 14, 8, 0x008A8A8A); @@ -2171,6 +2201,51 @@ pub const Workspace = struct { _ = allocator; } + pub fn paintQuickChatBar( + hdc: c.HDC, + left: i32, + right: i32, + presentation: QuickChatBarPresentation, + ) void { + const top = Tokens.header_height; + const layout = quickChatBarLayout(left, right); + GdiGradient.fillVertical( + hdc, + .{ .left = left, .top = top, .right = right, .bottom = top + Tokens.loop_bar_height }, + Tokens.loop_bar_top, + Tokens.loop_bar_bottom, + ); + if (layout.stripe) |stripe| { + fillRect(hdc, .{ .left = stripe.left, .top = stripe.top, .right = stripe.right, .bottom = stripe.bottom }, presentation.accent); + } + if (layout.title) |bounds| drawUtf8Bounded(hdc, presentation.title, bounds, 13, 0x00F2F2F7); + if (layout.state) |bounds| { + fillRect(hdc, .{ .left = bounds.left, .top = bounds.top, .right = bounds.right, .bottom = bounds.bottom }, 0x00303035); + drawUtf8Bounded(hdc, presentation.state, bounds, 10, 0x00D8D8DC); + } + if (layout.subtitle) |bounds| drawUtf8Bounded(hdc, presentation.subtitle, bounds, 10, 0x008E8E93); + if (presentation.age) |age| { + if (layout.age) |bounds| drawUtf8Bounded(hdc, age, bounds, 10, 0x008E8E93); + } + fillRect( + hdc, + .{ + .left = layout.restart_session.left, + .top = layout.restart_session.top, + .right = layout.restart_session.right, + .bottom = layout.restart_session.bottom, + }, + 0x00303035, + ); + drawUtf8Bounded(hdc, "Restart session", layout.restart_session, 10, 0x00D8D8DC); + drawUtf8Bounded(hdc, "Show in graph", layout.show_graph, 10, 0x008E8E93); + 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, @@ -7138,9 +7213,9 @@ fn drawUtf8Bounded(hdc: c.HDC, text: []const u8, bounds: LoopBarLayout.Rect, siz _ = c.SelectObject(hdc, old_font); } -fn tabLabel(tab: WorkspaceLayout.Tab, index: usize) []const u8 { +fn tabLabel(tab: WorkspaceLayout.Tab) []const u8 { if (tab.panes.items.len > 1) return "split"; - if (index == 0) return "agent"; + if (tab.panes.items.len != 0 and tab.panes.items[0].launches_agent) return "agent"; return "shell"; } @@ -7166,6 +7241,23 @@ fn elapsedLabel(created_at: u64) []const u8 { return "elapsed >1h"; } +fn quickChatAgeLabel(created_at: ?u64, now_seconds: u64, age_buffer: []u8) ?[]const u8 { + const created = created_at orelse return null; + const elapsed = now_seconds -| created; + if (elapsed < 60) return std.fmt.bufPrint(age_buffer, "{d}s", .{elapsed}) catch unreachable; + if (elapsed < 3600) return std.fmt.bufPrint(age_buffer, "{d}m", .{elapsed / 60}) catch unreachable; + if (elapsed < 86_400) { + return std.fmt.bufPrint(age_buffer, "{d}h {d}m", .{ + elapsed / 3600, + (elapsed % 3600) / 60, + }) catch unreachable; + } + return std.fmt.bufPrint(age_buffer, "{d}d {d}h", .{ + elapsed / 86_400, + (elapsed % 86_400) / 3600, + }) catch unreachable; +} + fn loopTypeAccent(loop_type: []const u8) u32 { if (std.mem.eql(u8, loop_type, "goalBased")) return 0x0048C78E; if (std.mem.eql(u8, loop_type, "timeBased")) return 0x00D6A649; @@ -7589,6 +7681,65 @@ 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 parity presentation has chat-only identity and age" { + var age_buffer: [32]u8 = undefined; + const now: u64 = 1_800_000_000; + const presentation = quickChatBarPresentation("Chat — Oct 14", now - 59, now, &age_buffer); + try std.testing.expectEqualStrings("Chat — Oct 14", presentation.title); + try std.testing.expectEqualStrings("IDLE", presentation.state); + try std.testing.expectEqualStrings("Not piloted", presentation.subtitle); + try std.testing.expectEqual(@as(u32, 0x00E98590), presentation.accent); + try std.testing.expectEqualStrings("59s", presentation.age.?); + + for ([_]struct { age: u64, expected: []const u8 }{ + .{ .age = 0, .expected = "0s" }, + .{ .age = 59, .expected = "59s" }, + .{ .age = 60, .expected = "1m" }, + .{ .age = 3660, .expected = "1h 1m" }, + .{ .age = 90000, .expected = "1d 1h" }, + .{ .age = 1, .expected = "0s" }, + }) |case| { + const formatted = quickChatBarPresentation( + "Renamed chat", + if (case.age == 1) now + 1 else now - case.age, + now, + &age_buffer, + ); + try std.testing.expectEqualStrings(case.expected, formatted.age.?); + } + const unknown = quickChatBarPresentation("Chat", null, now, &age_buffer); + try std.testing.expect(unknown.age == null); +} + +test "Quick Chat tab labels preserve saved pane role metadata" { + const allocator = std.testing.allocator; + var workspace = Workspace{ + .parent = null, + .allocator = allocator, + .zmx_path = &.{}, + .cwd = &.{}, + .input_queue = .{ .allocator = allocator }, + .layout = try WorkspaceLayout.Layout.initForLoop( + allocator, + quick_chat_scope_project, + "chat-1", + ), + .layout_path = &.{}, + .project_key = &.{}, + .loop_id = try allocator.dupe(u8, "chat-1"), + }; + defer workspace.layout.deinit(); + defer allocator.free(workspace.loop_id); + try workspace.layout.addTab("chat-shell", false); + try workspace.layout.addTab("chat-agent", true); + try workspace.layout.addTab("chat-split-agent", true); + try workspace.layout.splitFocused(.horizontal, "chat-split-shell"); + + try std.testing.expectEqualStrings("shell", tabLabel(workspace.layout.tabs.items[0])); + try std.testing.expectEqualStrings("agent", tabLabel(workspace.layout.tabs.items[1])); + try std.testing.expectEqualStrings("split", tabLabel(workspace.layout.tabs.items[2])); +} + const AttachOutputProbe = struct { input: []const u8, output: []u8,