From 17e685f1c4ab181c2b009b6ba1679729d19d53ff Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Thu, 8 Oct 2026 14:51:54 -0700 Subject: [PATCH] Fix stale terminal surfaces, New Tab UIA/cwd, and loop opens after deleting an attached loop Beta17 Dev Box qualification (4ab2fcf4) found, on the Windows shell: - a WinghosttyEmbeddableSurface (480x240 at client 0,0) left drawn over the sidebar while the graph showed, with the canvas New Loop button stranded; - UIA exposing one workspace tab after a native New Tab click drew two; - the New Tab shell starting in the install bin directory; - every loop open failing after the attached loop was deleted. A surface created while the workspace was collapsed (passive re-attach, project rebind restore) was created visible at its placeholder bounds and resize() un-collapsed the workspace. Surfaces now start hidden while collapsed, creation re-applies the layout without un-collapsing, and syncTopology hides everything while collapsed and any pane with no area. A passive re-observation (the ended loop's recreate) cancelled the explicit open of another loop, and attaching a loop into an empty slot left the layout's loop pane naming the dead loop, so the new terminal stayed hidden and later replacements failed. Passive checks no longer displace an explicit open, and the unshown loop pane is rebound to the opened loop. Native New Tab, split, tab cycling and tab select/close clicks republish UIA. New shells start in the loop's worktree, else its project folder (home for a global loop), as on macOS. The canvas New Loop/Open Folder buttons are re-laid-out with every UIA publication, touching the native controls only when their state changes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- graphcode-windows/src/App.zig | 595 ++++++++++++++++++++-- graphcode-windows/src/TerminalSurface.zig | 147 +++++- investigation/ui-parity-matrix.md | 2 +- 3 files changed, 691 insertions(+), 53 deletions(-) diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index c83e7db6..9f34f723 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -58,6 +58,9 @@ const WorkspaceTeardown = @import("WorkspaceTeardown.zig"); const Win32 = @import("Win32.zig"); const c = Win32.c; +/// Lets tests observe each production UIA publication (`syncAccessibility`); absent from product builds. +var accessibility_publication_observer: if (builtin.is_test) ?*const fn (*App) void else void = if (builtin.is_test) null else {}; + const title = std.unicode.utf8ToUtf16LeStringLiteral("GraphCode Windows"); const workspace_restart_message = "Workspace identity changed or could not be verified. Restart GraphCode before managing workspaces."; const WorktreeInspectRunner = *const fn ( @@ -1920,6 +1923,7 @@ pub const App = struct { fn openGraphLoop(self: *App, project_path: []const u8, node_id: []const u8) void { self.client.sendNodeAction(project_path, node_id, "resumeSession", null); const workspace = self.workspace orelse return; + self.bindLoopShellDirectory(workspace, project_path, node_id); workspace.openLaunchedNode(0, node_id, TerminalWorkspace.Workspace.loop_open_timeout_ms) catch { self.setStatus("Unable to open selected loop"); return; @@ -1927,6 +1931,25 @@ pub const App = struct { if (workspace.isAwaitingLaunch(0)) self.setStatus("Starting loop"); } + /// A loop's plain-shell tabs start where macOS starts them: the loop's worktree, else + /// its project folder; a global loop's at home. A remote project names no local folder. + fn bindLoopShellDirectory(self: *App, workspace: *TerminalWorkspace.Workspace, project_path: []const u8, node_id: []const u8) void { + const graph = self.model.graphFor(project_path) orelse return; + const index = GraphModel.findNodeIndexByID(graph.nodes.items, node_id) orelse return; + const node = graph.nodes.items[index]; + var home: ?[]u8 = null; + defer if (home) |value| self.allocator.free(value); + const directory: []const u8 = if (graph.project.isRemote()) + "" + else if (node.worktree_path.len != 0) + node.worktree_path + else if (graph.project.isGlobal()) global: { + home = std.process.getEnvVarOwned(self.allocator, "USERPROFILE") catch null; + break :global home orelse ""; + } else graph.project.path; + workspace.setShellDirectory(directory) catch self.setStatus("Unable to record the loop's shell directory"); + } + fn reportLaunchOutcome(self: *App, workspace: *TerminalWorkspace.Workspace) void { const outcome = workspace.takeLaunchOutcome() orelse return; self.setStatus(switch (outcome) { @@ -6000,26 +6023,44 @@ pub const App = struct { _ = self.selectNodeIndex(previous); _ = c.InvalidateRect(self.window.hwnd, null, 0); }, - .new_tab => if (self.workspace) |workspace| workspace.newTab() catch { - self.smoke_workspace_action_failed = true; - self.setStatus("Unable to create tab"); + .new_tab => { + if (self.workspace) |workspace| workspace.newTab() catch { + self.smoke_workspace_action_failed = true; + self.setStatus("Unable to create tab"); + }; + self.publishWorkspaceTabs(); }, - .close_tab => if (self.workspace) |workspace| workspace.closeFocusedPane() catch { - self.smoke_workspace_action_failed = true; - self.setStatus("Unable to close tab"); + .close_tab => { + if (self.workspace) |workspace| workspace.closeFocusedPane() catch { + self.smoke_workspace_action_failed = true; + self.setStatus("Unable to close tab"); + }; + self.publishWorkspaceTabs(); }, - .split_horizontal => if (self.workspace) |workspace| workspace.splitFocused(.horizontal) catch { - self.smoke_workspace_action_failed = true; - self.setStatus("Unable to split workspace"); + .split_horizontal => { + if (self.workspace) |workspace| workspace.splitFocused(.horizontal) catch { + self.smoke_workspace_action_failed = true; + self.setStatus("Unable to split workspace"); + }; + self.publishWorkspaceTabs(); }, - .split_vertical => if (self.workspace) |workspace| workspace.splitFocused(.vertical) catch { - self.smoke_workspace_action_failed = true; - self.setStatus("Unable to split workspace"); + .split_vertical => { + if (self.workspace) |workspace| workspace.splitFocused(.vertical) catch { + self.smoke_workspace_action_failed = true; + self.setStatus("Unable to split workspace"); + }; + self.publishWorkspaceTabs(); }, .focus_next_pane => if (self.workspace) |workspace| workspace.focusNextPane(), .focus_previous_pane => if (self.workspace) |workspace| workspace.focusPreviousPane(), - .select_previous_tab => if (self.workspace) |workspace| workspace.selectPreviousTab(), - .select_next_tab => if (self.workspace) |workspace| workspace.selectNextTab(), + .select_previous_tab => { + if (self.workspace) |workspace| workspace.selectPreviousTab(); + self.publishWorkspaceTabs(); + }, + .select_next_tab => { + if (self.workspace) |workspace| workspace.selectNextTab(); + self.publishWorkspaceTabs(); + }, .show_graph => self.showInGraph(finishShowGraphNative, syncAccessibility), .toggle_rail => { self.workspace_controls.apply(.toggle_rail); @@ -6067,6 +6108,12 @@ pub const App = struct { } } + /// The workspace's tabs changed: UIA republishes them so its tab list is the drawn one. + fn publishWorkspaceTabs(self: *App) void { + self.syncAccessibility(); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + } + fn showInGraph(self: *App, comptime finish_native: fn (*App) void, comptime publish: fn (*App) void) void { self.surface = .project; self.workspace_controls.panel_visible = false; @@ -6283,22 +6330,20 @@ pub const App = struct { const center_offset: i32 = if (!is_quick_chats and !is_overview and graph == null) -70 else -60; const y = bounds.top + @divTrunc(bounds.bottom - bounds.top, 2) + center_offset + 106; if (self.empty_open_folder_button != null) { - _ = c.ShowWindow( - self.empty_open_folder_button, - if (is_empty and !is_quick_chats and (is_overview or graph == null or is_global)) c.SW_SHOW else c.SW_HIDE, - ); - _ = c.SetWindowPos( + placeChildControl( self.empty_open_folder_button, - null, - physicalCoordinate(x, self.dpi), - physicalCoordinate(y, self.dpi), - physicalCoordinate(220, self.dpi), - physicalCoordinate(32, self.dpi), - c.SWP_NOZORDER | c.SWP_NOACTIVATE, + is_empty and !is_quick_chats and (is_overview or graph == null or is_global), + .{ + .left = physicalCoordinate(x, self.dpi), + .top = physicalCoordinate(y, self.dpi), + .right = physicalCoordinate(x, self.dpi) + physicalCoordinate(220, self.dpi), + .bottom = physicalCoordinate(y, self.dpi) + physicalCoordinate(32, self.dpi), + }, ); } if (self.empty_global_overview_button != null) { - setButtonText(self.empty_global_overview_button, if (is_quick_chats) "New Chat" else "New Loop"); + const label = if (is_quick_chats) "New Chat" else "New Loop"; + if (!childTextEquals(self.empty_global_overview_button, label)) setButtonText(self.empty_global_overview_button, label); const show_primary = is_quick_chats or is_overview or (self.surface == .project and graph != null and !is_global); const primary_x = if (is_empty) x else content_right - 140; @@ -6306,20 +6351,46 @@ pub const App = struct { y + (if (is_global or is_overview) @as(i32, 42) else @as(i32, 0)) else Tokens.header_height + 14; - _ = c.ShowWindow( - self.empty_global_overview_button, - if (show_primary) c.SW_SHOW else c.SW_HIDE, - ); + placeChildControl(self.empty_global_overview_button, show_primary, .{ + .left = physicalCoordinate(primary_x, self.dpi), + .top = physicalCoordinate(primary_y, self.dpi), + .right = physicalCoordinate(primary_x, self.dpi) + physicalCoordinate(if (is_empty) 220 else 120, self.dpi), + .bottom = physicalCoordinate(primary_y, self.dpi) + physicalCoordinate(32, self.dpi), + }); + } + } + + /// Shows or hides a child control and moves it to `bounds` (parent client pixels), + /// touching the native window only for what changed. + fn placeChildControl(control: c.HWND, show: bool, bounds: c.RECT) void { + const parent = c.GetParent(control); + var current: c.RECT = undefined; + if (c.GetWindowRect(control, ¤t) == 0) return; + _ = c.MapWindowPoints(null, parent, @ptrCast(¤t), 2); + if (current.left != bounds.left or current.top != bounds.top or + current.right != bounds.right or current.bottom != bounds.bottom) + { _ = c.SetWindowPos( - self.empty_global_overview_button, + control, null, - physicalCoordinate(primary_x, self.dpi), - physicalCoordinate(primary_y, self.dpi), - physicalCoordinate(if (is_empty) 220 else 120, self.dpi), - physicalCoordinate(32, self.dpi), + bounds.left, + bounds.top, + bounds.right - bounds.left, + bounds.bottom - bounds.top, c.SWP_NOZORDER | c.SWP_NOACTIVATE, ); } + const style: u32 = @bitCast(c.GetWindowLongW(control, c.GWL_STYLE)); + if (((style & c.WS_VISIBLE) != 0) != show) _ = c.ShowWindow(control, if (show) c.SW_SHOW else c.SW_HIDE); + } + + fn childTextEquals(control: c.HWND, text: []const u8) bool { + var buffer: [64]u16 = undefined; + const length = c.GetWindowTextW(control, &buffer, buffer.len); + if (length <= 0) return text.len == 0; + var utf8: [192]u8 = undefined; + const count = std.unicode.utf16LeToUtf8(&utf8, buffer[0..@intCast(length)]) catch return false; + return std.mem.eql(u8, utf8[0..count], text); } fn createButton(parent: c.HWND, text: []const u8, id: usize) c.HWND { @@ -6664,6 +6735,10 @@ pub const App = struct { } fn syncAccessibility(self: *App) void { + // The canvas's native New Loop / Open Folder buttons follow the same state UIA + // publishes, so a project switch or graph arrival never leaves one stale. + self.layoutEmptyStateControls(); + if (builtin.is_test) if (accessibility_publication_observer) |observe| observe(self); const provider = if (self.accessibility) |*value| value else return; self.syncAccessibilityTo(provider, logicalClientRect(self.window.hwnd, self.dpi)); self.syncHeaderFocus(); @@ -8662,7 +8737,7 @@ fn onWindowMessage( .select => workspace.selectTab(tab_action.index) catch {}, .close => workspace.closeTab(tab_action.index) catch {}, } - _ = c.InvalidateRect(hwnd, null, 0); + app.publishWorkspaceTabs(); result.* = 0; return true; } @@ -11133,6 +11208,454 @@ test "collapsed loop panel routes native tab and loop-bar clicks to the workspac } } +/// A `zmx` stand-in for live-terminal tests: `ls` prints `live.txt` (the daemon's running +/// sessions); `attach` records the directory it was started in, then blocks on the +/// terminal's input pipe like a real attach until its session is killed. +const fake_zmx_script = + "@echo off\r\n" ++ + "if \"%~1\"==\"ls\" goto ls\r\n" ++ + "if \"%~1\"==\"attach\" goto attach\r\n" ++ + "exit /b 0\r\n" ++ + ":ls\r\n" ++ + "if exist \"%~dp0live.txt\" type \"%~dp0live.txt\"\r\n" ++ + "exit /b 0\r\n" ++ + ":attach\r\n" ++ + ">\"%~dp0cwd-%~2.txt\" echo %CD%\r\n" ++ + "pause >nul\r\n" ++ + "exit /b 0\r\n"; + +const LiveLoop = struct { id: []const u8, state: []const u8 = "idle", worktree: ?[]const u8 = null }; + +const TerminalChild = struct { visible: bool, rect: c.RECT }; + +/// A loop workspace whose terminals are real winghostty surfaces (child windows of the +/// shell window) attached through real child processes. Only the daemon is supplied: its +/// graph arrives as frames and its sessions are the fake zmx's `live.txt`. +const LiveTerminalFixture = struct { + tmp: std.testing.TmpDir, + root: []u8, + project: []u8, + worktree: []u8, + bin: []u8, + workspace: TerminalWorkspace.Workspace, + app: App, + sequence: usize, + + fn init(self: *LiveTerminalFixture, loops: []const LiveLoop) !void { + const allocator = std.testing.allocator; + self.sequence = 2; + self.tmp = std.testing.tmpDir(.{}); + try self.tmp.dir.makePath("project"); + try self.tmp.dir.makePath("wt-a"); + try self.tmp.dir.makePath("bin"); + try self.tmp.dir.writeFile(.{ .sub_path = "zmx.cmd", .data = fake_zmx_script }); + self.root = try self.tmp.dir.realpathAlloc(allocator, "."); + self.project = try std.fs.path.join(allocator, &.{ self.root, "project" }); + self.worktree = try std.fs.path.join(allocator, &.{ self.root, "wt-a" }); + // The installed shell's working directory: `...\GraphCode\current\bin`. + self.bin = try std.fs.path.join(allocator, &.{ self.root, "bin" }); + self.app = try overviewTestApp(Dpi.base_dpi); + const app = &self.app; + self.workspace = .{ + .parent = app.window.hwnd, + .allocator = allocator, + .zmx_path = try std.fs.path.join(allocator, &.{ self.root, "zmx.cmd" }), + .cwd = try allocator.dupe(u8, self.bin), + .input_queue = .{ .allocator = allocator }, + .layout = try @import("WorkspaceLayout.zig").Layout.init(allocator, self.project), + .layout_path = try std.fs.path.join(allocator, &.{ self.root, "layout.json" }), + .project_key = try allocator.dupe(u8, self.project), + .project_path = try allocator.dupe(u8, self.project), + }; + if (c.winghostty_host_initialize(&self.workspace.host) != c.WINGHOSTTY_OK) return error.TestWinghosttyHostUnavailable; + // Workspace.init's per-slot cell buffers for the default grid. + for (&self.workspace.surfaces) |*slot| { + slot.cells = try allocator.alloc(c.winghostty_terminal_cell, @as(usize, slot.grid.cols) * slot.grid.rows); + for (slot.cells) |*cell| cell.* = .{ .codepoint = 0, .foreground = 0xE6E6E6, .background = 0, .flags = 0 }; + } + const initial = try self.frame(1, loops); + defer allocator.free(initial); + _ = try app.model.updateFromFrame(initial); + try std.testing.expect(app.selectProject(self.project)); + app.surface = .project; + app.workspace_controls = .{ .rail_visible = true, .panel_visible = false, .activity_enabled = false }; + app.workspace = &self.workspace; + try std.testing.expect(c.MoveWindow(app.window.hwnd, 0, 0, physicalCoordinate(1200, app.dpi), physicalCoordinate(700, app.dpi), 0) != 0); + app.layoutWorkspace(); + ReopenProbe.uia_tabs = 0; + ReopenProbe.drawn_tabs = 0; + accessibility_publication_observer = ReopenProbe.publish; + } + + fn deinit(self: *LiveTerminalFixture) void { + const allocator = std.testing.allocator; + accessibility_publication_observer = null; + self.app.workspace = null; + self.workspace.deinit(); + deinitOverviewTestApp(&self.app); + allocator.free(self.bin); + allocator.free(self.worktree); + allocator.free(self.project); + allocator.free(self.root); + self.tmp.cleanup(); + } + + fn frame(self: *LiveTerminalFixture, sequence: usize, loops: []const LiveLoop) ![]u8 { + const allocator = std.testing.allocator; + var nodes: std.ArrayListUnmanaged(u8) = .empty; + defer nodes.deinit(allocator); + for (loops, 0..) |loop, index| { + const binding = if (loop.worktree) |path| + try std.fmt.allocPrint(allocator, ",\"worktreeBinding\":{{\"path\":{f}}}", .{std.json.fmt(path, .{})}) + else + try allocator.dupe(u8, ""); + defer allocator.free(binding); + const node = try std.fmt.allocPrint( + allocator, + "{s}{{\"id\":\"{s}\",\"title\":\"{s}\",\"loopType\":\"turnBased\",\"state\":{{\"{s}\":{{}}}}{s}}}", + .{ if (index == 0) "" else ",", loop.id, loop.id, loop.state, binding }, + ); + defer allocator.free(node); + try nodes.appendSlice(allocator, node); + } + return std.fmt.allocPrint( + allocator, + "{{\"version\":2,\"kind\":\"event\",\"sequence\":{d},\"event\":{{\"graphChanged\":{{\"project\":{{\"path\":{f},\"name\":\"Core\"}},\"nodes\":[{s}],\"edges\":[]}}}}}}", + .{ sequence, std.json.fmt(self.project, .{}), nodes.items }, + ); + } + + /// A daemon graph change through the production frame path (and its workspace refresh). + fn deliver(self: *LiveTerminalFixture, loops: []const LiveLoop) !void { + const value = try self.frame(self.sequence, loops); + defer std.testing.allocator.free(value); + self.sequence += 1; + self.app.onFrameWithAccessibilityPublish(value, App.publishAccessibility); + } + + fn setLive(self: *LiveTerminalFixture, ids: []const []const u8) !void { + const allocator = std.testing.allocator; + var listing: std.ArrayListUnmanaged(u8) = .empty; + defer listing.deinit(allocator); + for (ids) |id| { + try listing.appendSlice(allocator, "name=graphcode-"); + try listing.appendSlice(allocator, id); + try listing.appendSlice(allocator, "\tpid=1\tclients=0\r\n"); + } + try self.tmp.dir.writeFile(.{ .sub_path = "live.txt", .data = listing.items }); + } + + fn slotFor(self: *const LiveTerminalFixture, session: []const u8) ?usize { + for (self.workspace.surfaces, 0..) |slot, index| { + if (slot.surface != null and std.mem.eql(u8, slot.session_name, session)) return index; + } + return null; + } + + /// The daemon kills a loop's session; its attach process exits. + fn endSession(self: *LiveTerminalFixture, session: []const u8) !void { + const index = self.slotFor(session) orelse return error.TestExpectedAttachedSession; + const child = self.workspace.surfaces[index].attach orelse return error.TestExpectedAttachedSession; + try std.testing.expect(c.TerminateProcess(child.id, 0) != 0); + try std.testing.expectEqual(@as(c.DWORD, c.WAIT_OBJECT_0), c.WaitForSingleObject(child.id, 5_000)); + } + + /// One WM_TIMER workspace step (`workspace.poll` and its launch report) plus the + /// thread's pending window messages. + fn tick(self: *LiveTerminalFixture) void { + self.workspace.poll(); + self.app.reportLaunchOutcome(&self.workspace); + var message: c.MSG = undefined; + while (c.PeekMessageW(&message, null, 0, 0, c.PM_REMOVE) != 0) { + _ = c.TranslateMessage(&message); + _ = c.DispatchMessageW(&message); + } + std.Thread.sleep(15 * std.time.ns_per_ms); + } + + fn waitFor(self: *LiveTerminalFixture, comptime done: fn (*LiveTerminalFixture, []const u8) bool, argument: []const u8) !void { + const deadline = std.time.milliTimestamp() + 12_000; + while (!done(self, argument)) { + if (std.time.milliTimestamp() >= deadline) { + std.debug.print("timed out waiting for {s}: status \"{s}\", awaiting {}, tabs {d}\n", .{ argument, self.app.status(), self.workspace.isAwaitingLaunch(0), self.workspace.tabCount() }); + for (self.workspace.surfaces, 0..) |slot, index| { + if (slot.surface != null or slot.attach != null or self.workspace.isAwaitingLaunch(index)) + std.debug.print(" slot {d}: session {s} surface {} attach {} awaiting {}\n", .{ index, slot.session_name, slot.surface != null, slot.attach != null, self.workspace.isAwaitingLaunch(index) }); + } + return error.TestTimedOut; + } + self.tick(); + } + } + + fn shows(self: *LiveTerminalFixture, session: []const u8) bool { + return self.slotFor(session) != null and !self.workspace.isAwaitingLaunch(0); + } + + fn loopSlotIdle(self: *LiveTerminalFixture, _: []const u8) bool { + return !self.workspace.hasSurface(0) and !self.workspace.hasAttach(0) and + !self.workspace.isAwaitingLaunch(0) and self.workspace.recreate_sessions[0].len == 0; + } + + fn terminals(self: *LiveTerminalFixture, out: *[8]TerminalChild) usize { + const class = std.unicode.utf8ToUtf16LeStringLiteral("WinghosttyEmbeddableSurface"); + var count: usize = 0; + var child: c.HWND = null; + while (count < out.len) { + child = c.FindWindowExW(self.app.window.hwnd, child, class, null); + if (child == null) break; + var rect: c.RECT = undefined; + _ = c.GetWindowRect(child, &rect); + _ = c.MapWindowPoints(null, self.app.window.hwnd, @ptrCast(&rect), 2); + const style: u32 = @bitCast(c.GetWindowLongW(child, c.GWL_STYLE)); + out[count] = .{ .visible = (style & c.WS_VISIBLE) != 0, .rect = rect }; + count += 1; + } + return count; + } + + fn expectNoVisibleTerminal(self: *LiveTerminalFixture) !void { + var children: [8]TerminalChild = undefined; + for (children[0..self.terminals(&children)]) |child| { + if (child.visible) { + std.debug.print("visible terminal over the graph at {d},{d} {d}x{d}\n", .{ + child.rect.left, child.rect.top, child.rect.right - child.rect.left, child.rect.bottom - child.rect.top, + }); + return error.TestUnexpectedVisibleTerminal; + } + } + } + + /// Exactly `expected` terminals are visible, all inside the loop workspace (right of + /// the sidebar, below the header, loop bar, and tab strip). + fn expectTerminalsInWorkspace(self: *LiveTerminalFixture, expected: usize) !void { + var children: [8]TerminalChild = undefined; + var visible: usize = 0; + for (children[0..self.terminals(&children)]) |child| { + if (!child.visible) continue; + visible += 1; + try std.testing.expect(child.rect.left >= physicalCoordinate(Tokens.sidebar_width, self.app.dpi)); + try std.testing.expect(child.rect.top >= physicalCoordinate(Tokens.header_height + Tokens.loop_bar_height + Tokens.tab_bar_height, self.app.dpi)); + try std.testing.expect(child.rect.right > child.rect.left and child.rect.bottom > child.rect.top); + } + try std.testing.expectEqual(expected, visible); + } + + fn showGraph(self: *LiveTerminalFixture) !void { + LoopBarProbe.publish(&self.app); + try nativeClickCenter(&self.app, LoopBarProbe.show_graph orelse return error.TestExpectedShowGraphButton); + try std.testing.expectEqual(GraphCanvas.Surface.project, self.app.surface); + } + + /// The directory the session's attach process was started in (the shell's directory). + fn attachDirectory(self: *LiveTerminalFixture, session: []const u8) ![]u8 { + const allocator = std.testing.allocator; + const name = try std.fmt.allocPrint(allocator, "cwd-graphcode-{s}.txt", .{session}); + defer allocator.free(name); + const deadline = std.time.milliTimestamp() + 12_000; + while (true) { + if (self.tmp.dir.readFileAlloc(allocator, name, 4096)) |data| { + defer allocator.free(data); + const trimmed = std.mem.trim(u8, data, " \r\n"); + if (trimmed.len != 0) return allocator.dupe(u8, trimmed); + } else |_| {} + if (std.time.milliTimestamp() >= deadline) return error.TestTimedOut; + self.tick(); + } + } + + fn expectSelectedPane(self: *LiveTerminalFixture, session: []const u8) !void { + const tab = self.workspace.layout.selectedConst() orelse return error.TestExpectedTab; + try std.testing.expectEqualStrings(session, tab.panes.items[tab.focused_pane].id); + } +}; + +fn expectSameDirectory(expected: []const u8, actual: []const u8) !void { + if (!std.ascii.eqlIgnoreCase(expected, actual)) { + std.debug.print("expected directory {s}, shell started in {s}\n", .{ expected, actual }); + return error.TestUnexpectedDirectory; + } +} + +test "workspace surface: a loop terminal attached while the graph shows stays hidden instead of drawing over the sidebar" { + var fixture: LiveTerminalFixture = undefined; + try fixture.init(&.{ .{ .id = "loop-a" }, .{ .id = "loop-b" } }); + defer fixture.deinit(); + const app = &fixture.app; + + try fixture.setLive(&.{"loop-a"}); + try clickSidebarLoopRow(app, fixture.project, "loop-a"); + try fixture.waitFor(LiveTerminalFixture.shows, "loop-a"); + try fixture.expectTerminalsInWorkspace(1); + + // The loop's session ends while its pane shows; the pane detaches. + try fixture.setLive(&.{}); + try fixture.endSession("loop-a"); + try fixture.waitFor(LiveTerminalFixture.loopSlotIdle, ""); + try fixture.showGraph(); + try fixture.expectNoVisibleTerminal(); + + // The daemon restarts the loop while the graph shows; graph refreshes re-observe its + // pane, which attaches behind the graph. + try fixture.setLive(&.{"loop-a"}); + const deadline = std.time.milliTimestamp() + 12_000; + while (fixture.slotFor("loop-a") == null) { + if (std.time.milliTimestamp() >= deadline) return error.TestTimedOut; + try fixture.deliver(&.{ .{ .id = "loop-a", .state = "running" }, .{ .id = "loop-b" } }); + for (0..5) |_| fixture.tick(); + } + for (0..10) |_| fixture.tick(); + try std.testing.expectEqual(GraphCanvas.Surface.project, app.surface); + try fixture.expectNoVisibleTerminal(); + + // Reopening the loop shows that terminal inside the workspace. + try clickSidebarLoopRow(app, fixture.project, "loop-a"); + try fixture.waitFor(LiveTerminalFixture.shows, "loop-a"); + try fixture.expectTerminalsInWorkspace(1); + try fixture.showGraph(); + try fixture.expectNoVisibleTerminal(); +} + +test "workspace surface: native New Tab publishes every drawn tab to UIA and starts its shell in the loop's directory" { + var fixture: LiveTerminalFixture = undefined; + try fixture.init(&.{ .{ .id = "loop-a" }, .{ .id = "loop-b" } }); + defer fixture.deinit(); + const app = &fixture.app; + const workspace = &fixture.workspace; + // Loop A works in its own worktree; loop B in the project folder. + try fixture.deliver(&.{ .{ .id = "loop-a", .worktree = fixture.worktree }, .{ .id = "loop-b" } }); + + try fixture.setLive(&.{ "loop-a", "loop-b" }); + for ([_][]const u8{ "loop-a", "loop-b" }, [_][]const u8{ fixture.worktree, fixture.project }) |loop, directory| { + try clickSidebarLoopRow(app, fixture.project, loop); + try fixture.waitFor(LiveTerminalFixture.shows, loop); + try std.testing.expectEqual(@as(usize, 1), workspace.tabCount()); + + const new_tab = TerminalWorkspace.chromeControlBounds(workspace.layout_origin_x, workspace.layout_origin_y, workspace.layout_width, 0); + try nativeClickCenter(app, new_tab); + try std.testing.expectEqual(@as(usize, 2), workspace.tabCount()); + try std.testing.expectEqual(workspace.tabCount(), ReopenProbe.uia_tabs); + + const shell = try std.testing.allocator.dupe(u8, workspace.layout.tabs.items[1].panes.items[0].id); + defer std.testing.allocator.free(shell); + const started_in = try fixture.attachDirectory(shell); + defer std.testing.allocator.free(started_in); + try expectSameDirectory(directory, started_in); + try fixture.expectTerminalsInWorkspace(1); + + // Native clicks on the drawn tabs keep UIA's tab list equal to the drawn one. + const agent_tab = TerminalWorkspace.tabBounds(workspace.layout_origin_x, workspace.layout_origin_y, 0); + try nativeClick(app, agent_tab.left + 24, @divTrunc(agent_tab.top + agent_tab.bottom, 2)); + try std.testing.expectEqual(@as(usize, 0), workspace.layout.selected_tab); + try std.testing.expectEqual(workspace.tabCount(), ReopenProbe.uia_tabs); + const shell_tab = TerminalWorkspace.tabBounds(workspace.layout_origin_x, workspace.layout_origin_y, 1); + try nativeClick(app, shell_tab.right - 12, @divTrunc(shell_tab.top + shell_tab.bottom, 2)); + try std.testing.expectEqual(@as(usize, 1), workspace.tabCount()); + try std.testing.expectEqual(workspace.tabCount(), ReopenProbe.uia_tabs); + try fixture.expectSelectedPane(loop); + try fixture.showGraph(); + } +} + +test "workspace surface: deleting the attached loop while the graph shows leaves every later loop open working" { + var fixture: LiveTerminalFixture = undefined; + try fixture.init(&.{ .{ .id = "loop-a" }, .{ .id = "loop-b" }, .{ .id = "loop-c" } }); + defer fixture.deinit(); + const app = &fixture.app; + + try fixture.setLive(&.{ "loop-a", "loop-b", "loop-c" }); + try clickSidebarLoopRow(app, fixture.project, "loop-a"); + try fixture.waitFor(LiveTerminalFixture.shows, "loop-a"); + try fixture.expectTerminalsInWorkspace(1); + try fixture.showGraph(); + + // `graphcode node delete loop-a`: the daemon kills its session and drops it from the graph. + try fixture.setLive(&.{ "loop-b", "loop-c" }); + try fixture.endSession("loop-a"); + try fixture.deliver(&.{ .{ .id = "loop-b" }, .{ .id = "loop-c" } }); + + for ([_][]const u8{ "loop-b", "loop-c", "loop-b" }) |loop| { + try clickSidebarLoopRow(app, fixture.project, loop); + // The open itself reports no focus or open failure. + try std.testing.expectEqualStrings("Starting loop", app.status()); + try fixture.waitFor(LiveTerminalFixture.shows, loop); + for (0..5) |_| fixture.tick(); + try std.testing.expectEqualStrings("Loop opened", app.status()); + try fixture.expectSelectedPane(loop); + try fixture.expectTerminalsInWorkspace(1); + try std.testing.expectEqual(@as(usize, 1), fixture.workspace.tabCount()); + try fixture.showGraph(); + try fixture.expectNoVisibleTerminal(); + } +} + +fn emptyStateButton(app: *App, button: c.HWND) TerminalChild { + var rect: c.RECT = undefined; + _ = c.GetWindowRect(button, &rect); + _ = c.MapWindowPoints(null, app.window.hwnd, @ptrCast(&rect), 2); + const style: u32 = @bitCast(c.GetWindowLongW(button, c.GWL_STYLE)); + return .{ .visible = (style & c.WS_VISIBLE) != 0, .rect = rect }; +} + +/// The canvas's New Loop button sits at the top right of a project that has loops. +fn expectNewLoopAtTopRight(app: *App) !void { + const button = emptyStateButton(app, app.empty_global_overview_button); + try std.testing.expect(button.visible); + const client = logicalClientRect(app.window.hwnd, app.dpi); + try std.testing.expectEqual(physicalCoordinate(client.right - 140, app.dpi), button.rect.left); + try std.testing.expectEqual(physicalCoordinate(Tokens.header_height + 14, app.dpi), button.rect.top); + try std.testing.expectEqual(physicalCoordinate(120, app.dpi), button.rect.right - button.rect.left); + try std.testing.expect(!emptyStateButton(app, app.empty_open_folder_button).visible); +} + +fn clickSidebarProjectRow(app: *App, path: []const u8) !void { + var rows = try Sidebar.appendRows(app.allocator, &app.model, app.currentWorktreeInspection(), app.sidebar_scroll, &app.sidebar_state); + defer rows.deinit(app.allocator); + const row = for (rows.items) |row| { + if (row.kind == .open_project and row.project_path != null and std.mem.eql(u8, row.project_path.?, path)) break row; + } else return error.TestExpectedProjectRow; + try nativeClick(app, 80, row.top + 8); +} + +test "workspace surface: canvas New Loop and Open Folder buttons follow the project shown after native opens and graph arrivals" { + var app = try overviewTestApp(Dpi.base_dpi); + defer deinitOverviewTestApp(&app); + // The first graph is queued as the project to subscribe to once connected. + defer app.allocator.free(app.pending_project_path); + try std.testing.expect(c.MoveWindow(app.window.hwnd, 0, 0, physicalCoordinate(1200, app.dpi), physicalCoordinate(700, app.dpi), 0) != 0); + app.surface = .project; + app.createEmptyStateControls(); + defer { + _ = c.DestroyWindow(app.empty_open_folder_button); + _ = c.DestroyWindow(app.empty_global_overview_button); + } + try std.testing.expect(app.empty_open_folder_button != null and app.empty_global_overview_button != null); + // No project yet: the empty canvas offers Open Folder. + try std.testing.expect(emptyStateButton(&app, app.empty_open_folder_button).visible); + + // The daemon restores a project with loops: Open Folder goes, New Loop moves to the top right. + app.onFrameWithAccessibilityPublish( + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"project":{"path":"C:\\core-fixture","name":"Core"},"nodes":[{"id":"loop-a","title":"Loop A","loopType":"turnBased","state":{"idle":{}}}],"edges":[]}}} + , App.publishAccessibility); + try std.testing.expectEqualStrings("C:\\core-fixture", app.model.graph.?.project.path); + try expectNewLoopAtTopRight(&app); + + // An extra, empty project opens beside it; selecting its row centres New Loop on its canvas. + app.onFrameWithAccessibilityPublish( + \\{"version":2,"kind":"event","sequence":2,"event":{"graphChanged":{"project":{"path":"C:\\empty-fixture","name":"Empty"},"nodes":[],"edges":[]}}} + , App.publishAccessibility); + try clickSidebarProjectRow(&app, "C:\\empty-fixture"); + try std.testing.expectEqualStrings("C:\\empty-fixture", app.model.graph.?.project.path); + const centred = emptyStateButton(&app, app.empty_global_overview_button); + try std.testing.expect(centred.visible); + try std.testing.expectEqual(physicalCoordinate(220, app.dpi), centred.rect.right - centred.rect.left); + + // Back on the project with loops, the button returns to the top right. + try clickSidebarProjectRow(&app, "C:\\core-fixture"); + try std.testing.expectEqualStrings("C:\\core-fixture", app.model.graph.?.project.path); + try expectNewLoopAtTopRight(&app); +} + test "selection routes with a loop workspace open: Ctrl+Tab review moves the loop bar and pane together" { var fixture: ShowGraphFixture = undefined; try fixture.init(); diff --git a/graphcode-windows/src/TerminalSurface.zig b/graphcode-windows/src/TerminalSurface.zig index 4a0cd128..931be157 100644 --- a/graphcode-windows/src/TerminalSurface.zig +++ b/graphcode-windows/src/TerminalSurface.zig @@ -393,6 +393,8 @@ pub const Workspace = struct { layout_height: i32 = 250, collapsed: bool = false, project_path: []u8 = &.{}, + /// The open loop's directory, where new terminal sessions start (see `sessionDirectory`). + shell_directory: []u8 = &.{}, syncing_topology: bool = false, syncing_focus: bool = false, persisting_layout: bool = false, @@ -477,6 +479,7 @@ pub const Workspace = struct { } } if (self.project_path.len != 0) self.allocator.free(self.project_path); + if (self.shell_directory.len != 0) self.allocator.free(self.shell_directory); if (self.host) |host| { _ = c.winghostty_host_deinitialize(host); self.host = null; @@ -523,6 +526,7 @@ pub const Workspace = struct { self.allocator.free(old_layout_path); for (&self.recreate_due_ms) |*due| due.* = 0; for (&self.recreate_delay_ms) |*delay| delay.* = 100; + self.clearShellDirectory(); self.restorePersistedSurfaces(); } @@ -566,14 +570,42 @@ pub const Workspace = struct { self.allocator.free(old_key); self.allocator.free(old_path); self.allocator.free(old_layout_path); + self.clearShellDirectory(); self.restorePersistedSurfaces(); return true; } + fn clearShellDirectory(self: *Workspace) void { + if (self.shell_directory.len != 0) self.allocator.free(self.shell_directory); + self.shell_directory = &.{}; + } + pub fn projectPath(self: *const Workspace) []const u8 { return self.project_path; } + /// Records where the open loop's plain-shell tabs and splits start, as on macOS: the + /// loop's worktree, else its project folder. Empty leaves them in the shell's own + /// working directory. + pub fn setShellDirectory(self: *Workspace, directory: []const u8) !void { + const copy = try self.allocator.dupe(u8, directory); + if (self.shell_directory.len != 0) self.allocator.free(self.shell_directory); + self.shell_directory = copy; + } + + /// The directory a new terminal session starts in: the open loop's directory, else the + /// workspace's project folder. One that does not exist locally (a reclaimed worktree, + /// a remote or global project) falls back to the shell's own working directory. + fn sessionDirectory(self: *const Workspace) []const u8 { + for ([_][]const u8{ self.shell_directory, self.project_path }) |candidate| { + if (candidate.len == 0 or !std.fs.path.isAbsolute(candidate)) continue; + var directory = std.fs.cwd().openDir(candidate, .{}) catch continue; + directory.close(); + return candidate; + } + return self.cwd; + } + fn layoutPathForProject(self: *Workspace, project: []const u8) ![]u8 { const configured = std.process.getEnvVarOwned(self.allocator, "GRAPHCODE_WORKSPACE_LAYOUT") catch try self.allocator.dupe(u8, "graphcode-workspace.json"); @@ -612,6 +644,9 @@ pub const Workspace = struct { const explicit = timeout_ms > 0; const wait = &self.launch_waits[index]; if (!explicit and now < self.passive_retry_due_ms[index]) return; + // A passive re-observation (a graph refresh, or an ended session's recreate) never + // displaces the loop the user just asked to open. + if (!explicit and wait.active() and wait.reports_timeout and !std.mem.eql(u8, wait.session, node_id)) return; const session = try self.allocator.dupe(u8, node_id); errdefer self.allocator.free(session); // Mount the tab immediately, but not the terminal: pending/failed launches still @@ -631,6 +666,8 @@ pub const Workspace = struct { self.layout.replacePaneID(node_id, wait.session) catch {}; return err; }; + } else { + try self.bindLoopPane(node_id); } } if (wait.active() and std.mem.eql(u8, wait.session, node_id)) { @@ -876,6 +913,8 @@ pub const Workspace = struct { try self.layout.addTab(node_id, true); } else if (index > 0 and self.layout.tabs.items.len == 1) { try self.layout.addTab(node_id, loop_pane); + } else if (loop_pane) { + try self.bindLoopPane(node_id); } try self.persistLayout(); var options = self.surfaceOptions(index); @@ -893,15 +932,67 @@ pub const Workspace = struct { self.surfaces[index].destroyed = false; self.surfaces[index].destroying = false; clearCells(&self.surfaces[index]); - self.resize( - self.layout_origin_x, - self.layout_origin_y, - self.layout_width, - self.layout_height, - ); + self.relayout(); self.clearRecreateSession(index); } + /// Whether any pane of the layout is `id`. + fn layoutHasPane(self: *const Workspace, id: []const u8) bool { + for (self.layout.tabs.items) |tab| for (tab.panes.items) |pane| { + if (std.mem.eql(u8, pane.id, id)) return true; + }; + return false; + } + + /// Whether a slot shows or is attaching `id`. + fn paneShown(self: *const Workspace, id: []const u8) bool { + for (self.surfaces) |slot| { + if ((slot.surface != null or slot.attach != null) and std.mem.eql(u8, slot.session_name, id)) return true; + } + return false; + } + + /// Makes `node_id` the layout's loop pane when the layout does not name it yet. The loop + /// pane that no slot shows any more (its session ended, or its loop was deleted) is + /// renamed in place and selected; without one, the loop gets its own tab. Otherwise the + /// loop's terminal would attach beside a stale pane that the tab strip and focus still + /// draw, leaving it hidden and every later open failing to find its pane. + fn bindLoopPane(self: *Workspace, node_id: []const u8) !void { + if (self.layoutHasPane(node_id)) return; + const previous_selected = self.layout.selected_tab; + for (self.layout.tabs.items, 0..) |tab, tab_index| for (tab.panes.items, 0..) |pane, pane_index| { + if (!pane.launches_agent or self.paneShown(pane.id)) continue; + const old_id = try self.allocator.dupe(u8, pane.id); + defer self.allocator.free(old_id); + const previous_focus = tab.focused_pane; + try self.layout.replacePaneID(old_id, node_id); + self.layout.selected_tab = tab_index; + self.layout.tabs.items[tab_index].focused_pane = pane_index; + self.persistLayout() catch |err| { + self.layout.replacePaneID(node_id, old_id) catch {}; + self.layout.selected_tab = previous_selected; + self.layout.tabs.items[tab_index].focused_pane = previous_focus; + return err; + }; + return; + }; + const previous_next_id = self.layout.next_tab_id; + try self.layout.addTab(node_id, true); + self.persistLayout() catch |err| { + _ = self.layout.removePane(node_id); + self.layout.selected_tab = previous_selected; + self.layout.next_tab_id = previous_next_id; + return err; + }; + } + + /// Re-applies the current layout to the native surfaces without changing whether the + /// workspace is collapsed: a surface created while the graph shows stays hidden. + fn relayout(self: *Workspace) void { + if (self.collapsed) return self.blurAll(); + self.syncTopology(); + } + pub fn newTab(self: *Workspace) !void { const surface_id = try self.layout.newSurfaceID(); defer self.allocator.free(surface_id); @@ -945,12 +1036,7 @@ pub const Workspace = struct { self.surfaces[index].destroyed = false; self.surfaces[index].destroying = false; clearCells(&self.surfaces[index]); - self.resize( - self.layout_origin_x, - self.layout_origin_y, - self.layout_width, - self.layout_height, - ); + self.relayout(); return index; } return error.SurfaceCapacityExceeded; @@ -1448,6 +1534,7 @@ pub const Workspace = struct { fn syncTopology(self: *Workspace) void { if (self.syncing_topology) return; + if (self.collapsed) return self.blurAll(); self.syncing_topology = true; defer self.syncing_topology = false; const selected = self.layout.selected() orelse return; @@ -1456,7 +1543,6 @@ pub const Workspace = struct { const pane_index = self.paneIndex(slot.session_name); if (slot.surface == null) continue; if (pane_index) |position| { - _ = c.winghostty_surface_set_visible(slot.surface, 1); const bounds = paneBounds( self.layout_origin_x, self.layout_origin_y, @@ -1466,6 +1552,9 @@ pub const Workspace = struct { position, pane_count, ); + // A pane with no area has nowhere to draw; showing it would leave the + // surface at its previous or placeholder bounds. + _ = c.winghostty_surface_set_visible(slot.surface, if (bounds.width != 0 and bounds.height != 0) 1 else 0); if (bounds.width != 0 and bounds.height != 0) { _ = c.winghostty_surface_set_bounds(slot.surface, &bounds); var metrics = slot.cell_metrics; @@ -1791,8 +1880,10 @@ pub const Workspace = struct { options.bounds.y = 0; options.bounds.width = 480; options.bounds.height = 240; - options.visible = 1; - options.focus = if (index == self.active_surface) 1 else 0; + // A surface created while the workspace is collapsed (the graph shows) starts hidden + // and unfocused; otherwise it would draw at these placeholder bounds, over the sidebar. + options.visible = if (self.collapsed) 0 else 1; + options.focus = if (!self.collapsed and index == self.active_surface) 1 else 0; options.theme = c.WINGHOSTTY_THEME_DARK; // Use the workspace's last-known runtime monitor DPI so a surface created // after a DPI change (e.g. a new split/tab opened post-move) starts scaled @@ -1835,6 +1926,7 @@ pub const Workspace = struct { try self.resizeSurfaceGrid(index, size); const vt = if (self.experimental_vt) try TerminalVt.State.create(self.allocator, size.cols, size.rows) else null; errdefer if (vt) |state| state.destroy(); + const directory = self.sessionDirectory(); const nonreading = std.process.getEnvVarOwned(self.allocator, "GRAPHCODE_SHELL_NONREADING_ATTACH") catch null; defer if (nonreading) |value| self.allocator.free(value); var attach_args: [5][]const u8 = undefined; @@ -1857,7 +1949,7 @@ pub const Workspace = struct { &session_buffer, )).len; } - var child = ZmxSession.child(self.allocator, attach_args[0..attach_len], self.cwd, .attach); + var child = ZmxSession.child(self.allocator, attach_args[0..attach_len], directory, .attach); try child.spawn(); if (child.stdin) |stdin| { var mode: c.DWORD = c.PIPE_NOWAIT; @@ -4189,6 +4281,29 @@ test "passive loop observation does not create a pending tab" { try std.testing.expect(workspace.surfaces[0].attach == null); } +test "an explicit open rebinds a stale loop pane and survives a passive recreate of the ended loop" { + var workspace = try minimalWorkspaceForOptionsTest(std.testing.allocator); + defer workspace.layout.deinit(); + defer workspace.cancelAllLaunchWaits(); + const path = "terminal-stale-loop-pane-test.json"; + workspace.layout_path = @constCast(path); + defer std.fs.cwd().deleteFile(path) catch {}; + // The deleted loop's pane outlived its session, beside a shell tab that is selected. + try workspace.layout.addTab("deleted-loop", true); + try workspace.layout.addTab("shell-tab", false); + try workspace.openLaunchedNode(0, "next-loop", LoopLaunchWait.open_timeout_ms); + try std.testing.expectEqual(@as(usize, 2), workspace.tabCount()); + try std.testing.expectEqual(@as(usize, 0), workspace.layout.selected_tab); + try std.testing.expectEqualStrings("next-loop", workspace.layout.tabs.items[0].panes.items[0].id); + try std.testing.expect(workspace.layout.tabs.items[0].panes.items[0].launches_agent); + try std.testing.expectEqualStrings("shell-tab", workspace.layout.tabs.items[1].panes.items[0].id); + // The ended loop's recreate is a passive check; it must not cancel the open. + try workspace.openLaunchedNode(0, "deleted-loop", 0); + try std.testing.expect(workspace.isAwaitingLaunch(0)); + try std.testing.expectEqualStrings("next-loop", workspace.launch_waits[0].session); + try std.testing.expect(workspace.launch_waits[0].reports_timeout); +} + test "a loop pane is detached only when the layout owns it and no slot shows or awaits it" { var workspace = try minimalWorkspaceForOptionsTest(std.testing.allocator); defer workspace.layout.deinit(); diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index 2e530538..303b9a87 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -181,7 +181,7 @@ native keyboard/accelerator/window proof; the workspace row remains Partial. | Workspace detail screen | Selected loop replaces canvas detail while sidebar remains | Selecting a sidebar or overview loop now replaces the canvas detail with the full terminal workspace while retaining the sidebar; the workspace UIA tree now exposes a destination-specific toolbar, loop bar, tab controls, and Show in Graph action. The `windows-shell` CI job builds the real Swift daemon, Zig shell, and pinned zmx/Winghostty providers and runs `Tools\windows\uia-live-gate.ps1` against the live workspace: the gate independently asserts the toolbar identity child, Show in Graph child, all three split controls, and at least one tab child are present under a real, non-gated `Workspace.init()` (commit `a31813b`, run https://github.com/scgopi/GraphCode/actions/runs/35415967793, passing). Beta10 Dev Box qualification (D7) then found that opening an attended (Turn-based or main) loop showed a bare `cmd` shell: the pane's `zmx attach` created the session before the daemon launched the agent, so the first instruction was never sent. The shell now asks the daemon to start the loop (`resumeSession`) and attaches only once its session is running, and restores persisted loop panes only while their session is live. That fix has unit evidence and a local dev-layout walkthrough with a stub agent, not a Dev Box requalification with a real backend, so the row is `Partial` | Partial | | Folder toolbar identity | Project name and local/remote identity | Workspace chrome now paints an explicit Workspace title, project name, and Local folder/Remote repository identity over the native header, with a matching stable UIA toolbar child. The live gate's `workspace-toolbar-*` assertion (named `"UIA project"`) now runs against the real shell build and passes on the `windows-shell` CI job (run https://github.com/scgopi/GraphCode/actions/runs/35415967793) | Validated | | Loop bar | Type stripe, title/state pill, live goal, pass trend, elapsed/usage, Stop, Show in graph | The native 46px workspace band shows loop-type stripe, title, state, current activity, backend, elapsed label from `createdAt`, metric-history pass count, token usage when reported, Stop for unresolved loops, and Show in graph. Focused hit-testing/UIA unit coverage plus the live `windows-shell` CI run (workspace toolbar/loop-bar UIA assertions passing at run https://github.com/scgopi/GraphCode/actions/runs/35415967793) validated presence end to end. The beta10 Dev Box qualification (100% DPI) then observed the metadata text drawn under Stop/Show in graph at a 960px window with the sidebar rail and loop detail panel visible. Painting, mouse hit-testing, and UIA bounds now share `LoopBarLayout.zig`, which clamps every text run to the space left of the leftmost button, ellipsizes squeezed runs, and drops runs narrower than 24px; at 960px with rail and panel that drops the metadata line, where macOS truncates instead. Only focused `LoopBarLayout.zig` unit tests cover this; no live observation of the corrected layout has been made | Partial | -| Tab pills | Named tabs, selection, state indicator, shortcuts, per-tab close | The native tab strip paints agent/shell/split labels, live state indicators, Ctrl+1-style shortcut hints, and per-tab close affordances. Close routing removes only the selected tab topology and refuses the final tab. The live gate's `workspace-tab-*` assertion now runs against the real shell build and passes on `windows-shell` (run https://github.com/scgopi/GraphCode/actions/runs/35415967793) | Validated | +| Tab pills | Named tabs, selection, state indicator, shortcuts, per-tab close | The native tab strip paints agent/shell/split labels, live state indicators, Ctrl+1-style shortcut hints, and per-tab close affordances. Close routing removes only the selected tab topology and refuses the final tab. The live gate's `workspace-tab-*` assertion now runs against the real shell build and passes on `windows-shell` (run https://github.com/scgopi/GraphCode/actions/runs/35415967793). The beta17 Dev Box qualification then found a native **New Tab** click drew two tabs while UIA still exposed one `workspace-tab-*` element, and that the new shell started in the install `bin` directory instead of the loop's folder. Native New Tab, split, next/previous tab, and tab select/close clicks now republish UIA, and new shells start in the loop's worktree, else its project folder (home for a global loop; the shell's own directory for a remote project or a missing folder), as on macOS. A native-linked App test with real winghostty surfaces drives the clicks through `WM_LBUTTONDOWN/UP` and asserts the published tab count and each new shell's starting directory; no Dev Box rerun has confirmed either, so the row is `Partial` | Partial | | Split controls | Visible Split Right, Split Down, New Tab buttons | The terminal tab bar renders distinct New Tab, Split Right, and Split Down controls with shared geometry helpers used by painting and hit testing, plus UIA children and focused gap-boundary regression coverage. The live gate's split-control assertion (`workspace-(new-tab\|split-right\|split-down)-*`, all three present) now runs against the real shell build and passes on `windows-shell` (run https://github.com/scgopi/GraphCode/actions/runs/35415967793) | Validated | | Pane headers | agent/shell identity, backend/shell detail, focused state | Product-owned pane headers distinguish agent and shell panes, label the zmx session detail, add truthful backend: agent/backend: shell detail, and retain the explicit focused-pane accent. Focused rendering unit coverage plus the live `windows-shell` CI run (real workspace/terminal panes, run https://github.com/scgopi/GraphCode/actions/runs/35415967793) provide the side-by-side live evidence that was previously blocked | Validated | | Mounted background tabs | Switching preserves live terminal surfaces | Workspace implementation tests cover topology. The live UIA gate creates a second tab and attempts a switch/identity round trip, but its former `^workspace-tab-` selectors also matched `workspace-tab-close-*` controls (whose name is `Close tab`), so an indexed invocation could close rather than select a tab. All seven actual-tab predicates now require numeric tab IDs; an AST-extracted source regression checks interleaved, close-first, reversed, no-close, close-only, empty, and split-tab child lists for exact selection and order. This source contract does not demonstrate backend continuity or a new live UIA round trip with the corrected gate | Partial |