diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index a32ec78a..b46670fd 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -1299,6 +1299,48 @@ fn quickChatByID(model: *const GraphModel.Model, id: []const u8) ?*const GraphMo return null; } +const QuickChatCreationRequest = struct { + title: []const u8, + backend: []const u8, +}; + +const PendingQuickChatCreate = union(enum) { + v1, + v2: [36]u8, +}; + +fn quickChatCreationRequest( + title_buffer: []u8, + local_time: c.SYSTEMTIME, + configured_backend: []const u8, +) QuickChatCreationRequest { + const months = [_][]const u8{ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + }; + const chat_title = if (local_time.wMonth >= 1 and local_time.wMonth <= months.len and local_time.wDay >= 1) + std.fmt.bufPrint( + title_buffer, + "Chat — {s} {d}", + .{ months[local_time.wMonth - 1], local_time.wDay }, + ) catch "Chat" + else + "Chat"; + return .{ + .title = chat_title, + .backend = if (configured_backend.len == 0) "claudeCode" else configured_backend, + }; +} + +fn bindQuickChatWorkspace( + workspace: *TerminalWorkspace.Workspace, + chat: GraphModel.QuickChat, +) !void { + try workspace.scopeToLoop(chat.id, TerminalWorkspace.quick_chat_scope_project, true); + try workspace.setTabPresentation("composite", chat.backend, "idle"); + try workspace.openLaunchedNode(0, chat.id, TerminalWorkspace.Workspace.loop_open_timeout_ms); +} + 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| { @@ -1466,6 +1508,8 @@ pub const App = struct { header_focus_transition: bool = false, canvas_layout_store: ?CanvasLayoutStore.Store = null, quick_chats_requested: bool = false, + pending_quick_chat_create: ?PendingQuickChatCreate = null, + completed_quick_chat_create_request: ?[36]u8 = null, selected_quick_chat: ?usize = null, workspace_reservation: WorkspaceReservation = .{}, workspace_summary_work: WorkspaceManager.SummaryWork = .{}, @@ -1622,6 +1666,8 @@ pub const App = struct { self.sync_requested = false; self.restore_requested = false; self.quick_chats_requested = false; + self.pending_quick_chat_create = null; + self.completed_quick_chat_create_request = null; } fn sendPendingOpen(self: *App) void { @@ -2059,7 +2105,7 @@ pub const App = struct { }, .quick_chats, .quick_chat_changed, .quick_chat_deleted, .quick_chat_activity => { if (event == .quick_chat_changed) { - if (Wire.jsonString(frame, "id")) |id| self.openQuickChat(id); + if (Wire.jsonString(frame, "id")) |id| self.handleQuickChatChanged(frame, id); } var deleted_bound_chat = false; if (event == .quick_chat_deleted and !self.smoke) { @@ -2079,6 +2125,7 @@ pub const App = struct { _ = c.InvalidateRect(self.window.hwnd, null, 0); }, .error_occurred => { + self.clearFailedQuickChatCreate(frame); self.failSessionRestart("Daemon refused the session restart"); const ingress_failure = self.pending_rebind_path.len != 0; if (self.pending_rebind_path.len != 0) { @@ -3124,10 +3171,70 @@ pub const App = struct { } fn createQuickChat(self: *App) void { - self.client.sendCreateQuickChat("Chat", "claudeCode"); + var title_buffer: [64]u8 = undefined; + var local_time: c.SYSTEMTIME = undefined; + c.GetLocalTime(&local_time); + const request = quickChatCreationRequest( + &title_buffer, + local_time, + if (self.product_settings) |settings| settings.default_backend else "claudeCode", + ); + const request_id = self.client.sendCreateQuickChat(request.title, request.backend) orelse { + self.setStatus("Unable to create quick chat"); + return; + }; + self.pending_quick_chat_create = if (self.client.protocolMode() == .v1) + .v1 + else + .{ .v2 = request_id }; + self.completed_quick_chat_create_request = null; self.setStatus("Creating quick chat..."); } + fn handleQuickChatChanged(self: *App, frame: []const u8, id: []const u8) void { + if (Wire.responseRequestID(frame)) |response_id| { + if (self.completed_quick_chat_create_request) |completed| { + if (std.ascii.eqlIgnoreCase(response_id, &completed)) return; + } + } + if (self.pending_quick_chat_create) |pending| { + switch (pending) { + .v1 => { + self.pending_quick_chat_create = null; + self.client.sendOpenQuickChat(id); + self.setStatus("Opening quick chat..."); + return; + }, + .v2 => |request_id| { + if (Wire.responseRequestID(frame)) |response_id| { + if (std.ascii.eqlIgnoreCase(response_id, &request_id)) { + self.pending_quick_chat_create = null; + self.completed_quick_chat_create_request = request_id; + self.client.sendOpenQuickChat(id); + self.setStatus("Opening quick chat..."); + return; + } + } + return; + }, + } + } + self.openQuickChat(id); + } + + fn clearFailedQuickChatCreate(self: *App, frame: []const u8) void { + const pending = self.pending_quick_chat_create orelse return; + switch (pending) { + .v1 => self.pending_quick_chat_create = null, + .v2 => |request_id| { + const response_id = Wire.responseRequestID(frame) orelse return; + if (!std.ascii.eqlIgnoreCase(response_id, &request_id)) return; + self.pending_quick_chat_create = null; + self.completed_quick_chat_create_request = request_id; + }, + } + } + fn renameSelectedQuickChat(self: *App) void { const index = self.selected_quick_chat orelse return; if (index >= self.model.quick_chats.items.len) return; @@ -3182,7 +3289,7 @@ pub const App = struct { defer self.allocator.free(home); _ = workspace.rebindProject(home) catch {}; } else |_| {} - workspace.scopeToLoop(chat.id, TerminalWorkspace.quick_chat_scope_project, false) catch { + bindQuickChatWorkspace(workspace, chat) catch { self.setStatus("Unable to open quick chat workspace"); self.routeFromFailedQuickChatOpen(); return; @@ -3197,7 +3304,6 @@ pub const App = struct { self.routeFromFailedQuickChatOpen(); return; } - workspace.openNode(0, chat.id) catch self.setStatus("Unable to open quick chat workspace"); self.syncAccessibility(); return; } @@ -7590,8 +7696,8 @@ pub const App = struct { const show_primary = is_quick_chats or is_overview or (self.surface == .project and graph != null and !is_global); const add_bounds = GraphCanvas.addButtonBounds(.{ .left = bounds.left, .top = bounds.top, .right = bounds.right, .bottom = bounds.bottom }); - const primary_x = if (is_empty) content_left + @divTrunc(content_right - content_left - 118, 2) else add_bounds.left; - const primary_y = if (is_empty) + const primary_x = if (is_empty and !is_quick_chats) content_left + @divTrunc(content_right - content_left - 118, 2) else add_bounds.left; + const primary_y = if (is_empty and !is_quick_chats) y + (if (is_global or is_overview) @as(i32, 42) else @as(i32, 0)) else add_bounds.top; @@ -8092,6 +8198,19 @@ pub const App = struct { const rail = GraphCanvas.attentionRailLayout(canvas_rect); self.appendAccessibilityElement(&elements, &owned_identities, "canvas-attention-review", "needs-you", "Review oldest attention loop (Ctrl+Shift+R)", 4, .{ .logical = rail.review }, false, false) catch return; } + if (self.surface == .quick_chats and self.model.quick_chats.items.len == 0) { + self.appendAccessibilityElement( + &elements, + &owned_identities, + "quick-chat-new", + "empty-canvas", + "New Chat", + 4, + .{ .logical = GraphCanvas.quickChatEmptyActionBounds(canvas_rect) }, + false, + true, + ) catch return; + } for (GraphCanvas.header_actions) |action| { const logical_bounds = header_layout.bounds(action) orelse continue; const bounds = (AccessibilityBounds{ .logical = logical_bounds }).physicalRect(self.dpi); @@ -8632,6 +8751,7 @@ pub const App = struct { .{ .identity = "quick-chats-header:quick-chats", .target = .quick_chats_header }, .{ .identity = "quick-chats-disclosure:quick-chats", .target = .quick_chats_disclosure }, .{ .identity = "quick-chat-new:quick-chats", .target = .new_quick_chat }, + .{ .identity = "quick-chat-new:empty-canvas", .target = .new_quick_chat }, .{ .identity = "sidebar-update-banner:update", .target = .update_banner }, .{ .identity = "needs-you-header:needs-you", .target = .needs_you_header }, .{ .identity = "activity-header:activity", .target = .activity_header }, @@ -9943,6 +10063,8 @@ fn onWindowMessage( if (updated_connection_state != app.last_connection_state) { if (updated_connection_state != .connected) { app.model.invalidateWorktreeNotices(.connection_changed); + app.pending_quick_chat_create = null; + app.completed_quick_chat_create_request = null; app.syncAccessibility(); } app.last_connection_state = updated_connection_state; @@ -10245,7 +10367,9 @@ fn onWindowMessage( _ = c.InvalidateRect(hwnd, null, 0); }, .quick_chats => { - if (GraphCanvas.hitTestQuickChat(app.model.quick_chats.items.len, x, y, &app.canvas, bounds)) |index| { + if (app.model.quick_chats.items.len == 0 and GraphCanvas.hitTestQuickChatEmptyAction(x, y, bounds)) { + app.createQuickChat(); + } else if (GraphCanvas.hitTestQuickChat(app.model.quick_chats.items.len, x, y, &app.canvas, bounds)) |index| { app.client.sendOpenQuickChat(app.model.quick_chats.items[index].id); app.setStatus("Opening quick chat..."); } else { @@ -24052,6 +24176,223 @@ test "Quick Chat parity bound identity ignores previous loop and chat list order if (sink.failure) |err| return err; } +test "Quick Chat remaining parity creation uses the local date and configured backend" { + var title_buffer: [64]u8 = undefined; + const request = quickChatCreationRequest( + &title_buffer, + .{ + .wYear = 2026, + .wMonth = 10, + .wDayOfWeek = 2, + .wDay = 20, + .wHour = 12, + .wMinute = 0, + .wSecond = 0, + .wMilliseconds = 0, + }, + "copilotCLI", + ); + try std.testing.expectEqualStrings("Chat — Oct 20", request.title); + try std.testing.expectEqualStrings("copilotCLI", request.backend); +} + +test "Quick Chat remaining parity create response requests the daemon session exactly once" { + var app = try overviewTestApp(Dpi.base_dpi); + defer deinitOverviewTestApp(&app); + app.client.mode = .v2; + app.surface = .quick_chats; + app.createQuickChat(); + const request_id = switch (app.pending_quick_chat_create orelse return error.TestExpectedQuickChatCreateRequest) { + .v1 => return error.TestExpectedV2QuickChatCreate, + .v2 => |value| value, + }; + try std.testing.expectEqual(@as(usize, 1), app.client.outbound_count); + + app.onFrameWithAccessibilityPublish( + \\{"version":2,"kind":"event","sequence":1,"event":{"quickChatChanged":{"id":"chat-new","title":"Chat — Oct 20","backend":"copilotCLI"}}} + , App.publishAccessibility); + try std.testing.expect(app.pending_quick_chat_create != null); + try std.testing.expectEqual(@as(usize, 1), app.client.outbound_count); + try std.testing.expectEqual(GraphCanvas.Surface.quick_chats, app.surface); + + app.onFrameWithAccessibilityPublish( + \\{"version":2,"kind":"response","requestID":"00000000-0000-4000-8000-000000000099","event":{"quickChatChanged":{"id":"other-chat","title":"Other","backend":"claudeCode"}}} + , App.publishAccessibility); + try std.testing.expect(app.pending_quick_chat_create != null); + try std.testing.expectEqual(@as(usize, 1), app.client.outbound_count); + try std.testing.expectEqual(GraphCanvas.Surface.quick_chats, app.surface); + + const response = try std.fmt.allocPrint( + app.allocator, + "{{\"version\":2,\"kind\":\"response\",\"requestID\":\"{s}\",\"event\":{{\"quickChatChanged\":{{\"id\":\"chat-new\",\"title\":\"Chat — Oct 20\",\"backend\":\"copilotCLI\"}}}}}}", + .{request_id}, + ); + defer app.allocator.free(response); + app.onFrameWithAccessibilityPublish(response, App.publishAccessibility); + try std.testing.expect(app.pending_quick_chat_create == null); + try std.testing.expectEqual(@as(?[36]u8, request_id), app.completed_quick_chat_create_request); + try std.testing.expectEqual(@as(usize, 2), app.client.outbound_count); + const newest = app.client.outbound[(app.client.outbound_head + app.client.outbound_count - 1) % app.client.outbound.len]; + try std.testing.expect(std.mem.indexOf(u8, newest, "\"openQuickChat\":{\"id\":\"chat-new\"}") != null); + + app.onFrameWithAccessibilityPublish(response, App.publishAccessibility); + try std.testing.expectEqual(@as(usize, 2), app.client.outbound_count); + try std.testing.expectEqual(GraphCanvas.Surface.quick_chats, app.surface); +} + +test "Quick Chat remaining parity create pending state clears on matched errors and v1 changes" { + var app = try overviewTestApp(Dpi.base_dpi); + defer deinitOverviewTestApp(&app); + app.client.mode = .v2; + app.createQuickChat(); + const request_id = switch (app.pending_quick_chat_create orelse return error.TestExpectedQuickChatCreateRequest) { + .v1 => return error.TestExpectedV2QuickChatCreate, + .v2 => |value| value, + }; + app.clearFailedQuickChatCreate( + \\{"version":2,"kind":"response","requestID":"00000000-0000-4000-8000-000000000099","event":{"errorOccurred":"unrelated"}} + ); + try std.testing.expect(app.pending_quick_chat_create != null); + const rejection = try std.fmt.allocPrint( + app.allocator, + "{{\"version\":2,\"kind\":\"response\",\"requestID\":\"{s}\",\"event\":{{\"errorOccurred\":\"create failed\"}}}}", + .{request_id}, + ); + defer app.allocator.free(rejection); + app.clearFailedQuickChatCreate(rejection); + try std.testing.expect(app.pending_quick_chat_create == null); + try std.testing.expectEqual(@as(?[36]u8, request_id), app.completed_quick_chat_create_request); + app.clearFailedQuickChatCreate(rejection); + try std.testing.expect(app.pending_quick_chat_create == null); + + var legacy = try overviewTestApp(Dpi.base_dpi); + defer deinitOverviewTestApp(&legacy); + legacy.client.mode = .v1; + legacy.surface = .quick_chats; + legacy.createQuickChat(); + switch (legacy.pending_quick_chat_create orelse return error.TestExpectedQuickChatCreateRequest) { + .v1 => {}, + .v2 => return error.TestExpectedV1QuickChatCreate, + } + legacy.onFrameWithAccessibilityPublish( + \\{"quickChatChanged":{"id":"chat-v1","title":"Chat","backend":"claudeCode"}} + , App.publishAccessibility); + try std.testing.expect(legacy.pending_quick_chat_create == null); + try std.testing.expectEqual(@as(usize, 2), legacy.client.outbound_count); + const newest = legacy.client.outbound[(legacy.client.outbound_head + legacy.client.outbound_count - 1) % legacy.client.outbound.len]; + try std.testing.expect(std.mem.indexOf(u8, newest, "\"openQuickChat\":{\"id\":\"chat-v1\"}") != null); +} + +test "Quick Chat remaining parity opens the daemon-owned chat instead of the previous loop pane" { + const allocator = std.testing.allocator; + 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.layout.addTab("previous-loop", true); + const chat = GraphModel.QuickChat{ + .id = @constCast("chat-b"), + .title = @constCast("Chat — Oct 20"), + .backend = @constCast("copilotCLI"), + }; + + bindQuickChatWorkspace(&workspace, chat) catch {}; + + try std.testing.expectEqualStrings("chat-b", workspace.boundQuickChatID().?); + try std.testing.expectEqual(@as(usize, 1), workspace.tabCount()); + const tab = workspace.layout.tabs.items[0]; + try std.testing.expectEqual(@as(usize, 1), tab.panes.items.len); + try std.testing.expectEqualStrings("chat-b", tab.panes.items[0].id); + try std.testing.expect(tab.panes.items[0].launches_agent); + try std.testing.expectEqualStrings("Copilot CLI", workspace.tabTitle(0).?); + try std.testing.expect(workspace.isAwaitingLaunch(0)); + try std.testing.expectEqualStrings("chat-b", workspace.launch_waits[0].session); +} + +test "Quick Chat remaining parity same scope repairs daemon-owned agent metadata" { + const allocator = std.testing.allocator; + 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-1", TerminalWorkspace.quick_chat_scope_project, false); + try workspace.layout.addTab("chat-1", false); + try std.testing.expect(!workspace.layout.tabs.items[0].panes.items[0].launches_agent); + + try workspace.scopeToLoop("chat-1", TerminalWorkspace.quick_chat_scope_project, true); + + try std.testing.expectEqual(@as(usize, 1), workspace.layout.tabs.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); +} + +test "Quick Chat remaining parity empty canvas exposes top right and centered New Chat actions" { + const Sink = struct { + primary: c.RECT = std.mem.zeroes(c.RECT), + centered: ?c.RECT = null, + + fn syncCanvasBounds(_: *@This(), _: c.RECT) void {} + + fn syncCanvasControls(self: *@This(), primary: c.RECT, _: []const u8, _: [4]c.RECT) void { + self.primary = primary; + } + + 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-new:empty-canvas")) { + self.centered = .{ + .left = element.left, + .top = element.top, + .right = element.right, + .bottom = element.bottom, + }; + } + } + } + }; + + var app = try overviewTestApp(Dpi.base_dpi); + defer deinitOverviewTestApp(&app); + try std.testing.expect(c.MoveWindow(app.window.hwnd, 0, 0, 1200, 700, 0) != 0); + app.surface = .quick_chats; + app.createEmptyStateControls(); + defer { + _ = c.DestroyWindow(app.empty_open_folder_button); + _ = c.DestroyWindow(app.empty_global_overview_button); + } + app.layoutEmptyStateControls(); + + var sink = Sink{}; + app.syncAccessibilityTo(&sink, .{ .left = 0, .top = 0, .right = 1200, .bottom = 700 }); + const canvas = GraphCanvas.renderBounds(1200, 700, app.workspace_controls); + const top_right = (AccessibilityBounds{ .logical = GraphCanvas.addButtonBounds(.{ + .left = canvas.left, + .top = canvas.top, + .right = canvas.right, + .bottom = canvas.bottom, + }) }).physicalRect(app.dpi); + const primary = emptyStateButton(&app, app.empty_global_overview_button); + try std.testing.expect(primary.visible); + try std.testing.expectEqualDeep(top_right, primary.rect); + const centered = sink.centered orelse return error.TestExpectedQuickChatEmptyAction; + try std.testing.expect(centered.left < centered.right and centered.top < centered.bottom); + try std.testing.expect(centered.top > primary.rect.bottom); + var before = app.client.outbound_count; + try std.testing.expect(app.applyUiaDynamicInvoke(Accessibility.worktreeIdentityPayload("quick-chat-new:empty-canvas"))); + try std.testing.expectEqual(before + 1, app.client.outbound_count); + before = app.client.outbound_count; + try nativeClick(&app, @divTrunc(centered.left + centered.right, 2), @divTrunc(centered.top + centered.bottom, 2)); + try std.testing.expectEqual(before + 1, app.client.outbound_count); +} + 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); @@ -24299,6 +24640,7 @@ test "header UIA identities hash to distinct payloads" { "header-toggle-panel:control", "needs-you-header:needs-you", "activity-header:activity", + "quick-chat-new:empty-canvas", }; for (identities, 0..) |lhs, i| { for (identities[i + 1 ..]) |rhs| { diff --git a/graphcode-windows/src/DaemonClient.zig b/graphcode-windows/src/DaemonClient.zig index 31ed44e7..a76ef55a 100644 --- a/graphcode-windows/src/DaemonClient.zig +++ b/graphcode-windows/src/DaemonClient.zig @@ -510,9 +510,13 @@ pub const DaemonClient = struct { self.sendCommand(command); } - pub fn sendCreateQuickChat(self: *DaemonClient, title: []const u8, backend: []const u8) void { - const command = Wire.commandCreateQuickChat(self.allocator, title, backend) catch return; - self.sendCommand(command); + pub fn sendCreateQuickChat(self: *DaemonClient, title: []const u8, backend: []const u8) ?[36]u8 { + if (self.protocolMode() == .v1 and !self.v1OpenReady()) return null; + const command = Wire.commandCreateQuickChat(self.allocator, title, backend) catch return null; + var request_id: [36]u8 = undefined; + _ = self.allocateRequestID(&request_id); + if (!self.sendCommandWithRequestID(command, request_id)) return null; + return request_id; } pub fn sendOpenQuickChat(self: *DaemonClient, id: []const u8) void { @@ -1419,6 +1423,24 @@ test "open project returns the request ID used by the v2 envelope" { try std.testing.expectEqualStrings(&request_id, Wire.responseRequestID(frame).?); } +test "Quick Chat remaining parity create exposes its request id without changing command bytes" { + var client = try DaemonClient.initForTesting(std.testing.allocator); + defer client.deinit(); + const request_id = client.sendCreateQuickChat("Chat — Oct 20", "copilotCLI") orelse + return error.TestExpectedQuickChatCreateRequest; + + try std.testing.expectEqual(@as(usize, 1), client.outbound_count); + try std.testing.expectEqualStrings( + "{\"createQuickChat\":{\"title\":\"Chat — Oct 20\",\"backend\":\"copilotCLI\"}}", + client.outbound[client.outbound_head], + ); + try std.testing.expectEqualSlices( + u8, + &request_id, + &client.outbound_request_ids[client.outbound_head].?, + ); +} + const ReadCapture = struct { var frames: [8][512]u8 = undefined; var lengths: [8]usize = [_]usize{0} ** 8; diff --git a/graphcode-windows/src/GraphCanvas.zig b/graphcode-windows/src/GraphCanvas.zig index 687bf911..cc5f36bc 100644 --- a/graphcode-windows/src/GraphCanvas.zig +++ b/graphcode-windows/src/GraphCanvas.zig @@ -1089,9 +1089,7 @@ fn drawQuickChats( state: *const CanvasState, ) void { if (model.quick_chats.items.len == 0) { - const center_y = bounds.top + @divTrunc(bounds.bottom - bounds.top, 2) - 60; - drawTextRect(hdc, allocator, "No chats yet", rect(bounds.left + 40, center_y, bounds.right - 40, center_y + 34), 20, 0x00F2F2F2, c.DT_CENTER | c.DT_SINGLELINE); - drawTextRect(hdc, allocator, "A quick chat is a bare session for questions that are not a loop's work.", rect(bounds.left + 100, center_y + 40, bounds.right - 100, center_y + 86), 13, 0x00A8A8AE, c.DT_CENTER | c.DT_WORDBREAK); + paintQuickChatEmptyState(hdc, allocator, bounds); return; } const rows = (model.quick_chats.items.len + 2) / 3; @@ -1106,6 +1104,68 @@ fn drawQuickChats( } } +const quick_chat_empty_message = "A quick chat is a bare session with no goal, no trigger, and no place in any graph — for the questions that aren't a loop's work."; + +const QuickChatEmptyLayout = struct { + glyph: c.RECT, + title: c.RECT, + message: c.RECT, + action: c.RECT, +}; + +fn quickChatEmptyLayout(bounds: c.RECT) QuickChatEmptyLayout { + const center_x = bounds.left + @divTrunc(bounds.right - bounds.left, 2); + const center_y = bounds.top + @divTrunc(bounds.bottom - bounds.top, 2) - 60; + return .{ + .glyph = rect(center_x - 28, center_y - 58, center_x + 28, center_y - 12), + .title = rect(bounds.left + 40, center_y, bounds.right - 40, center_y + 34), + .message = rect(bounds.left + 100, center_y + 40, bounds.right - 100, center_y + 86), + .action = rect(center_x - 59, center_y + 106, center_x + 59, center_y + 136), + }; +} + +pub fn quickChatEmptyActionBounds(bounds: c.RECT) c.RECT { + return quickChatEmptyLayout(bounds).action; +} + +pub fn hitTestQuickChatEmptyAction(x: i32, y: i32, bounds: c.RECT) bool { + return insideGraph(x, y, quickChatEmptyActionBounds(bounds)); +} + +fn paintQuickChatEmptyState(hdc: c.HDC, allocator: std.mem.Allocator, bounds: c.RECT) void { + const layout = quickChatEmptyLayout(bounds); + drawQuickChatEmptyGlyph(hdc, layout.glyph); + drawTextRect(hdc, allocator, "No chats yet", layout.title, 20, 0x00F2F2F2, c.DT_CENTER | c.DT_SINGLELINE); + drawTextRect(hdc, allocator, quick_chat_empty_message, layout.message, 13, 0x00A8A8AE, c.DT_CENTER | c.DT_WORDBREAK); + paintQuickChatEmptyAction(hdc, allocator, layout.action); +} + +fn drawQuickChatEmptyGlyph(hdc: c.HDC, bounds: c.RECT) void { + const pen = c.CreatePen(c.PS_SOLID, 2, 0x00919090) orelse return; + defer _ = c.DeleteObject(pen); + const old_pen = c.SelectObject(hdc, pen); + defer _ = c.SelectObject(hdc, old_pen); + const old_brush = c.SelectObject(hdc, c.GetStockObject(c.HOLLOW_BRUSH)); + defer _ = c.SelectObject(hdc, old_brush); + const left = rect(bounds.left + 2, bounds.top + 4, bounds.right - 16, bounds.bottom - 10); + const right = rect(bounds.left + 17, bounds.top + 13, bounds.right - 1, bounds.bottom - 1); + _ = c.RoundRect(hdc, left.left, left.top, left.right, left.bottom, 8, 8); + _ = c.MoveToEx(hdc, left.left + 13, left.bottom, null); + _ = c.LineTo(hdc, left.left + 9, left.bottom + 6); + _ = c.LineTo(hdc, left.left + 19, left.bottom); + _ = c.RoundRect(hdc, right.left, right.top, right.right, right.bottom, 8, 8); + _ = c.MoveToEx(hdc, right.right - 14, right.bottom, null); + _ = c.LineTo(hdc, right.right - 9, right.bottom + 6); + _ = c.LineTo(hdc, right.right - 20, right.bottom); +} + +fn paintQuickChatEmptyAction(hdc: c.HDC, allocator: std.mem.Allocator, bounds: c.RECT) void { + roundedControl(hdc, bounds, 8, 0x001A1B19, 0x00313230); + drawCardText(hdc, allocator, "New Chat", rect(bounds.left + 10, bounds.top, bounds.right - 10, bounds.bottom), 12, .semibold, 0x00E8E8E8, true) catch |err| { + std.log.warn("Quick Chat empty action label unavailable: {s}", .{@errorName(err)}); + }; +} + fn overviewLaneBounds( model: *const GraphModel.Model, graph_index: usize, @@ -5049,6 +5109,21 @@ test "canvas visual finish add control shares compact paint focus and hit geomet try std.testing.expect(dib.count(bounds, 0x00E8E8E8) > 0); } +test "Quick Chat remaining parity empty state matches macOS copy glyph and centered action" { + try std.testing.expectEqualStrings( + "A quick chat is a bare session with no goal, no trigger, and no place in any graph — for the questions that aren't a loop's work.", + quick_chat_empty_message, + ); + var dib = try ParityDib.init(640, 480); + defer dib.deinit(); + const bounds = rect(0, 0, 640, 480); + fill(dib.dc, bounds, Tokens.canvas_tone); + paintQuickChatEmptyState(dib.dc, std.testing.allocator, bounds); + const layout = quickChatEmptyLayout(bounds); + try std.testing.expect(dib.count(layout.glyph, 0x00919090) > 0); + try std.testing.expect(dib.count(layout.action, 0x001A1B19) > 0); +} + test "canvas visual finish notebook raster has 48 pixel pitch and follows pan zoom" { var dib = try ParityDib.init(640, 480); defer dib.deinit(); diff --git a/graphcode-windows/src/TerminalSurface.zig b/graphcode-windows/src/TerminalSurface.zig index f5b137bb..0ba90b2e 100644 --- a/graphcode-windows/src/TerminalSurface.zig +++ b/graphcode-windows/src/TerminalSurface.zig @@ -874,6 +874,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 and try self.layout.ensureAgentFirst(loop)) try self.persistLayout(); // Reopening the open loop picks up a saved pane an earlier restore could not attach. self.expediteShellRestores(); self.restorePersistedSurfaces(loop, 1); @@ -886,7 +887,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 new_layout.ensureAgentFirst(loop); for (self.surfaces, 0..) |_, index| self.destroySurface(index); self.clearAllRecreateState(); diff --git a/graphcode-windows/src/WorkspaceLayout.zig b/graphcode-windows/src/WorkspaceLayout.zig index 724f52c5..47039357 100644 --- a/graphcode-windows/src/WorkspaceLayout.zig +++ b/graphcode-windows/src/WorkspaceLayout.zig @@ -72,8 +72,14 @@ pub const Layout = struct { /// Makes sure the layout names `id` as its agent pane, adding it as the first tab when /// missing, as macOS does when a saved layout lost it. The human's selected tab stays. - pub fn ensureAgentFirst(self: *Layout, id: []const u8) !void { - if (self.idExists(id)) return; + /// Returns whether the layout changed. + pub fn ensureAgentFirst(self: *Layout, id: []const u8) !bool { + for (self.tabs.items) |*tab| for (tab.panes.items) |*pane| { + if (!std.mem.eql(u8, pane.id, id)) continue; + if (pane.launches_agent) return false; + pane.launches_agent = true; + return true; + }; var panes: std.ArrayListUnmanaged(Pane) = .empty; errdefer panes.deinit(self.allocator); try panes.append(self.allocator, .{ .id = try self.allocator.dupe(u8, id), .launches_agent = true }); @@ -85,6 +91,7 @@ pub const Layout = struct { } else { self.selected_tab += 1; } + return true; } pub fn default(allocator: std.mem.Allocator, project_key: []const u8, node_id: []const u8) !Layout { @@ -936,23 +943,39 @@ test "layout file names are the loop id when it is a plain name and a hash other test "ensureAgentFirst mounts the loop's agent tab first and keeps the selected tab" { var layout = try Layout.init(std.testing.allocator, "p"); defer layout.deinit(); - try layout.ensureAgentFirst("loop"); + _ = try layout.ensureAgentFirst("loop"); try std.testing.expectEqual(@as(usize, 1), layout.tabs.items.len); try std.testing.expect(layout.tabs.items[0].panes.items[0].launches_agent); try layout.addTab("shell", false); layout.selected_tab = 1; - try layout.ensureAgentFirst("loop"); + _ = try layout.ensureAgentFirst("loop"); try std.testing.expectEqual(@as(usize, 2), layout.tabs.items.len); var other = try Layout.init(std.testing.allocator, "p"); defer other.deinit(); try other.addTab("shell", false); - try other.ensureAgentFirst("loop"); + _ = try other.ensureAgentFirst("loop"); try std.testing.expectEqualStrings("loop", other.tabs.items[0].panes.items[0].id); try std.testing.expectEqualStrings("shell", other.tabs.items[1].panes.items[0].id); try std.testing.expectEqual(@as(usize, 1), other.selected_tab); try std.testing.expectEqualStrings("shell", other.selected().?.panes.items[0].id); } +test "Quick Chat remaining parity promotes an existing chat shell pane to agent ownership" { + var layout = try Layout.initForLoop(std.testing.allocator, "graphcode://quick-chats", "chat"); + defer layout.deinit(); + try layout.addTab("chat", false); + try layout.addTab("shell", false); + layout.selected_tab = 1; + + _ = try layout.ensureAgentFirst("chat"); + + try std.testing.expectEqual(@as(usize, 2), layout.tabs.items.len); + try std.testing.expectEqual(@as(usize, 1), layout.selected_tab); + try std.testing.expectEqualStrings("chat", layout.tabs.items[0].panes.items[0].id); + try std.testing.expect(layout.tabs.items[0].panes.items[0].launches_agent); + try std.testing.expectEqualStrings("shell", layout.selected().?.panes.items[0].id); +} + test "legacy project layout is adopted by the loop it names, without other loops' agent panes" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup();