From 28a415a334163c8e0ae399c86098da7bbc4d823c Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 21 Sep 2026 10:13:13 -0700 Subject: [PATCH] Implement sidebar navigation parity Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Tools/windows/uia-live-gate.ps1 | 189 ++++++- .../src/AccessibilityProvider.cpp | 20 +- graphcode-windows/src/App.zig | 406 ++++++++++++++- graphcode-windows/src/DaemonClient.zig | 22 + graphcode-windows/src/MainWindow.zig | 60 ++- graphcode-windows/src/Sidebar.zig | 478 +++++++++++++++++- graphcode-windows/src/Wire.zig | 26 + investigation/ui-parity-matrix.md | 14 +- 8 files changed, 1138 insertions(+), 77 deletions(-) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index c7c08a55..d16a42c9 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -2,7 +2,8 @@ param( [Parameter(Mandatory)] [string] $Shell, [string] $Zmx = "", - [string[]] $ArgumentList = @() + [string[]] $ArgumentList = @(), + [switch] $SidebarParityOnly ) $ErrorActionPreference = "Stop" @@ -181,7 +182,8 @@ public static class GraphCodeUiaGateState { public static bool FocusControl(IntPtr parent, IntPtr control) { if (parent == IntPtr.Zero || control == IntPtr.Zero) return false; ActivateWindow(parent); - uint parentThread = GetWindowThreadProcessId(parent, out _); + uint ignoredProcessId; + uint parentThread = GetWindowThreadProcessId(parent, out ignoredProcessId); uint currentThread = GetCurrentThreadId(); bool attached = currentThread != parentThread && AttachThreadInput(currentThread, parentThread, true); @@ -195,9 +197,11 @@ public static class GraphCodeUiaGateState { public static bool ActivateWindow(IntPtr window) { if (window == IntPtr.Zero) return false; IntPtr foreground = GetForegroundWindow(); + uint ignoredForegroundProcessId; uint foregroundThread = foreground == IntPtr.Zero ? 0 : - GetWindowThreadProcessId(foreground, out _); - uint targetThread = GetWindowThreadProcessId(window, out _); + GetWindowThreadProcessId(foreground, out ignoredForegroundProcessId); + uint ignoredTargetProcessId; + uint targetThread = GetWindowThreadProcessId(window, out ignoredTargetProcessId); uint currentThread = GetCurrentThreadId(); bool attachForeground = foregroundThread != 0 && currentThread != foregroundThread && @@ -480,6 +484,9 @@ $oldSupportDirectory = [Environment]::GetEnvironmentVariable("GRAPHCODE_SUPPORT_ $oldResetSidebar = [Environment]::GetEnvironmentVariable("GRAPHCODE_UIA_RESET_SIDEBAR") $oldUpdateAvailable = [Environment]::GetEnvironmentVariable("GRAPHCODE_UIA_UPDATE_AVAILABLE") $oldShowUpdate = [Environment]::GetEnvironmentVariable("GRAPHCODE_UIA_SHOW_UPDATE") +$oldIngressError = [Environment]::GetEnvironmentVariable("GRAPHCODE_UIA_INGRESS_ERROR") +$oldDaemonCommandLog = [Environment]::GetEnvironmentVariable("GRAPHCODE_UIA_DAEMON_COMMAND_LOG") +$oldShellExecuteLog = [Environment]::GetEnvironmentVariable("GRAPHCODE_UIA_SHELL_EXECUTE_LOG") $process = $null $settingsProcess = $null $status = $null @@ -499,6 +506,8 @@ $policyContents = $null $settingsDirectory = $null $settingsPath = $null $settingsErrorPath = $null +$daemonCommandLogPath = $null +$shellExecuteLogPath = $null try { if ($Zmx) { $env:GRAPHCODE_ZMX = $Zmx } $env:GRAPHCODE_GATE_CWD = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path @@ -509,6 +518,12 @@ try { $env:USERNAME = "GraphCodeUIAGate" $env:GRAPHCODE_UIA_FIXTURE_ROWS = "C:\fixture-safe|safe,C:\fixture-unsafe|unsafe" $env:GRAPHCODE_DAEMON_PIPE = "\\.\pipe\graphcode-uia-gate-$PID" + $daemonCommandLogPath = Join-Path $env:GRAPHCODE_GATE_CWD ".graphcode-uia-daemon-command-$PID.json" + $shellExecuteLogPath = Join-Path $env:GRAPHCODE_GATE_CWD ".graphcode-uia-shell-execute-$PID.log" + Remove-Item -LiteralPath $daemonCommandLogPath -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $shellExecuteLogPath -Force -ErrorAction SilentlyContinue + $env:GRAPHCODE_UIA_DAEMON_COMMAND_LOG = $daemonCommandLogPath + $env:GRAPHCODE_UIA_SHELL_EXECUTE_LOG = $shellExecuteLogPath $settingsDirectory = Join-Path $env:GRAPHCODE_GATE_CWD ".graphcode-uia-product-settings-$PID" $settingsPath = Join-Path $settingsDirectory "settings.json" $settingsErrorPath = Join-Path $settingsDirectory "stderr.log" @@ -620,15 +635,22 @@ try { $navigationIds = @("overview-destination", "quick-chats-destination") $canvasActionIds = @("canvas-primary-action", "zoom-out", "actual-size", "zoom-in", "fit-canvas") $projectRows = @(Get-DirectChildren $projects $rawWalker | Where-Object { $_.Current.AutomationId -match '^project-row-' }) + $openProjectRows = @(Get-DirectChildren $projects $rawWalker | Where-Object { $_.Current.AutomationId -match '^open-project-' }) $graphDestination = Find-FragmentById $root "overview-destination" $rawWalker Require (($null -ne $graphDestination) -and ($graphDestination.Current.Name -eq "Graph")) ` "global sidebar destination did not expose the pinned Graph identity" - Require ($projectRows.Count -eq 3) "Projects did not expose grouped recent rows and the open project row" - Require ((@($projectRows | ForEach-Object { $_.Current.Name }) -join "|") -eq "Fixture local|Fixture remote|UIA project") "dynamic project row names were not synchronized" + Require ($projectRows.Count -eq 1) "Projects did not expose grouped recent rows" + Require ($openProjectRows.Count -eq 1) "Projects did not expose the separate open-project row" + Require ((@($projectRows | ForEach-Object { $_.Current.Name }) -join "|") -eq "Fixture remote") "recent project row names were not synchronized" + Require ($openProjectRows[0].Current.Name -eq "UIA project") "open project row name was not synchronized" foreach ($projectRow in $projectRows) { Require (($projectRow.Current.BoundingRectangle.Width -gt 0) -and ($projectRow.Current.BoundingRectangle.Height -gt 0)) "dynamic project row has empty bounds" } + foreach ($projectRow in $openProjectRows) { + Require (($projectRow.Current.BoundingRectangle.Width -gt 0) -and + ($projectRow.Current.BoundingRectangle.Height -gt 0)) "open project row has empty bounds" + } $needsYouRows = @(Get-DirectChildren $projects $rawWalker | Where-Object { $_.Current.AutomationId -match '^needs-you-row-' }) @@ -687,31 +709,27 @@ try { ForEach-Object { $_.Current.AutomationId } | Where-Object { $_ }) $null = Assert-FragmentLinks $projects $rawWalker $projectChildIds "RawView Projects" $null = Assert-FragmentLinks $projects $controlWalker $projectChildIds "ControlView Projects" - $localSection = @(Get-DirectChildren $projects $rawWalker | Where-Object { - $_.Current.AutomationId -match '^sidebar-section-' -and $_.Current.Name -eq "Local Projects" - }) | Select-Object -First 1 $remoteSection = @(Get-DirectChildren $projects $rawWalker | Where-Object { $_.Current.AutomationId -match '^sidebar-section-' -and $_.Current.Name -eq "Remote Repositories" }) | Select-Object -First 1 - Require (($null -ne $localSection) -and ($null -ne $remoteSection)) ` - "Local and Remote sidebar sections did not expose independent actions" + Require ($null -ne $remoteSection) "Remote sidebar section did not expose its section action" $remoteRowId = @($projectRows | Where-Object { $_.Current.Name -eq "Fixture remote" })[0].Current.AutomationId - $localSection.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + $remoteSection.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() Start-Sleep -Milliseconds 150 $collapsedProjectNames = @(Get-DirectChildren $projects $rawWalker | - Where-Object { $_.Current.AutomationId -match '^project-row-' } | + Where-Object { $_.Current.AutomationId -match '^(project-row|open-project)-' } | ForEach-Object { $_.Current.Name }) - Require (("Fixture local" -notin $collapsedProjectNames) -and - ("Fixture remote" -in $collapsedProjectNames)) ` - "Local section collapse affected the Remote section or retained its Local child" - $remoteAfterLocalCollapse = @(Get-DirectChildren $projects $rawWalker | Where-Object { - $_.Current.AutomationId -eq $remoteRowId + Require (("Fixture remote" -notin $collapsedProjectNames) -and + ("UIA project" -in $collapsedProjectNames)) ` + "Remote section collapse did not hide only the unopened recent folder" + $openProjectAfterRemoteCollapse = @(Get-DirectChildren $projects $rawWalker | Where-Object { + $_.Current.AutomationId -match '^open-project-' -and $_.Current.Name -eq 'UIA project' }) | Select-Object -First 1 - Require ($null -ne $remoteAfterLocalCollapse) "Remote row identity changed during Local collapse" - $localSection = @(Get-DirectChildren $projects $rawWalker | Where-Object { - $_.Current.AutomationId -match '^sidebar-section-' -and $_.Current.Name -eq "Local Projects" + Require ($null -ne $openProjectAfterRemoteCollapse) "open project row disappeared when collapsing recents" + $remoteSection = @(Get-DirectChildren $projects $rawWalker | Where-Object { + $_.Current.AutomationId -match '^sidebar-section-' -and $_.Current.Name -eq "Remote Repositories" }) | Select-Object -First 1 - $localSection.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + $remoteSection.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() Start-Sleep -Milliseconds 150 $loopRows = @(Get-DirectChildren $loops $rawWalker | Where-Object { @@ -936,7 +954,7 @@ try { $process.Refresh() Require (-not $process.HasExited) "surface UIA actions terminated the shell" $activeProjectRow = @(Get-DirectChildren $projects $rawWalker | Where-Object { - $_.Current.AutomationId -match '^project-row-' -and $_.Current.Name -eq "UIA project" + $_.Current.AutomationId -match '^open-project-' -and $_.Current.Name -eq "UIA project" }) | Select-Object -First 1 $activeLoopRow = @(Get-DirectChildren $loops $rawWalker | Where-Object { $_.Current.AutomationId -match '^loop-row-' -and $_.Current.Name -eq "UIA loop A" @@ -1745,6 +1763,116 @@ try { )) "About dialog rejected its close command" Start-Sleep -Milliseconds 250 + Require ([GraphCodeUiaGateState]::PostFixtureMutation($shellWindow, 20)) ` + "sidebar parity fixture reset was rejected" + Start-Sleep -Milliseconds 200 + Require ([GraphCodeUiaGateState]::PostFixtureMutation($shellWindow, 19)) ` + "sidebar ingress-error fixture mutation was rejected" + Start-Sleep -Milliseconds 150 + $sidebarErrorFooter = @(Get-DirectChildren $projects $rawWalker | Where-Object { + $_.Current.AutomationId -match '^sidebar-error-footer-' -and + $_.Current.Name -eq 'Folder could not be opened because the background service returned a detailed error that should wrap cleanly in the sidebar footer.' + }) | Select-Object -First 1 + Require ($null -ne $sidebarErrorFooter) "sidebar error footer omitted its dedicated UIA identity" + Require (($sidebarErrorFooter.Current.BoundingRectangle.Width -gt 0) -and + ($sidebarErrorFooter.Current.BoundingRectangle.Height -gt 24)) ` + "sidebar error footer did not expose wrapped multi-line bounds" + + $needsYouStop = @(Get-DirectChildren $projects $rawWalker | Where-Object { + $_.Current.AutomationId -match '^needs-you-stop-' -and $_.Current.Name -eq "Stop loop" + }) | Select-Object -First 1 + Require ($null -ne $needsYouStop) "Needs-you row omitted its Stop action" + Remove-Item -LiteralPath $daemonCommandLogPath -Force -ErrorAction SilentlyContinue + $needsYouStop.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + for ($index = 0; $index -lt 40 -and -not (Test-Path -LiteralPath $daemonCommandLogPath); $index++) { + Start-Sleep -Milliseconds 50 + } + Require (Test-Path -LiteralPath $daemonCommandLogPath) "Needs-you Stop did not emit a daemon command" + $needsYouStopCommand = [IO.File]::ReadAllText($daemonCommandLogPath) + Require ($needsYouStopCommand -match '"projectPath":"C:\\\\GraphCode\\\\fixture"') ` + "Needs-you Stop routed to the wrong project: $needsYouStopCommand" + Require ($needsYouStopCommand -match '"stopNode":\{"_0":"22222222-2222-4222-8222-222222222222"\}') ` + "Needs-you Stop routed to the wrong loop: $needsYouStopCommand" + + Remove-Item -LiteralPath $daemonCommandLogPath -Force -ErrorAction SilentlyContinue + Require ([GraphCodeUiaGateState]::PostFixtureMutation($shellWindow, 16)) ` + "sidebar root reorder fixture mutation was rejected" + for ($index = 0; $index -lt 40 -and -not (Test-Path -LiteralPath $daemonCommandLogPath); $index++) { + Start-Sleep -Milliseconds 50 + } + $reorderedLoopRows = @(Get-DirectChildren $loops $rawWalker | Where-Object { + $_.Current.AutomationId -match '^loop-row-' + }) + $reorderedRootNames = @($reorderedLoopRows | Where-Object { + $_.Current.Name -in @("UIA loop A", "UIA loop C") + } | ForEach-Object { $_.Current.Name }) + Require ((($reorderedRootNames -join '|') -eq 'UIA loop C|UIA loop A') -or + (($reorderedRootNames -join '|') -eq 'UIA loop C|UIA loop A|UIA loop B')) ` + "sidebar root reorder was not observable in the loop tree: $($reorderedRootNames -join '|')" + Require (Test-Path -LiteralPath $daemonCommandLogPath) "sidebar root reorder did not emit a daemon command" + $reorderCommand = [IO.File]::ReadAllText($daemonCommandLogPath) + Require ($reorderCommand -match '"sidebarNodesReordered"') ` + "sidebar root reorder did not use the sidebar-order daemon command: $reorderCommand" + + Remove-Item -LiteralPath $shellExecuteLogPath -Force -ErrorAction SilentlyContinue + Require ([GraphCodeUiaGateState]::PostFixtureMutation($shellWindow, 17)) ` + "project Move fixture mutation was rejected" + for ($index = 0; $index -lt 40 -and -not (Test-Path -LiteralPath $shellExecuteLogPath); $index++) { + Start-Sleep -Milliseconds 50 + } + Require (Test-Path -LiteralPath $shellExecuteLogPath) "project Move did not record its Explorer request" + $moveProjectLog = [IO.File]::ReadAllText($shellExecuteLogPath) + Require ($moveProjectLog -match 'file=explorer\.exe') "project Move did not route through Explorer" + Require ($moveProjectLog -match 'parameters=/select,"C:\\GraphCode\\fixture"') ` + "project Move did not target the expected folder: $moveProjectLog" + + Require ([GraphCodeUiaGateState]::PostFixtureMutation($shellWindow, 18)) ` + "activity fixture mutation was rejected" + Start-Sleep -Milliseconds 200 + $activityFilter = @(Get-DirectChildren $projects $rawWalker | Where-Object { + $_.Current.AutomationId -match '^activity-filter-' + }) | Select-Object -First 1 + $activityRight = @(Get-DirectChildren $projects $rawWalker | Where-Object { + $_.Current.AutomationId -match '^activity-control-' -and $_.Current.Name -eq 'Scroll activity right' + }) | Select-Object -First 1 + Require (($null -ne $activityFilter) -and ($null -ne $activityRight)) ` + "activity strip omitted its filter or scroll affordances" + $activityBeforeScroll = @(Get-DirectChildren $projects $rawWalker | Where-Object { + $_.Current.AutomationId -match '^activity-row-' + } | ForEach-Object { $_.Current.Name }) + Require (($activityBeforeScroll -join '|') -eq 'Activity E|Activity D') ` + "activity strip did not expose the expected initial viewport: $($activityBeforeScroll -join '|')" + $activityRight.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + Start-Sleep -Milliseconds 150 + $activityAfterScroll = @(Get-DirectChildren $projects $rawWalker | Where-Object { + $_.Current.AutomationId -match '^activity-row-' + } | ForEach-Object { $_.Current.Name }) + Require (($activityAfterScroll -join '|') -eq 'Activity D|Activity C') ` + "activity strip scroll-right did not advance the live viewport: $($activityAfterScroll -join '|')" + $activityFilter.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + Start-Sleep -Milliseconds 150 + $filteredActivityRows = @(Get-DirectChildren $projects $rawWalker | Where-Object { + $_.Current.AutomationId -match '^activity-row-' + }) + $filteredActivityNames = @($filteredActivityRows | ForEach-Object { $_.Current.Name }) + Require (($filteredActivityNames -join '|') -eq 'Activity C|Activity B') ` + "activity attention-only filter did not reduce the strip to attention rows: $($filteredActivityNames -join '|')" + $activityNavigationRow = @($filteredActivityRows | Where-Object { $_.Current.Name -eq 'Activity C' }) | Select-Object -First 1 + Require ($null -ne $activityNavigationRow) "activity strip omitted the Activity C card after filtering" + $activityNavigationRow.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + Start-Sleep -Milliseconds 250 + $workspaceLoopBar = @(Get-DirectChildren $graph $rawWalker | Where-Object { + $_.Current.AutomationId -match '^workspace-loop-bar-' + }) | Select-Object -First 1 + Require ($null -ne $workspaceLoopBar) "activity navigation did not open a workspace" + $selectedWorkspaceCard = @(Get-DirectChildren $graph $rawWalker | Where-Object { + $_.Current.AutomationId -match '^canvas-card-' -and + $_.Current.Name -eq 'Activity C' -and + $_.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern).Current.IsSelected + }) | Select-Object -First 1 + Require ($null -ne $selectedWorkspaceCard) "activity navigation did not select the targeted loop" + if ($SidebarParityOnly) { return } + Require $process.CloseMainWindow() "shell refused caption close" Start-Sleep -Milliseconds 250 $process.Refresh() @@ -2083,6 +2211,12 @@ try { if ($settingsDirectory) { Remove-Item -LiteralPath $settingsDirectory -Recurse -Force -ErrorAction SilentlyContinue } + if ($daemonCommandLogPath) { + Remove-Item -LiteralPath $daemonCommandLogPath -Force -ErrorAction SilentlyContinue + } + if ($shellExecuteLogPath) { + Remove-Item -LiteralPath $shellExecuteLogPath -Force -ErrorAction SilentlyContinue + } if ($null -eq $oldZmx) { Remove-Item Env:GRAPHCODE_ZMX -ErrorAction SilentlyContinue } else { $env:GRAPHCODE_ZMX = $oldZmx } if ($null -eq $oldCwd) { Remove-Item Env:GRAPHCODE_GATE_CWD -ErrorAction SilentlyContinue } @@ -2112,4 +2246,13 @@ try { if ($null -eq $oldShowUpdate) { Remove-Item Env:GRAPHCODE_UIA_SHOW_UPDATE -ErrorAction SilentlyContinue } else { $env:GRAPHCODE_UIA_SHOW_UPDATE = $oldShowUpdate } + if ($null -eq $oldIngressError) { + Remove-Item Env:GRAPHCODE_UIA_INGRESS_ERROR -ErrorAction SilentlyContinue + } else { $env:GRAPHCODE_UIA_INGRESS_ERROR = $oldIngressError } + if ($null -eq $oldDaemonCommandLog) { + Remove-Item Env:GRAPHCODE_UIA_DAEMON_COMMAND_LOG -ErrorAction SilentlyContinue + } else { $env:GRAPHCODE_UIA_DAEMON_COMMAND_LOG = $oldDaemonCommandLog } + if ($null -eq $oldShellExecuteLog) { + Remove-Item Env:GRAPHCODE_UIA_SHELL_EXECUTE_LOG -ErrorAction SilentlyContinue + } else { $env:GRAPHCODE_UIA_SHELL_EXECUTE_LOG = $oldShellExecuteLog } } diff --git a/graphcode-windows/src/AccessibilityProvider.cpp b/graphcode-windows/src/AccessibilityProvider.cpp index 98348ac7..55b38ed5 100644 --- a/graphcode-windows/src/AccessibilityProvider.cpp +++ b/graphcode-windows/src/AccessibilityProvider.cpp @@ -166,7 +166,16 @@ class Node final : public IRawElementProviderSimple, property == UIA_IsEnabledPropertyId || property == UIA_IsControlElementPropertyId || property == UIA_IsContentElementPropertyId) { - bool_value = true; + if (isRowKey(id_)) { + const Row &row = state_->rows.at(id_); + const bool sidebar_error_footer = + row.identity.rfind("sidebar-error-footer:", 0) == 0; + bool_value = sidebar_error_footer + ? (property != UIA_IsKeyboardFocusablePropertyId) + : true; + } else { + bool_value = true; + } kind = kBool; } else if (property == UIA_HasKeyboardFocusPropertyId) { bool_value = state_->focused == id_; @@ -775,9 +784,12 @@ class Node final : public IRawElementProviderSimple, row.identity.rfind("sidebar-section:", 0) == 0 ? L"sidebar-section-" : row.identity.rfind("needs-you-header:", 0) == 0 ? L"needs-you-header-" : row.identity.rfind("needs-you-row:", 0) == 0 ? L"needs-you-row-" : + row.identity.rfind("needs-you-stop:", 0) == 0 ? L"needs-you-stop-" : row.identity.rfind("activity-header:", 0) == 0 ? L"activity-header-" : + row.identity.rfind("activity-filter:", 0) == 0 ? L"activity-filter-" : row.identity.rfind("activity-row:", 0) == 0 ? L"activity-row-" : row.identity.rfind("activity-control:", 0) == 0 ? L"activity-control-" : + row.identity.rfind("open-project:", 0) == 0 ? L"open-project-" : row.identity.rfind("project-new-loop:", 0) == 0 ? L"project-new-loop-" : row.identity.rfind("project-disclosure:", 0) == 0 ? L"project-disclosure-" : row.identity.rfind("quick-chats-header:", 0) == 0 ? L"quick-chats-header-" : @@ -795,6 +807,7 @@ class Node final : public IRawElementProviderSimple, row.identity.rfind("workspace-new-tab:", 0) == 0 ? L"workspace-new-tab-" : row.identity.rfind("workspace-split-right:", 0) == 0 ? L"workspace-split-right-" : row.identity.rfind("workspace-split-down:", 0) == 0 ? L"workspace-split-down-" : + row.identity.rfind("sidebar-error-footer:", 0) == 0 ? L"sidebar-error-footer-" : parent == 1 ? L"project-row-" : parent == 2 ? L"loop-row-" : parent == 3 ? L"worktree-row-" : L"canvas-card-"; @@ -814,10 +827,15 @@ class Node final : public IRawElementProviderSimple, if (id_ >= 1 && id_ <= 3) return UIA_ListControlTypeId; if (isRowKey(id_)) { const Row &row = state_->rows.at(id_); + if (row.identity.rfind("sidebar-error-footer:", 0) == 0) { + return UIA_TextControlTypeId; + } const bool action = row.identity.rfind("sidebar-section:", 0) == 0 || row.identity.rfind("needs-you-header:", 0) == 0 || + row.identity.rfind("needs-you-stop:", 0) == 0 || row.identity.rfind("activity-header:", 0) == 0 || + row.identity.rfind("activity-filter:", 0) == 0 || row.identity.rfind("activity-control:", 0) == 0 || row.identity.rfind("project-new-loop:", 0) == 0 || row.identity.rfind("project-disclosure:", 0) == 0 || diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 2e4a096a..4137c06f 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -136,7 +136,11 @@ const UiaDynamicTarget = union(enum) { new_quick_chat, needs_you_header, needs_you: usize, + needs_you_stop: usize, activity_header, + activity_filter, + activity_left, + activity_right, activity: usize, recent_project: []const u8, open_project: []const u8, @@ -189,6 +193,11 @@ pub const App = struct { sidebar_state: Sidebar.State, sidebar_store: ?Sidebar.Store = null, sidebar_hover_y: i32 = -1, + sidebar_drag_project_path: []u8 = &.{}, + sidebar_drag_node_id: []u8 = &.{}, + sidebar_drag_origin_y: i32 = 0, + sidebar_drag_active: bool = false, + sidebar_drag_started: bool = false, workspace: ?*TerminalWorkspace.Workspace = null, navigation_cursor: Navigation.Cursor = .{}, workspace_controls: WorkspaceControls.State = .{ .panel_visible = false }, @@ -337,6 +346,8 @@ pub const App = struct { if (self.pending_previous_subscription.len != 0) self.allocator.free(self.pending_previous_subscription); if (self.status_override.len != 0) self.allocator.free(self.status_override); if (self.ingress_error.len != 0) self.allocator.free(self.ingress_error); + if (self.sidebar_drag_project_path.len != 0) self.allocator.free(self.sidebar_drag_project_path); + if (self.sidebar_drag_node_id.len != 0) self.allocator.free(self.sidebar_drag_node_id); for (self.declared_entry_ids.items) |id| self.allocator.free(id); self.declared_entry_ids.deinit(); for (self.kept_worktree_paths.items) |path| self.allocator.free(path); @@ -419,7 +430,7 @@ pub const App = struct { self.updateNativeChrome(); if (std.process.getEnvVarOwned(self.allocator, "GRAPHCODE_UIA_FIXTURE_ROWS")) |fixture| { defer self.allocator.free(fixture); - self.installUiaFixture(); + self.installUiaFixture(true); if (envFlag("GRAPHCODE_UIA_SHOW_SWEEP")) self.presentWorktreeSweep(); } else |_| {} const uia_gate = std.process.getEnvVarOwned(self.allocator, "GRAPHCODE_UIA_GATE") catch null; @@ -822,6 +833,90 @@ pub const App = struct { _ = c.ReleaseCapture(); } + fn clearSidebarRootDrag(self: *App) void { + if (self.sidebar_drag_project_path.len != 0) self.allocator.free(self.sidebar_drag_project_path); + if (self.sidebar_drag_node_id.len != 0) self.allocator.free(self.sidebar_drag_node_id); + self.sidebar_drag_project_path = &.{}; + self.sidebar_drag_node_id = &.{}; + self.sidebar_drag_origin_y = 0; + self.sidebar_drag_active = false; + self.sidebar_drag_started = false; + } + + fn beginSidebarRootDrag(self: *App, project_path: []const u8, node_id: []const u8, y: i32) void { + self.clearSidebarRootDrag(); + self.sidebar_drag_project_path = self.allocator.dupe(u8, project_path) catch return; + self.sidebar_drag_node_id = self.allocator.dupe(u8, node_id) catch { + self.allocator.free(self.sidebar_drag_project_path); + self.sidebar_drag_project_path = &.{}; + return; + }; + self.sidebar_drag_origin_y = y; + self.sidebar_drag_active = true; + } + + fn updateSidebarRootDrag(self: *App, y: i32) void { + if (!self.sidebar_drag_active or self.sidebar_drag_started) return; + if (@abs(y - self.sidebar_drag_origin_y) < 6) return; + self.sidebar_drag_started = true; + _ = c.SetCapture(self.window.hwnd); + } + + fn sidebarRootDropIndex(self: *App, project_path: []const u8, y: i32) ?usize { + var client: c.RECT = undefined; + if (c.GetClientRect(self.window.hwnd, &client) == 0) return null; + var rows = Sidebar.appendRows( + self.allocator, + &self.model, + if (self.worktree_inspection) |*value| value else null, + self.sidebar_scroll, + &self.sidebar_state, + ) catch return null; + defer rows.deinit(self.allocator); + var count: usize = 0; + for (rows.items) |row| { + if (row.kind != .loop or row.depth != 0) continue; + if (row.project_path == null or !std.mem.eql(u8, row.project_path.?, project_path)) continue; + if (y < row.top + 12) return count; + count += 1; + } + return count; + } + + fn completeSidebarRootDrag(self: *App, y: i32) bool { + if (!self.sidebar_drag_active) return false; + defer { + if (self.sidebar_drag_started) _ = c.ReleaseCapture(); + self.clearSidebarRootDrag(); + } + if (!self.sidebar_drag_started) return false; + const graph = self.model.graphFor(self.sidebar_drag_project_path) orelse return true; + const drop_index = self.sidebarRootDropIndex(self.sidebar_drag_project_path, y) orelse return true; + const changed = Sidebar.reorderRootIDs( + &self.sidebar_state, + self.allocator, + graph.nodes.items, + graph.edges.items, + self.sidebar_drag_node_id, + drop_index, + ) catch { + self.setStatus("Sidebar root order could not be updated"); + return true; + }; + if (!changed) return true; + if (self.sidebar_store) |*store| store.save(&self.sidebar_state) catch self.setStatus("Sidebar root order could not be saved"); + var roots = Sidebar.rootIDs(self.allocator, graph.nodes.items, graph.edges.items, &self.sidebar_state) catch { + self.setStatus("Sidebar root order could not be collected"); + return true; + }; + defer roots.deinit(self.allocator); + self.client.sendSidebarRootOrder(self.sidebar_drag_project_path, roots.items); + self.setStatus("Sidebar root order updated"); + self.syncAccessibility(); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + return true; + } + fn copyEdgeDragSourceForDrop(self: *App) ?[]u8 { const source_id = self.canvas.endEdgeDrag() orelse return null; return self.allocator.dupe(u8, source_id) catch { @@ -982,6 +1077,19 @@ pub const App = struct { if (self.model.selected_index) |index| _ = self.selectNodeIndex(index); } + fn stopAttentionEntry(self: *App, entry: GraphModel.AttentionEntry) void { + self.client.sendNodeAction(entry.project_path, entry.node.id, "stopNode", null); + const project_name = if (self.model.graphFor(entry.project_path)) |graph| graph.project.name else entry.project_path; + const message = std.fmt.allocPrint(self.allocator, "Stopping {s} in {s}...", .{ entry.node.title, project_name }) catch return; + self.replaceStatus(message); + } + + fn navigateToActivityEvent(self: *App, event: GraphModel.ActivityEvent) void { + const graph = self.model.graphFor(event.project_path) orelse return; + const index = GraphModel.findNodeIndexByID(graph.nodes.items, event.node_id) orelse return; + self.openLoopFromAccessibility(event.project_path, index); + } + fn selectedEdgeIndex(self: *const App) ?usize { if (self.selected_edge_id.len == 0 or self.selected_edge_project_path.len == 0) return null; const graph = self.model.graph orelse return null; @@ -1928,12 +2036,31 @@ pub const App = struct { _ = c.InvalidateRect(self.window.hwnd, null, 0); } - fn revealProjectPath(self: *App, path: []const u8) void { + fn launchExplorer(self: *App, path: []const u8, success_status: []const u8) void { const parameters = WorktreeStatus.explorerParameters(self.allocator, path) catch { self.setStatus("Unable to prepare Explorer"); return; }; defer self.allocator.free(parameters); + if (std.process.getEnvVarOwned(self.allocator, "GRAPHCODE_UIA_SHELL_EXECUTE_LOG")) |log_path| { + defer self.allocator.free(log_path); + const payload = std.fmt.allocPrint(self.allocator, "verb=open\nfile=explorer.exe\nparameters={s}\n", .{parameters}) catch { + self.setStatus("Unable to record Explorer request"); + return; + }; + defer self.allocator.free(payload); + var file = std.fs.createFileAbsolute(log_path, .{ .truncate = true }) catch { + self.setStatus("Unable to record Explorer request"); + return; + }; + defer file.close(); + file.writeAll(payload) catch { + self.setStatus("Unable to record Explorer request"); + return; + }; + self.setStatus(success_status); + return; + } else |_| {} const wide_raw = std.unicode.utf8ToUtf16LeAlloc(self.allocator, parameters) catch { self.setStatus("Unable to encode Explorer path"); return; @@ -1951,7 +2078,11 @@ pub const App = struct { null, c.SW_SHOWNORMAL, ); - self.setStatus(if (@intFromPtr(result) <= 32) "Unable to open Explorer" else "Opened project in Explorer"); + self.setStatus(if (@intFromPtr(result) <= 32) "Unable to open Explorer" else success_status); + } + + fn revealProjectPath(self: *App, path: []const u8) void { + self.launchExplorer(path, "Opened project in Explorer"); } fn trashProjectPath(self: *App, path: []const u8) void { @@ -2176,8 +2307,8 @@ pub const App = struct { self.inspectWorktreesImpl(false); } - fn installUiaFixture(self: *App) void { - if (envFlag("GRAPHCODE_UIA_RESET_SIDEBAR")) self.sidebar_state.clearExpandedNodes(); + fn installUiaFixture(self: *App, reset_sidebar: bool) void { + if (reset_sidebar and envFlag("GRAPHCODE_UIA_RESET_SIDEBAR")) self.sidebar_state.clearExpandedNodes(); const graph_frame = \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"uia-graph","project":{"path":"C:\\GraphCode\\fixture","name":"UIA project","remote":false},"nodes":[{"id":"11111111-1111-4111-8111-111111111111","title":"UIA loop A","loopType":"goalBased","state":"succeeded","activity":"checking tests","presence":{"presence":"idle","confidence":"reported"},"goal":{"summary":"All tests pass","predicate":"swift test","metric":{"command":"coverage","direction":"maximize"}},"modelTier":"capable","worktreeBinding":{"path":"C:\\fixture-safe","branch":"feature/parity"}},{"id":"22222222-2222-4222-8222-222222222222","title":"UIA loop B","loopType":"proactive","state":"running","activity":"needs response","presence":{"presence":"awaitingInput","confidence":"reported"},"subGraph":{"nodes":[{"id":"55555555-5555-4555-8555-555555555555","title":"UIA nested A","loopType":"turnBased","state":"idle"},{"id":"66666666-6666-4666-8666-666666666666","title":"UIA nested B","loopType":"goalBased","state":"running"}]}}],"edges":[{"id":"88888888-8888-4888-8888-888888888888","from":"11111111-1111-4111-8111-111111111111","to":"22222222-2222-4222-8222-222222222222","kind":"handoff"}]}}} ; @@ -2476,6 +2607,95 @@ pub const App = struct { self.openProductSettings(); return; } + if (mutation == 16) { + const project_path = "C:\\GraphCode\\fixture"; + if (self.model.graphFor(project_path)) |graph| { + if (GraphModel.findNodeIndexByID(graph.nodes.items, "77777777-7777-4777-8777-777777777777") == null) { + const extra = GraphModel.Node{ + .id = self.allocator.dupe(u8, "77777777-7777-4777-8777-777777777777") catch return, + .title = self.allocator.dupe(u8, "UIA loop C") catch return, + .loop_type = self.allocator.dupe(u8, "turnBased") catch return, + .state = self.allocator.dupe(u8, "idle") catch return, + .activity = self.allocator.dupe(u8, "") catch return, + .presence = self.allocator.dupe(u8, "idle") catch return, + }; + if (self.model.graph) |*current| if (std.mem.eql(u8, current.project.path, project_path)) { + current.nodes.append(extra) catch return; + }; + for (self.model.graphs.items) |*summary| { + if (!std.mem.eql(u8, summary.project.path, project_path)) continue; + summary.nodes.append(.{ + .id = self.allocator.dupe(u8, extra.id) catch return, + .title = self.allocator.dupe(u8, extra.title) catch return, + .loop_type = self.allocator.dupe(u8, extra.loop_type) catch return, + .state = self.allocator.dupe(u8, extra.state) catch return, + .activity = self.allocator.dupe(u8, extra.activity) catch return, + .presence = self.allocator.dupe(u8, extra.presence) catch return, + }) catch return; + } + } + } else return; + self.syncAccessibility(); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + var rows = Sidebar.appendRows( + self.allocator, + &self.model, + if (self.worktree_inspection) |*value| value else null, + self.sidebar_scroll, + &self.sidebar_state, + ) catch return; + defer rows.deinit(self.allocator); + var start_y: ?i32 = null; + var drop_y: ?i32 = null; + for (rows.items) |row| { + if (row.kind != .loop or row.depth != 0 or row.project_path == null or !std.mem.eql(u8, row.project_path.?, "C:\\GraphCode\\fixture")) continue; + if (row.index == 0) start_y = row.top + 8; + if (row.index == 2) drop_y = row.top + 20; + } + if (start_y) |drag_start| { + self.beginSidebarRootDrag("C:\\GraphCode\\fixture", "11111111-1111-4111-8111-111111111111", drag_start); + self.updateSidebarRootDrag(drop_y orelse (drag_start + 32)); + _ = self.completeSidebarRootDrag(drop_y orelse (drag_start + 32)); + } + return; + } + if (mutation == 17) { + self.handleContextAction(.move_project, .{ .project = .{ .path = "C:\\GraphCode\\fixture", .remote = false } }); + return; + } + if (mutation == 18) { + const baseline = + \\{"version":2,"kind":"event","sequence":54,"event":{"graphChanged":{"id":"uia-activity","project":{"path":"C:\\GraphCode\\fixture","name":"UIA project","remote":false},"nodes":[{"id":"act-1","title":"Activity A","loopType":"goalBased","state":"idle"},{"id":"act-2","title":"Activity B","loopType":"goalBased","state":"idle"},{"id":"act-3","title":"Activity C","loopType":"goalBased","state":"idle"},{"id":"act-4","title":"Activity D","loopType":"goalBased","state":"idle"},{"id":"act-5","title":"Activity E","loopType":"goalBased","state":"idle"}],"edges":[]}}} + ; + const changed = + \\{"version":2,"kind":"event","sequence":55,"event":{"graphChanged":{"id":"uia-activity","project":{"path":"C:\\GraphCode\\fixture","name":"UIA project","remote":false},"nodes":[{"id":"act-1","title":"Activity A","loopType":"goalBased","state":"succeeded"},{"id":"act-2","title":"Activity B","loopType":"goalBased","state":"failed"},{"id":"act-3","title":"Activity C","loopType":"goalBased","state":"awaitingInput"},{"id":"act-4","title":"Activity D","loopType":"goalBased","state":"blocked"},{"id":"act-5","title":"Activity E","loopType":"goalBased","state":"running"}],"edges":[]}}} + ; + _ = self.model.updateFromFrame(baseline) catch return; + _ = self.model.updateFromFrame(changed) catch return; + self.surface = .project; + self.workspace_controls.panel_visible = false; + self.layoutWorkspace(); + self.layoutEmptyStateControls(); + self.syncAccessibility(); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + return; + } + if (mutation == 19) { + self.setIngressError("Folder could not be opened because the background service returned a detailed error that should wrap cleanly in the sidebar footer."); + return; + } + if (mutation == 20) { + self.model.deinit(); + self.model = GraphModel.Model.init(self.allocator); + self.installUiaFixture(false); + self.surface = .project; + self.workspace_controls.panel_visible = false; + self.layoutWorkspace(); + self.layoutEmptyStateControls(); + self.syncAccessibility(); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + return; + } const dialog = if (self.worktree_dialog) |*value| value else return; switch (mutation) { 1 => { @@ -3054,6 +3274,16 @@ pub const App = struct { self.update_lock.lock(); const update_checking = self.update_thread != null and !self.update_done; self.update_lock.unlock(); + var recent_menu: []MainWindow.RecentFolderItem = &.{}; + if (self.model.recent_projects.items.len != 0) { + recent_menu = self.allocator.alloc(MainWindow.RecentFolderItem, self.model.recent_projects.items.len) catch &.{}; + } + defer if (recent_menu.len != 0) self.allocator.free(recent_menu); + if (recent_menu.len != 0) { + for (self.model.recent_projects.items, 0..) |project, index| { + recent_menu[index] = .{ .path = project.path, .name = project.name }; + } + } MainWindow.updateMenu(self.window.hwnd, .{ .has_project = self.model.graph != null, .can_worktrees = if (self.model.graph) |graph| graph.project.isLocalFilesystem() else false, @@ -3064,6 +3294,7 @@ pub const App = struct { .workspace_visible = self.workspace_controls.panel_visible, .activity_visible = self.workspace_controls.activity_enabled, .update_checking = update_checking, + .recent_folders = recent_menu, }); self.layoutEmptyStateControls(); } @@ -3249,6 +3480,17 @@ pub const App = struct { return; }; self.appendAccessibilityElement(&elements, &owned_identities, "needs-you-row", identity, name, 1, .{ .left = 18, .top = section + 30 + row_offset, .right = 232, .bottom = section + 60 + row_offset }, self.model.selected_node_id != null and std.mem.eql(u8, self.model.selected_node_id.?, entry.node.id), true) catch return; + self.appendAccessibilityElement( + &elements, + &owned_identities, + "needs-you-stop", + identity, + "Stop loop", + 1, + Sidebar.needsYouStopBounds(&self.model, if (self.worktree_inspection) |*value| value else null, &self.sidebar_state, self.sidebar_scroll, index), + false, + true, + ) catch return; } } if (self.model.activity.items.len != 0) { @@ -3256,14 +3498,51 @@ pub const App = struct { const attention_rows = @min(self.model.attentionCount(), 4); const activity_top = section + 30 + (@as(i32, @intCast(attention_rows)) * 34) + 18; self.appendAccessibilityElement(&elements, &owned_identities, "activity-header", "activity", "Activity", 1, .{ .left = 12, .top = activity_top, .right = 232, .bottom = activity_top + 24 }, false, true) catch return; - for (self.model.activity.items[0..@min(self.model.activity.items.len, 4)], 0..) |event, index| { - const row_offset = @as(i32, @intCast(index)) * 116; + self.appendAccessibilityElement( + &elements, + &owned_identities, + "activity-filter", + "attention", + if (self.sidebar_state.activity_attention_only) "Show all activity" else "Show attention-only activity", + 1, + Sidebar.activityFilterBounds(&self.model, if (self.worktree_inspection) |*value| value else null, &self.sidebar_state, self.sidebar_scroll), + false, + true, + ) catch return; + const viewport = Sidebar.activityViewport(&self.model, &self.sidebar_state); + for (0..viewport.visible_count) |visible_index| { + const activity_index = Sidebar.activityEventAtVisible(&self.model, &self.sidebar_state, visible_index) orelse break; + const event = self.model.activity.items[activity_index]; const identity = std.fmt.allocPrint(self.allocator, "{s}:{s}", .{ event.project_path, event.node_id }) catch return; defer self.allocator.free(identity); - self.appendAccessibilityElement(&elements, &owned_identities, "activity-row", identity, event.title, 1, .{ .left = 18 + row_offset, .top = activity_top + 24, .right = 130 + row_offset, .bottom = activity_top + 58 }, false, true) catch return; + self.appendAccessibilityElement( + &elements, + &owned_identities, + "activity-row", + identity, + event.title, + 1, + Sidebar.activityCardBounds(&self.model, if (self.worktree_inspection) |*value| value else null, &self.sidebar_state, self.sidebar_scroll, visible_index), + false, + true, + ) catch return; } - self.appendAccessibilityElement(&elements, &owned_identities, "activity-control", "scroll-left", "Scroll activity left", 1, .{ .left = 184, .top = activity_top, .right = 206, .bottom = activity_top + 24 }, false, true) catch return; - self.appendAccessibilityElement(&elements, &owned_identities, "activity-control", "scroll-right", "Scroll activity right", 1, .{ .left = 208, .top = activity_top, .right = 230, .bottom = activity_top + 24 }, false, true) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "activity-control", "scroll-left", "Scroll activity left", 1, Sidebar.activityControlBounds(&self.model, if (self.worktree_inspection) |*value| value else null, &self.sidebar_state, self.sidebar_scroll, .left), false, true) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "activity-control", "scroll-right", "Scroll activity right", 1, Sidebar.activityControlBounds(&self.model, if (self.worktree_inspection) |*value| value else null, &self.sidebar_state, self.sidebar_scroll, .right), false, true) catch return; + } + if (self.ingress_error.len != 0) { + elements.append(.{ + .identity = "sidebar-error-footer:ingress", + .name = self.ingress_error, + .parent = 1, + .selected = false, + .eligible = false, + .invokable = false, + .left = Sidebar.errorFooterRect(client.bottom).left, + .top = Sidebar.errorFooterRect(client.bottom).top, + .right = Sidebar.errorFooterRect(client.bottom).right, + .bottom = Sidebar.errorFooterRect(client.bottom).bottom, + }) catch return; } switch (self.surface) { .project, .workspace => if (self.model.graph) |graph| { @@ -3410,6 +3689,9 @@ pub const App = struct { .{ .identity = "quick-chat-new:quick-chats", .target = .new_quick_chat }, .{ .identity = "needs-you-header:needs-you", .target = .needs_you_header }, .{ .identity = "activity-header:activity", .target = .activity_header }, + .{ .identity = "activity-filter:attention", .target = .activity_filter }, + .{ .identity = "activity-control:scroll-left", .target = .activity_left }, + .{ .identity = "activity-control:scroll-right", .target = .activity_right }, }; for (static_targets) |candidate| { if (Accessibility.worktreeIdentityPayload(candidate.identity) == payload) target = candidate.target; @@ -3557,6 +3839,12 @@ pub const App = struct { if (target != null) return false; target = .{ .needs_you = index }; } + const stop_identity = std.fmt.allocPrint(self.allocator, "needs-you-stop:{s}:{s}", .{ entry.project_path, entry.node.id }) catch return false; + defer self.allocator.free(stop_identity); + if (Accessibility.worktreeIdentityPayload(stop_identity) == payload) { + if (target != null) return false; + target = .{ .needs_you_stop = index }; + } } for (self.model.activity.items, 0..) |event, index| { const identity = std.fmt.allocPrint(self.allocator, "activity-row:{s}:{s}", .{ event.project_path, event.node_id }) catch return false; @@ -3566,14 +3854,6 @@ pub const App = struct { target = .{ .activity = index }; } } - for ([_][]const u8{ "scroll-left", "scroll-right" }) |control| { - const identity = std.fmt.allocPrint(self.allocator, "activity-control:{s}", .{control}) catch return false; - defer self.allocator.free(identity); - if (Accessibility.worktreeIdentityPayload(identity) == payload) { - if (target != null) return false; - target = .{ .activity = if (std.mem.eql(u8, control, "scroll-left")) 0 else 1 }; - } - } const resolved = target orelse return false; switch (resolved) { .local_section => self.sidebar_state.local_collapsed = !self.sidebar_state.local_collapsed, @@ -3592,13 +3872,15 @@ pub const App = struct { const entry = self.model.attention_entries.items[index]; if (self.selectProject(entry.project_path)) _ = self.model.setSelectedID(entry.node.id); }, - .activity_header => {}, - .activity => |index| { - if (index < self.model.activity.items.len) { - const event = self.model.activity.items[index]; - if (self.selectProject(event.project_path)) _ = self.model.setSelectedID(event.node_id); - } + .needs_you_stop => |index| { + if (index >= self.model.attention_entries.items.len) return false; + self.stopAttentionEntry(self.model.attention_entries.items[index]); }, + .activity_header => {}, + .activity_filter => Sidebar.toggleActivityAttentionOnly(&self.sidebar_state, &self.model), + .activity_left => Sidebar.stepActivity(&self.sidebar_state, &self.model, .left), + .activity_right => Sidebar.stepActivity(&self.sidebar_state, &self.model, .right), + .activity => |index| if (index < self.model.activity.items.len) self.navigateToActivityEvent(self.model.activity.items[index]), .recent_project => |path| self.openProject(path), .open_project => |path| { if (self.selectProject(path)) { @@ -3906,6 +4188,11 @@ fn onWindowMessage( app.openFolder(); } else if (id == MainWindow.empty_new_loop_id) { if (app.surface == .quick_chats) app.handleAction(.quick_chat) else app.handleAction(.create_node); + } else if (MainWindow.isRecentFolderCommand(id)) { + const recent_index = id - MainWindow.recent_folder_command_base; + if (recent_index < app.model.recent_projects.items.len) { + app.openProject(app.model.recent_projects.items[recent_index].path); + } } else if (MainWindow.commandFromId(id)) |command| { switch (command) { .open_folder => app.openFolder(), @@ -4260,6 +4547,22 @@ fn onWindowMessage( else routing.workspace_top; const rail_left = routing.rail_left; + if (app.workspace_controls.rail_visible and x < rail_left) { + const inspection = if (app.worktree_inspection) |*value| value else null; + if (Sidebar.rowAt(x, y, &app.model, inspection, app.sidebar_scroll, workspace_top, &app.sidebar_state)) |row| { + if (row.kind == .loop and row.depth == 0 and x < 198) { + if (row.project_path) |path| if (app.model.graphFor(path)) |graph| { + if (row.index < graph.nodes.items.len) app.beginSidebarRootDrag(path, graph.nodes.items[row.index].id, y); + }; + } else { + app.clearSidebarRootDrag(); + } + } else { + app.clearSidebarRootDrag(); + } + } else { + app.clearSidebarRootDrag(); + } if ((app.workspace_controls.panel_visible or app.surface == .workspace) and x >= rail_left and y >= workspace_top) { if (app.surface == .workspace) { if (workspaceGraph(&app.model)) |graph| { @@ -4427,6 +4730,10 @@ fn onWindowMessage( return true; } if (app.workspace_controls.rail_visible) { + if (app.completeSidebarRootDrag(y)) { + result.* = 0; + return true; + } app.update_lock.lock(); const update_available = app.update_state.state == .available; app.update_lock.unlock(); @@ -4435,6 +4742,22 @@ fn onWindowMessage( result.* = 0; return true; } + if (Sidebar.needsYouStopAt( + x, + y, + &app.model, + if (app.worktree_inspection) |*value| value else null, + &app.sidebar_state, + app.sidebar_scroll, + )) |attention_index| { + if (attention_index < app.model.attention_entries.items.len) { + app.stopAttentionEntry(app.model.attention_entries.items[attention_index]); + } + app.syncAccessibility(); + _ = c.InvalidateRect(hwnd, null, 0); + result.* = 0; + return true; + } if (Sidebar.attentionRowAt( y, &app.model, @@ -4454,6 +4777,38 @@ fn onWindowMessage( result.* = 0; return true; } + if (Sidebar.activityControlAt( + x, + y, + &app.model, + if (app.worktree_inspection) |*value| value else null, + &app.sidebar_state, + app.sidebar_scroll, + )) |control| { + switch (control) { + .filter => Sidebar.toggleActivityAttentionOnly(&app.sidebar_state, &app.model), + .left => Sidebar.stepActivity(&app.sidebar_state, &app.model, .left), + .right => Sidebar.stepActivity(&app.sidebar_state, &app.model, .right), + } + app.syncAccessibility(); + _ = c.InvalidateRect(hwnd, null, 0); + result.* = 0; + return true; + } + if (Sidebar.activityCardAt( + x, + y, + &app.model, + if (app.worktree_inspection) |*value| value else null, + &app.sidebar_state, + app.sidebar_scroll, + )) |activity_index| { + if (activity_index < app.model.activity.items.len) { + app.navigateToActivityEvent(app.model.activity.items[activity_index]); + } + result.* = 0; + return true; + } if (Sidebar.rowAt( x, y, @@ -4552,6 +4907,7 @@ fn onWindowMessage( app.setStatus("Opening quick chat..."); }, } + app.clearSidebarRootDrag(); app.clampSidebarScroll(); app.syncAccessibility(); _ = c.InvalidateRect(hwnd, null, 0); @@ -4559,6 +4915,7 @@ fn onWindowMessage( return true; } } + app.clearSidebarRootDrag(); result.* = 0; return true; }, @@ -4727,6 +5084,7 @@ fn onWindowMessage( c.WM_MOUSEMOVE => { const hover_y = mouseY(lparam); const hover_x = mouseX(lparam); + app.updateSidebarRootDrag(hover_y); const next_hover = if (mouseX(lparam) >= 0 and mouseX(lparam) < Tokens.sidebar_width) hover_y else -1; if (next_hover != app.sidebar_hover_y) { app.sidebar_hover_y = next_hover; diff --git a/graphcode-windows/src/DaemonClient.zig b/graphcode-windows/src/DaemonClient.zig index 9e004d9d..8a4e564c 100644 --- a/graphcode-windows/src/DaemonClient.zig +++ b/graphcode-windows/src/DaemonClient.zig @@ -393,6 +393,18 @@ pub const DaemonClient = struct { self.sendCommand(command); } + pub fn sendSidebarRootOrder( + self: *DaemonClient, + project_path: []const u8, + node_ids: []const []const u8, + ) void { + const command = Wire.commandGraphSidebarNodesReordered(self.allocator, project_path, node_ids) catch { + self.publishState(self.connectionState(), "sidebar root order command encoding failed"); + return; + }; + self.sendCommand(command); + } + pub fn sendRenameNode(self: *DaemonClient, project_path: []const u8, node_id: []const u8, title: []const u8) void { const command = Wire.commandGraphRenameNode(self.allocator, project_path, node_id, title) catch { self.publishState(self.connectionState(), "rename node command encoding failed"); @@ -573,13 +585,23 @@ pub const DaemonClient = struct { }; self.allocator.free(command_json); } + self.recordUiaCommand(addressed); _ = self.sendCommandInternal(addressed, null); } fn sendCommandWithRequestID(self: *DaemonClient, command_json: []u8, request_id: [36]u8) bool { + self.recordUiaCommand(command_json); return self.sendCommandInternal(command_json, request_id); } + fn recordUiaCommand(self: *DaemonClient, command_json: []const u8) void { + const log_path = std.process.getEnvVarOwned(self.allocator, "GRAPHCODE_UIA_DAEMON_COMMAND_LOG") catch return; + defer self.allocator.free(log_path); + var file = std.fs.createFileAbsolute(log_path, .{ .truncate = true }) catch return; + defer file.close(); + file.writeAll(command_json) catch {}; + } + fn sendCommandInternal(self: *DaemonClient, command_json: []u8, request_id: ?[36]u8) bool { self.mutex.lock(); if (self.stop_worker or self.outbound_count == outbound_capacity) { diff --git a/graphcode-windows/src/MainWindow.zig b/graphcode-windows/src/MainWindow.zig index fe21a8eb..452b1f06 100644 --- a/graphcode-windows/src/MainWindow.zig +++ b/graphcode-windows/src/MainWindow.zig @@ -55,6 +55,13 @@ pub const Command = enum(u16) { pub const empty_open_folder_id: usize = 4601; pub const empty_new_loop_id: usize = 4602; +pub const recent_folder_command_base: usize = 4700; +pub const recent_folder_command_limit: usize = 4799; + +pub const RecentFolderItem = struct { + path: []const u8, + name: []const u8, +}; pub const MenuState = struct { has_project: bool, @@ -66,6 +73,7 @@ pub const MenuState = struct { workspace_visible: bool, activity_visible: bool, update_checking: bool, + recent_folders: []const RecentFolderItem = &.{}, }; pub fn commandFromId(id: usize) ?Command { @@ -166,14 +174,19 @@ pub fn restoreExistingInstance() void { pub fn installMenu(hwnd: c.HWND) !void { const menu = c.CreateMenu() orelse return error.MenuCreationFailed; const file = c.CreatePopupMenu() orelse return error.MenuCreationFailed; + const add_folder = c.CreatePopupMenu() orelse return error.MenuCreationFailed; + const recent_folders = c.CreatePopupMenu() orelse return error.MenuCreationFailed; const loop = c.CreatePopupMenu() orelse return error.MenuCreationFailed; const terminal = c.CreatePopupMenu() orelse return error.MenuCreationFailed; const view = c.CreatePopupMenu() orelse return error.MenuCreationFailed; const help = c.CreatePopupMenu() orelse return error.MenuCreationFailed; - append(file, "Open Folder...\tCtrl+O", @intFromEnum(Command.open_folder)); - append(file, "Clone Repository...\tCtrl+Shift+C", @intFromEnum(Command.clone_repository)); - append(file, "Add Remote Repository...\tCtrl+Shift+R", @intFromEnum(Command.remote_repository)); + append(add_folder, "Open Folder...\tCtrl+O", @intFromEnum(Command.open_folder)); + append(add_folder, "Clone Repository...\tCtrl+Shift+C", @intFromEnum(Command.clone_repository)); + append(add_folder, "Add Remote Repository...\tCtrl+Shift+R", @intFromEnum(Command.remote_repository)); + separator(add_folder); + appendPopup(add_folder, "Recent Folders", recent_folders); + appendPopup(file, "Add Folder", add_folder); separator(file); append(file, "New Quick Chat\tCtrl+Q", @intFromEnum(Command.new_quick_chat)); append(file, "Open Global Overview", @intFromEnum(Command.open_global_overview)); @@ -236,6 +249,7 @@ pub fn installMenu(hwnd: c.HWND) !void { } pub fn updateMenu(hwnd: c.HWND, state: MenuState) void { + updateRecentFolderMenu(hwnd, state.recent_folders); setEnabled(hwnd, .open_global_overview, true); setEnabled(hwnd, .worktrees, state.can_worktrees); setEnabled(hwnd, .reclaim_worktrees, state.can_worktrees); @@ -268,6 +282,32 @@ pub fn updateMenu(hwnd: c.HWND, state: MenuState) void { _ = c.DrawMenuBar(hwnd); } +pub fn isRecentFolderCommand(id: usize) bool { + return id >= recent_folder_command_base and id <= recent_folder_command_limit; +} + +fn updateRecentFolderMenu(hwnd: c.HWND, recent_folders: []const RecentFolderItem) void { + const root = c.GetMenu(hwnd); + if (root == null) return; + const file = c.GetSubMenu(root, 0); + if (file == null) return; + const add_folder = c.GetSubMenu(file, 0); + if (add_folder == null) return; + const recent = c.GetSubMenu(add_folder, 4); + if (recent == null) return; + var count = c.GetMenuItemCount(recent); + while (count > 0) : (count -= 1) { + _ = c.DeleteMenu(recent, @intCast(count - 1), c.MF_BYPOSITION); + } + if (recent_folders.len == 0) { + appendEnabled(recent, "No recent folders", recent_folder_command_base, false); + return; + } + for (recent_folders[0..@min(recent_folders.len, recent_folder_command_limit - recent_folder_command_base + 1)], 0..) |project, index| { + append(recent, project.name, recent_folder_command_base + index); + } +} + fn setEnabled(hwnd: c.HWND, command: Command, enabled: bool) void { const flags: c.UINT = @intCast(@as(i32, c.MF_BYCOMMAND) | if (enabled) @as(i32, c.MF_ENABLED) else @as(i32, c.MF_GRAYED)); @@ -281,9 +321,15 @@ fn setChecked(hwnd: c.HWND, command: Command, checked: bool) void { } fn append(menu: c.HMENU, text: []const u8, id: usize) void { + appendEnabled(menu, text, id, true); +} + +fn appendEnabled(menu: c.HMENU, text: []const u8, id: usize, enabled: bool) void { const wide = toWideZ(std.heap.c_allocator, text) catch return; defer std.heap.c_allocator.free(wide); - _ = c.AppendMenuW(menu, c.MF_STRING, id, wide.ptr); + var flags: c.UINT = c.MF_STRING; + if (!enabled) flags |= c.MF_GRAYED; + _ = c.AppendMenuW(menu, flags, id, wide.ptr); } fn appendPopup(menu: c.HMENU, text: []const u8, popup: c.HMENU) void { @@ -332,6 +378,12 @@ test "native menu exposes the parity command groups" { try std.testing.expectEqual(@as(?Command, null), commandFromId(9999)); } +test "recent folder commands use a dedicated command range" { + try std.testing.expect(isRecentFolderCommand(recent_folder_command_base)); + try std.testing.expect(isRecentFolderCommand(recent_folder_command_limit)); + try std.testing.expect(!isRecentFolderCommand(recent_folder_command_limit + 1)); +} + test "native menu labels are NUL terminated UTF-16" { const wide = try toWideZ(std.testing.allocator, "Clone Repository…"); defer std.testing.allocator.free(wide); diff --git a/graphcode-windows/src/Sidebar.zig b/graphcode-windows/src/Sidebar.zig index 93a1ad56..e4711877 100644 --- a/graphcode-windows/src/Sidebar.zig +++ b/graphcode-windows/src/Sidebar.zig @@ -9,6 +9,8 @@ pub const State = struct { local_collapsed: bool = false, remote_collapsed: bool = false, chats_collapsed: bool = false, + activity_attention_only: bool = false, + activity_scroll: usize = 0, collapsed_projects: std.StringHashMapUnmanaged(void) = .empty, expanded_nodes: std.StringHashMapUnmanaged(void) = .empty, root_order: std.ArrayListUnmanaged([]u8) = .empty, @@ -257,15 +259,21 @@ pub fn draw( drawText(hdc, allocator, "Needs you", 18, section_y + 10, 11, 0x00FFCD7A); var attention_y = section_y + 30; if (model.attention_entries.items.len != 0) { - for (model.attention_entries.items[0..@min(model.attention_entries.items.len, 4)]) |entry| { + for (model.attention_entries.items[0..@min(model.attention_entries.items.len, 4)], 0..) |entry, index| { drawText(hdc, allocator, entry.node.title, 24, attention_y, 11, 0x00E6E6E6); drawText(hdc, allocator, attentionReason(entry.node), 24, attention_y + 15, 9, stateColor(entry.node.state)); + const stop_bounds = needsYouStopBounds(model, inspection, state, 0, index); + fill(hdc, stop_bounds, 0x00353224); + drawTextRect(hdc, allocator, "Stop", stop_bounds, 9, 0x00FFCD7A, c.DT_CENTER | c.DT_SINGLELINE | c.DT_VCENTER); attention_y += 34; } } else { - for (model.attention.items[0..@min(model.attention.items.len, 4)]) |node| { + for (model.attention.items[0..@min(model.attention.items.len, 4)], 0..) |node, index| { drawText(hdc, allocator, node.title, 24, attention_y, 11, 0x00E6E6E6); drawText(hdc, allocator, attentionReason(node), 24, attention_y + 15, 9, stateColor(node.state)); + const stop_bounds = needsYouStopBounds(model, inspection, state, 0, index); + fill(hdc, stop_bounds, 0x00353224); + drawTextRect(hdc, allocator, "Stop", stop_bounds, 9, 0x00FFCD7A, c.DT_CENTER | c.DT_SINGLELINE | c.DT_VCENTER); attention_y += 34; } } @@ -274,19 +282,32 @@ pub fn draw( const attention_rows = @min(model.attentionCount(), 4); const activity_y = section_y + 30 + (@as(i32, @intCast(attention_rows)) * 34) + 18; drawText(hdc, allocator, "Activity", 18, activity_y, 11, 0x00B8B8B8); - var x: i32 = 24; - for (model.activity.items[0..@min(model.activity.items.len, 4)]) |event| { + const filter_bounds = activityFilterBounds(model, inspection, state, 0); + fill(hdc, filter_bounds, if (state.activity_attention_only) 0x00302B1D else 0x0026262B); + drawTextRect(hdc, allocator, "Attention only", filter_bounds, 9, if (state.activity_attention_only) 0x00FFCD7A else 0x00B8B8B8, c.DT_CENTER | c.DT_SINGLELINE | c.DT_VCENTER); + const left_bounds = activityControlBounds(model, inspection, state, 0, .left); + const right_bounds = activityControlBounds(model, inspection, state, 0, .right); + fill(hdc, left_bounds, 0x0026262B); + fill(hdc, right_bounds, 0x0026262B); + drawTextRect(hdc, allocator, "<", left_bounds, 10, 0x00B8B8B8, c.DT_CENTER | c.DT_SINGLELINE | c.DT_VCENTER); + drawTextRect(hdc, allocator, ">", right_bounds, 10, 0x00B8B8B8, c.DT_CENTER | c.DT_SINGLELINE | c.DT_VCENTER); + const viewport = activityViewport(model, state); + for (0..viewport.visible_count) |visible_index| { + const activity_index = activityEventAtVisible(model, state, visible_index) orelse break; + const event = model.activity.items[activity_index]; + const card = activityCardBounds(model, inspection, state, 0, visible_index); const stamp = std.fmt.allocPrint(allocator, "{d}m", .{@max(0, @divTrunc(std.time.timestamp() - event.timestamp, 60))}) catch null; defer if (stamp) |value| allocator.free(value); - drawText(hdc, allocator, event.title, x, activity_y + 18, 10, 0x00E6E6E6); - drawText(hdc, allocator, stamp orelse "", x, activity_y + 32, 9, stateColor(event.state)); - x += 116; + fill(hdc, card, 0x0026262B); + fill(hdc, rect(card.left, card.top, card.left + 3, card.bottom), stateColor(event.state)); + drawTextRect(hdc, allocator, event.title, rect(card.left + 8, card.top + 4, card.right - 8, card.top + 20), 10, 0x00E6E6E6, c.DT_LEFT | c.DT_SINGLELINE | c.DT_END_ELLIPSIS); + drawTextRect(hdc, allocator, stamp orelse "", rect(card.left + 8, card.top + 18, card.right - 8, card.bottom - 4), 9, stateColor(event.state), c.DT_LEFT | c.DT_SINGLELINE | c.DT_VCENTER); } } if (ingress_error.len != 0) { const bounds = errorFooterRect(viewport_bottom); fill(hdc, bounds, 0x00242448); - drawText(hdc, allocator, ingress_error, bounds.left + 10, bounds.top + 10, 10, 0x006060FF); + drawTextRect(hdc, allocator, ingress_error, errorFooterTextRect(viewport_bottom), 10, 0x006060FF, c.DT_LEFT | c.DT_WORDBREAK | c.DT_NOPREFIX); } if (update_version.len != 0) { const bounds = updateBannerRect(viewport_bottom, ingress_error.len != 0); @@ -307,6 +328,11 @@ pub fn errorFooterRect(viewport_bottom: i32) c.RECT { return rect(8, viewport_bottom - 84, Tokens.sidebar_width - 8, viewport_bottom - 42); } +pub fn errorFooterTextRect(viewport_bottom: i32) c.RECT { + const bounds = errorFooterRect(viewport_bottom); + return rect(bounds.left + 10, bounds.top + 8, bounds.right - 10, bounds.bottom - 8); +} + pub fn updateBannerRect(viewport_bottom: i32, has_error: bool) c.RECT { const error_offset: i32 = if (has_error) 50 else 0; return rect(8, viewport_bottom - 92 - error_offset, Tokens.sidebar_width - 8, viewport_bottom - 42 - error_offset); @@ -318,6 +344,15 @@ pub fn updateBannerAt(x: i32, y: i32, viewport_bottom: i32, available: bool, has return x >= bounds.left and x < bounds.right and y >= bounds.top and y < bounds.bottom; } +pub const ActivityControl = enum { filter, left, right }; +pub const ActivityDirection = enum { left, right }; + +pub const ActivityViewport = struct { + start: usize, + visible_count: usize, + total_count: usize, +}; + fn loopAccent(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; @@ -353,6 +388,13 @@ pub fn attentionReason(node: GraphModel.Node) []const u8 { return compactState(node.state); } +pub fn activityNeedsAttention(state: []const u8) bool { + return std.mem.eql(u8, state, "failed") or + std.mem.eql(u8, state, "stalled") or + std.mem.eql(u8, state, "blocked") or + std.mem.eql(u8, state, "awaitingInput"); +} + fn elapsedText(allocator: std.mem.Allocator, created_at: i64, now: i64) ![]u8 { if (created_at <= 0 or now <= created_at) return allocator.dupe(u8, "-"); const seconds = now - created_at; @@ -461,7 +503,7 @@ pub fn appendRows( } if (!local_collapsed) { for (model.recent_projects.items, 0..) |project, index| { - if (project.isRemote()) continue; + if (project.isRemote() or isProjectOpen(model, project.path)) continue; try rows.append(allocator, .{ .kind = .project, .index = index, .top = top, .project_path = project.path }); top += 24; } @@ -472,7 +514,7 @@ pub fn appendRows( } if (!remote_collapsed) { for (model.recent_projects.items, 0..) |project, index| { - if (!project.isRemote()) continue; + if (!project.isRemote() or isProjectOpen(model, project.path)) continue; try rows.append(allocator, .{ .kind = .project, .index = index, .top = top, .project_path = project.path }); top += 24; } @@ -601,12 +643,12 @@ pub fn rowAt( } fn hasLocalProjects(model: *const GraphModel.Model) bool { - for (model.recent_projects.items) |project| if (!project.isRemote()) return true; + for (model.recent_projects.items) |project| if (!project.isRemote() and !isProjectOpen(model, project.path)) return true; return false; } fn hasRemoteProjects(model: *const GraphModel.Model) bool { - for (model.recent_projects.items) |project| if (project.isRemote()) return true; + for (model.recent_projects.items) |project| if (project.isRemote() and !isProjectOpen(model, project.path)) return true; return false; } @@ -615,6 +657,17 @@ fn projectHeadingCount(model: *const GraphModel.Model) usize { @as(usize, @intFromBool(hasRemoteProjects(model))); } +fn isProjectOpen(model: *const GraphModel.Model, path: []const u8) bool { + for (model.open_projects.items) |project| { + if (std.mem.eql(u8, project.path, path)) return true; + } + for (model.graphs.items) |graph| { + if (std.mem.eql(u8, graph.project.path, path)) return true; + } + if (model.graph) |graph| return std.mem.eql(u8, graph.project.path, path); + return false; +} + fn hierarchyItems( allocator: std.mem.Allocator, nodes: []const GraphModel.Node, @@ -626,7 +679,30 @@ fn hierarchyItems( const visited = try allocator.alloc(bool, nodes.len); defer allocator.free(visited); @memset(visited, false); + var roots = try collectRootIndices(allocator, nodes, edges, state, false, visited); + defer roots.deinit(allocator); + for (roots.items) |index| try appendHierarchy(allocator, &result, visited, nodes, edges, index, 0, state); + var unresolved = try collectRootIndices(allocator, nodes, edges, state, true, visited); + defer unresolved.deinit(allocator); + for (unresolved.items) |index| if (!visited[index]) try appendHierarchy(allocator, &result, visited, nodes, edges, index, 0, state); + return result; +} + +fn collectRootIndices( + allocator: std.mem.Allocator, + nodes: []const GraphModel.Node, + edges: []const GraphModel.Edge, + state: ?*const State, + only_unvisited: bool, + visited: []const bool, +) !std.ArrayList(usize) { + var indices: std.ArrayList(usize) = .empty; + errdefer indices.deinit(allocator); for (nodes, 0..) |node, index| { + if (only_unvisited) { + if (!visited[index]) try indices.append(allocator, index); + continue; + } var incoming = false; for (edges) |edge| { if (std.mem.eql(u8, edge.kind, "handoff") and std.mem.eql(u8, edge.to, node.id)) { @@ -634,12 +710,31 @@ fn hierarchyItems( break; } } - if (!incoming) try appendHierarchy(allocator, &result, visited, nodes, edges, index, 0, state); + if (!incoming) try indices.append(allocator, index); } - for (nodes, 0..) |_, index| { - if (!visited[index]) try appendHierarchy(allocator, &result, visited, nodes, edges, index, 0, state); + std.sort.heap(usize, indices.items, RootOrderContext{ .nodes = nodes, .state = state }, compareRootOrder); + return indices; +} + +const RootOrderContext = struct { + nodes: []const GraphModel.Node, + state: ?*const State, +}; + +fn rootOrderRank(state: ?*const State, id: []const u8) usize { + if (state) |value| { + for (value.root_order.items, 0..) |candidate, index| { + if (std.mem.eql(u8, candidate, id)) return index; + } } - return result; + return std.math.maxInt(usize); +} + +fn compareRootOrder(context: RootOrderContext, lhs: usize, rhs: usize) bool { + const lhs_rank = rootOrderRank(context.state, context.nodes[lhs].id); + const rhs_rank = rootOrderRank(context.state, context.nodes[rhs].id); + if (lhs_rank == rhs_rank) return lhs < rhs; + return lhs_rank < rhs_rank; } fn appendHierarchy( @@ -714,8 +809,14 @@ pub fn worktreeSectionBottom(project_count: usize, worktree_count: usize) i32 { pub fn contentBottom(model: *const GraphModel.Model, inspection: ?*const WorktreeStatus.Inspection, state: ?*const State) i32 { const section = sidebarSectionBottom(model, inspection, state); - return if (model.attentionCount() == 0) section else section + 30 + - @as(i32, @intCast(@min(model.attentionCount(), 4))) * 34; + var bottom = section; + if (model.attentionCount() != 0) { + bottom += 30 + @as(i32, @intCast(@min(model.attentionCount(), 4))) * 34; + } + if (model.activity.items.len != 0) { + bottom += 18 + 24 + 34; + } + return bottom; } pub fn attentionRowAt( @@ -740,6 +841,223 @@ pub fn sidebarSectionBottom(model: *const GraphModel.Model, inspection: ?*const return last.top + (if (last.kind == .worktree) @as(i32, 34) else 24); } +pub fn needsYouStopBounds( + model: *const GraphModel.Model, + inspection: ?*const WorktreeStatus.Inspection, + state: ?*const State, + scroll_offset: i32, + index: usize, +) c.RECT { + const top = sidebarSectionBottom(model, inspection, state) - scroll_offset + 30 + @as(i32, @intCast(index)) * 34; + return rect(Tokens.sidebar_width - 66, top + 6, Tokens.sidebar_width - 14, top + 26); +} + +pub fn needsYouStopAt( + x: i32, + y: i32, + model: *const GraphModel.Model, + inspection: ?*const WorktreeStatus.Inspection, + state: ?*const State, + scroll_offset: i32, +) ?usize { + if (model.attentionCount() == 0) return null; + const count = @min(model.attentionCount(), 4); + for (0..count) |index| { + const bounds = needsYouStopBounds(model, inspection, state, scroll_offset, index); + if (x >= bounds.left and x < bounds.right and y >= bounds.top and y < bounds.bottom) + return index; + } + return null; +} + +pub fn activityFilterBounds( + model: *const GraphModel.Model, + inspection: ?*const WorktreeStatus.Inspection, + state: *const State, + scroll_offset: i32, +) c.RECT { + const top = activityHeaderTop(model, inspection, state, scroll_offset); + return rect(78, top + 2, 168, top + 22); +} + +pub fn activityControlBounds( + model: *const GraphModel.Model, + inspection: ?*const WorktreeStatus.Inspection, + state: *const State, + scroll_offset: i32, + control: ActivityDirection, +) c.RECT { + const top = activityHeaderTop(model, inspection, state, scroll_offset); + const left: i32 = if (control == .left) 184 else 208; + return rect(left, top, left + 22, top + 22); +} + +pub fn activityControlAt( + x: i32, + y: i32, + model: *const GraphModel.Model, + inspection: ?*const WorktreeStatus.Inspection, + state: *const State, + scroll_offset: i32, +) ?ActivityControl { + if (model.activity.items.len == 0) return null; + const filter = activityFilterBounds(model, inspection, state, scroll_offset); + if (x >= filter.left and x < filter.right and y >= filter.top and y < filter.bottom) return .filter; + const left = activityControlBounds(model, inspection, state, scroll_offset, .left); + if (x >= left.left and x < left.right and y >= left.top and y < left.bottom) return .left; + const right = activityControlBounds(model, inspection, state, scroll_offset, .right); + if (x >= right.left and x < right.right and y >= right.top and y < right.bottom) return .right; + return null; +} + +pub fn activityCardBounds( + model: *const GraphModel.Model, + inspection: ?*const WorktreeStatus.Inspection, + state: *const State, + scroll_offset: i32, + visible_index: usize, +) c.RECT { + const top = activityCardsTop(model, inspection, state, scroll_offset); + const left = 18 + @as(i32, @intCast(visible_index)) * 112; + return rect(left, top, @min(left + 100, Tokens.sidebar_width - 10), top + 34); +} + +pub fn activityCardAt( + x: i32, + y: i32, + model: *const GraphModel.Model, + inspection: ?*const WorktreeStatus.Inspection, + state: *const State, + scroll_offset: i32, +) ?usize { + const viewport = activityViewport(model, state); + for (0..viewport.visible_count) |visible_index| { + const bounds = activityCardBounds(model, inspection, state, scroll_offset, visible_index); + if (x >= bounds.left and x < bounds.right and y >= bounds.top and y < bounds.bottom) + return activityEventAtVisible(model, state, visible_index); + } + return null; +} + +pub fn activityViewport(model: *const GraphModel.Model, state: *const State) ActivityViewport { + const total = filteredActivityCount(model, state); + const capacity = @min(total, @as(usize, 2)); + const max_start = if (total > capacity) total - capacity else 0; + return .{ + .start = @min(state.activity_scroll, max_start), + .visible_count = capacity, + .total_count = total, + }; +} + +pub fn activityEventAtVisible(model: *const GraphModel.Model, state: *const State, visible_index: usize) ?usize { + const viewport = activityViewport(model, state); + if (visible_index >= viewport.visible_count) return null; + const target = viewport.start + visible_index; + var count: usize = 0; + for (model.activity.items, 0..) |event, index| { + if (!activityMatchesFilter(event, state)) continue; + if (count == target) return index; + count += 1; + } + return null; +} + +pub fn stepActivity(state: *State, model: *const GraphModel.Model, direction: ActivityDirection) void { + const viewport = activityViewport(model, state); + if (viewport.total_count <= viewport.visible_count) { + state.activity_scroll = 0; + return; + } + if (direction == .left) { + if (state.activity_scroll > 0) state.activity_scroll -= 1; + } else { + const max_start = viewport.total_count - viewport.visible_count; + if (state.activity_scroll < max_start) state.activity_scroll += 1; + } +} + +pub fn toggleActivityAttentionOnly(state: *State, model: *const GraphModel.Model) void { + state.activity_attention_only = !state.activity_attention_only; + const viewport = activityViewport(model, state); + if (viewport.total_count <= viewport.visible_count) { + state.activity_scroll = 0; + } else if (state.activity_scroll > viewport.total_count - viewport.visible_count) { + state.activity_scroll = viewport.total_count - viewport.visible_count; + } +} + +pub fn rootIDs( + allocator: std.mem.Allocator, + nodes: []const GraphModel.Node, + edges: []const GraphModel.Edge, + state: ?*const State, +) !std.ArrayList([]const u8) { + const visited = try allocator.alloc(bool, nodes.len); + defer allocator.free(visited); + @memset(visited, false); + var root_indices = try collectRootIndices(allocator, nodes, edges, state, false, visited); + defer root_indices.deinit(allocator); + var ids: std.ArrayList([]const u8) = .empty; + errdefer ids.deinit(allocator); + for (root_indices.items) |index| try ids.append(allocator, nodes[index].id); + return ids; +} + +pub fn reorderRootIDs( + state: *State, + allocator: std.mem.Allocator, + nodes: []const GraphModel.Node, + edges: []const GraphModel.Edge, + dragged_id: []const u8, + drop_index: usize, +) !bool { + var roots = try rootIDs(allocator, nodes, edges, state); + defer roots.deinit(allocator); + const from_index = for (roots.items, 0..) |id, index| { + if (std.mem.eql(u8, id, dragged_id)) break index; + } else return false; + const bounded_drop = @min(drop_index, roots.items.len); + const target = if (bounded_drop > from_index) bounded_drop - 1 else bounded_drop; + if (target == from_index) return false; + const dragged = roots.orderedRemove(from_index); + roots.insert(allocator, target, dragged) catch return error.OutOfMemory; + try state.reorderRoots(roots.items); + return true; +} + +fn filteredActivityCount(model: *const GraphModel.Model, state: *const State) usize { + var count: usize = 0; + for (model.activity.items) |event| { + if (activityMatchesFilter(event, state)) count += 1; + } + return count; +} + +fn activityMatchesFilter(event: GraphModel.ActivityEvent, state: *const State) bool { + return !state.activity_attention_only or activityNeedsAttention(event.state); +} + +fn activityHeaderTop( + model: *const GraphModel.Model, + inspection: ?*const WorktreeStatus.Inspection, + state: *const State, + scroll_offset: i32, +) i32 { + const section = sidebarSectionBottom(model, inspection, state); + const attention_rows = @min(model.attentionCount(), 4); + return section - scroll_offset + 30 + (@as(i32, @intCast(attention_rows)) * 34) + 18; +} + +fn activityCardsTop( + model: *const GraphModel.Model, + inspection: ?*const WorktreeStatus.Inspection, + state: *const State, + scroll_offset: i32, +) i32 { + return activityHeaderTop(model, inspection, state, scroll_offset) + 24; +} + pub fn maxScroll(model: *const GraphModel.Model, inspection: ?*const WorktreeStatus.Inspection, viewport_bottom: i32, state: ?*const State) i32 { return @max(contentBottom(model, inspection, state) - viewport_bottom, 0); } @@ -809,6 +1127,78 @@ test "root reorder validates uniqueness and replaces order atomically" { try std.testing.expectEqualStrings("root-b", state.root_order.items[1]); } +test "root ordering follows persisted root ids and can be reordered by drop index" { + const nodes = [_]GraphModel.Node{ + .{ .id = @constCast("root-b"), .title = @constCast("Root B"), .loop_type = @constCast("turnBased"), .state = @constCast("idle"), .activity = @constCast(""), .presence = @constCast("idle") }, + .{ .id = @constCast("root-a"), .title = @constCast("Root A"), .loop_type = @constCast("turnBased"), .state = @constCast("idle"), .activity = @constCast(""), .presence = @constCast("idle") }, + }; + var state = State.init(std.testing.allocator); + defer state.deinit(); + var initial = try rootIDs(std.testing.allocator, &nodes, &.{}, &state); + defer initial.deinit(std.testing.allocator); + try std.testing.expectEqualStrings("root-b", initial.items[0]); + try std.testing.expect(try reorderRootIDs(&state, std.testing.allocator, &nodes, &.{}, "root-a", 0)); + try std.testing.expectEqualStrings("root-a", state.root_order.items[0]); + var reordered = try rootIDs(std.testing.allocator, &nodes, &.{}, &state); + defer reordered.deinit(std.testing.allocator); + try std.testing.expectEqualStrings("root-a", reordered.items[0]); + try std.testing.expectEqualStrings("root-b", reordered.items[1]); + try std.testing.expect(try reorderRootIDs(&state, std.testing.allocator, &nodes, &.{}, "root-a", 2)); + var moved_to_end = try rootIDs(std.testing.allocator, &nodes, &.{}, &state); + defer moved_to_end.deinit(std.testing.allocator); + try std.testing.expectEqualStrings("root-b", moved_to_end.items[0]); + try std.testing.expectEqualStrings("root-a", moved_to_end.items[1]); +} + +test "activity viewport filters attention events and scrolls horizontally" { + var model = GraphModel.Model.init(std.testing.allocator); + defer model.deinit(); + for ([_][]const u8{ "running", "failed", "blocked" }, 0..) |state_text, index| { + try model.activity.append(.{ + .title = try std.fmt.allocPrint(std.testing.allocator, "Event {d}", .{index}), + .state = try std.testing.allocator.dupe(u8, state_text), + }); + } + var state = State.init(std.testing.allocator); + defer state.deinit(); + try std.testing.expectEqual(@as(usize, 2), activityViewport(&model, &state).visible_count); + try std.testing.expectEqual(@as(?usize, 0), activityEventAtVisible(&model, &state, 0)); + stepActivity(&state, &model, .right); + try std.testing.expectEqual(@as(?usize, 1), activityEventAtVisible(&model, &state, 0)); + toggleActivityAttentionOnly(&state, &model); + try std.testing.expect(state.activity_attention_only); + try std.testing.expectEqual(@as(usize, 2), activityViewport(&model, &state).total_count); + try std.testing.expectEqual(@as(?usize, 1), activityEventAtVisible(&model, &state, 0)); + try std.testing.expectEqual(@as(?usize, 2), activityEventAtVisible(&model, &state, 1)); +} + +test "needs-you stop and activity hit targets remain bounded" { + var model = GraphModel.Model.init(std.testing.allocator); + defer model.deinit(); + try model.attention.append(.{ + .id = try std.testing.allocator.dupe(u8, "attention"), + .title = try std.testing.allocator.dupe(u8, "Needs You"), + .loop_type = try std.testing.allocator.dupe(u8, "goal"), + .state = try std.testing.allocator.dupe(u8, "failed"), + .activity = try std.testing.allocator.dupe(u8, "failed"), + .presence = try std.testing.allocator.dupe(u8, "idle"), + .worktree_path = try std.testing.allocator.dupe(u8, ""), + .worktree_branch = try std.testing.allocator.dupe(u8, ""), + }); + try model.activity.append(.{ + .title = try std.testing.allocator.dupe(u8, "Activity"), + .state = try std.testing.allocator.dupe(u8, "failed"), + }); + var state = State.init(std.testing.allocator); + defer state.deinit(); + const stop = needsYouStopBounds(&model, null, &state, 0, 0); + try std.testing.expectEqual(@as(?usize, 0), needsYouStopAt(stop.left + 2, stop.top + 2, &model, null, &state, 0)); + const card = activityCardBounds(&model, null, &state, 0, 0); + try std.testing.expectEqual(@as(?usize, 0), activityCardAt(card.left + 2, card.top + 2, &model, null, &state, 0)); + const filter = activityFilterBounds(&model, null, &state, 0); + try std.testing.expectEqual(ActivityControl.filter, activityControlAt(filter.left + 2, filter.top + 2, &model, null, &state, 0).?); +} + test "shared sidebar layout routes every loop row after project rows and scroll" { var model = GraphModel.Model.init(std.testing.allocator); defer model.deinit(); @@ -1028,6 +1418,31 @@ test "recent projects are grouped into local and remote sections" { try std.testing.expectEqual(@as(usize, 0), rows.items[3].index); } +test "recent project rows exclude folders already open in the projects list" { + var model = GraphModel.Model.init(std.testing.allocator); + defer model.deinit(); + try model.recent_projects.append(.{ + .path = try std.testing.allocator.dupe(u8, "C:\\open"), + .name = try std.testing.allocator.dupe(u8, "Open local"), + }); + try model.recent_projects.append(.{ + .path = try std.testing.allocator.dupe(u8, "C:\\recent"), + .name = try std.testing.allocator.dupe(u8, "Recent local"), + }); + try model.open_projects.append(.{ + .path = try std.testing.allocator.dupe(u8, "C:\\open"), + .name = try std.testing.allocator.dupe(u8, "Open local"), + }); + var rows = try appendRows(std.testing.allocator, &model, null, 0, null); + defer rows.deinit(std.testing.allocator); + try std.testing.expectEqual(RowKind.local_heading, rows.items[0].kind); + try std.testing.expectEqual(RowKind.project, rows.items[1].kind); + try std.testing.expectEqualStrings("C:\\recent", rows.items[1].project_path.?); + try std.testing.expectEqual(RowKind.overview, rows.items[2].kind); + try std.testing.expectEqual(RowKind.open_project, rows.items[3].kind); + try std.testing.expectEqualStrings("C:\\open", rows.items[3].project_path.?); +} + test "handoff edges derive stable nested loop order and depth" { const nodes = [_]GraphModel.Node{ .{ .id = @constCast("child"), .title = @constCast("Child"), .loop_type = @constCast("turnBased"), .state = @constCast("idle"), .activity = @constCast(""), .presence = @constCast("idle") }, @@ -1175,6 +1590,15 @@ test "update banner is a bounded footer action" { try std.testing.expect(updateBannerRect(700, true).bottom < errorFooterRect(700).top); } +test "error footer exposes an inset wrapping rect" { + const footer = errorFooterRect(700); + const text = errorFooterTextRect(700); + try std.testing.expect(text.left > footer.left); + try std.testing.expect(text.right < footer.right); + try std.testing.expect(text.top > footer.top); + try std.testing.expect(text.bottom < footer.bottom); +} + fn rect(left: i32, top: i32, right: i32, bottom: i32) c.RECT { return .{ .left = left, .top = top, .right = right, .bottom = bottom }; } @@ -1203,3 +1627,21 @@ fn drawText( var bounds = rect(x, y, 1200, y + size + 8); _ = c.DrawTextW(hdc, wide.ptr, @intCast(wide.len), &bounds, c.DT_LEFT | c.DT_SINGLELINE | c.DT_END_ELLIPSIS); } + +fn drawTextRect( + hdc: c.HDC, + allocator: std.mem.Allocator, + text: []const u8, + bounds_value: c.RECT, + size: i32, + color: u32, + format: c.UINT, +) void { + _ = size; + const wide = std.unicode.utf8ToUtf16LeAlloc(allocator, text) catch return; + defer allocator.free(wide); + _ = c.SetTextColor(hdc, color); + _ = c.SetBkMode(hdc, c.TRANSPARENT); + var bounds = bounds_value; + _ = c.DrawTextW(hdc, wide.ptr, @intCast(wide.len), &bounds, format); +} diff --git a/graphcode-windows/src/Wire.zig b/graphcode-windows/src/Wire.zig index 67e18dae..127f87c8 100644 --- a/graphcode-windows/src/Wire.zig +++ b/graphcode-windows/src/Wire.zig @@ -590,6 +590,26 @@ pub fn commandGraphRefreshUsage(allocator: std.mem.Allocator, project_path: []co "{{\"graphCommand\":{{\"projectPath\":{s},\"command\":{{\"refreshUsage\":{{}}}}}}}}", .{path}); } +pub fn commandGraphSidebarNodesReordered( + allocator: std.mem.Allocator, + project_path: []const u8, + node_ids: []const []const u8, +) ![]u8 { + const path = try quoteJson(allocator, project_path); + defer allocator.free(path); + var quoted_ids: std.ArrayList([]u8) = .empty; + defer { + for (quoted_ids.items) |item| allocator.free(item); + quoted_ids.deinit(allocator); + } + for (node_ids) |node_id| try quoted_ids.append(allocator, try quoteJson(allocator, node_id)); + const joined = try std.mem.join(allocator, ",", quoted_ids.items); + defer allocator.free(joined); + return std.fmt.allocPrint(allocator, + "{{\"graphCommand\":{{\"projectPath\":{s},\"command\":{{\"sidebarNodesReordered\":{{\"_0\":[{s}]}}}}}}}}", + .{ path, joined }); +} + fn graphUnaryUUID(allocator: std.mem.Allocator, project_path: []const u8, name: []const u8, node_id: []const u8) ![]u8 { const path = try quoteJson(allocator, project_path); defer allocator.free(path); const id = try quoteJson(allocator, node_id); defer allocator.free(id); @@ -1073,6 +1093,12 @@ test "graph commands match Swift Codable associated-value shapes" { "{\"graphCommand\":{\"projectPath\":\"C:\\\\work\\\\graph\",\"command\":{\"deleteEdge\":{\"_0\":\"33333333-3333-4333-8333-333333333333\"}}}}", delete, ); + const reordered = try commandGraphSidebarNodesReordered(allocator, project, &.{ node, "22222222-2222-4222-8222-222222222222" }); + defer allocator.free(reordered); + try std.testing.expectEqualStrings( + "{\"graphCommand\":{\"projectPath\":\"C:\\\\work\\\\graph\",\"command\":{\"sidebarNodesReordered\":{\"_0\":[\"11111111-1111-4111-8111-111111111111\",\"22222222-2222-4222-8222-222222222222\"]}}}}", + reordered, + ); } test "global overview command uses the daemon command shape" { diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index 193b424f..f94e4dac 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -47,16 +47,16 @@ Statuses: | Quick Chats group | Selectable header, hover New Chat, disclosure, child rows | The native header remains selectable, reveals a hover-only New Chat action and disclosure, and exposes stable selectable child rows with Rename/Delete context actions. Focused menu tests cover stable chat identity; the live UIA gate invokes New Chat, collapses and restores children, and verifies child runtime identity survives. | Validated | | Local/remote sections | Group labels, independent collapse, folder/network glyphs | LOCAL and REMOTE retain local/folder and remote/network identity and now toggle independently as native section actions. Focused layout coverage validates mixed ordering, and the live UIA gate collapses LOCAL while proving the REMOTE row and its stable automation identity remain present before restoring LOCAL. | Validated | | Project rows | Selection, folder type, hover New Loop, disclosure | Open project rows retain selection and local/remote glyphs, reveal hover-only New Loop and disclosure controls, and collapse/restore their own loop tree without changing row identity. The live UIA gate invokes the project-row New Loop action into the real native node form and exercises project collapse/expand through stable UIA actions. | Validated | -| Nested loop tree | Edge-derived hierarchy, persisted expansion, drag reorder of roots | Handoff edges derive a cycle-safe root/descendant tree; nested rows disclose and collapse by stable node ID, and expanded IDs persist atomically in the GraphCode support directory. A transactional root-order validator and persisted `root` records now exist, but pointer drag handling, daemon/sidebar-order command integration, and live reorder evidence remain absent | Partial | +| Nested loop tree | Edge-derived hierarchy, persisted expansion, drag reorder of roots | Handoff edges derive a cycle-safe root/descendant tree; nested rows disclose and collapse by stable node ID, expanded IDs persist atomically in the GraphCode support directory, and root rows now reorder through live pointer drag backed by the existing transactional `root` records. Focused Sidebar/Wire coverage and the deterministic UIA gate verify observable reorder plus emission of the new `sidebarNodesReordered` daemon command for server-side persistence parity. | Validated | | Loop row presentation | Type stripe, title, elapsed time, state indicator | Rows now show a loop-type stripe, title, compact state indicator, and a compact elapsed value derived from `createdAt`. Focused/live executable evidence for the elapsed clock is not yet complete | Partial | -| Project context menu | Move, worktrees, settings, Explorer, remote info, close, remove, delete loops/project | Project rows now expose Move and a confirmed Windows Recycle Bin action for local folders in addition to the existing lifecycle actions. Move is currently routed to Explorer rather than a completed relocation flow, and live filesystem evidence is not yet complete | Partial | +| Project context menu | Move, worktrees, settings, Explorer, remote info, close, remove, delete loops/project | Project rows retain the existing lifecycle actions plus Move and the confirmed Windows Recycle Bin path for local folders. Windows keeps Move as the native Explorer `/select` handoff rather than an in-app relocation flow; the deterministic UIA gate now verifies the live shell-execute target and selected folder path. | Validated | | Loop context menu | Open, composite actions, rename, stop, delete | Sidebar and canvas loop rows share stable-ID Open, Rename, Stop, and Delete actions. Composite cards expose Open Group, Pilot Once, and Arm Schedule; the drilled-in canvas addresses mutations through the parent composite. Final live menu and accessibility evidence remains incomplete | Partial | -| Recent projects | Reachable from Add Folder menu | Recent rows remain directly under “Projects”; the new actions do not yet add the required Add Folder grouping or distinct recent/open presentation | Partial | -| Add Folder menu | Open Folder, Clone, Add Remote, recents | The File menu still exposes Open Folder, Clone Repository, and Add Remote Repository with shortcuts; the recent-folder submenu remains absent | Partial | +| Recent projects | Reachable from Add Folder menu | Recent and currently-open projects are now exposed as distinct sidebar rows, with unopened recents remaining under the LOCAL/REMOTE sections while open workspaces use separate `open-project` identities. The deterministic UIA gate verifies the split presentation and section behavior; recent-menu reachability is covered by the native menu tests, but a live submenu walkthrough remains outstanding. | Partial | +| Add Folder menu | Open Folder, Clone, Add Remote, recents | File now groups Open Folder, Clone Repository, and Add Remote Repository under Add Folder and adds a dedicated Recent Folders submenu with its own command range. Focused MainWindow coverage validates the native menu structure and recent-folder command wiring; a live submenu walkthrough remains outstanding. | Partial | | Sidebar update banner | Available version and click-to-install action | A persistent footer banner now shows the retained offered version and reopens the native update offer when clicked. A deterministic live fixture captured the banner and verified the click raises `GraphCode Update Available`; the offer still hands installation off to the verified release page | Partial | -| Sidebar error footer | Persistent, scoped project-ingress error | Folder, clone, remote, and daemon-open failures now persist in a dedicated red sidebar footer independently of transient status. Successful project ingress clears it, and a deterministic live fixture verifies it stacks below the update offer; long-message wrapping and dedicated UIA semantics remain incomplete | Partial | -| Needs-you section | Navigable list with reason/project and Stop action | Up to four entries now expose selection, explicit reason copy, stable UIA identities, and click/UIA navigation; Stop context routing and a live populated walkthrough remain incomplete | Partial | -| Activity strip | Optional bottom strip, summary, attention-only filter, horizontally scrolling actionable events | Activity events now retain project/node identity and timestamps, render timestamped cards, and expose stable UIA rows plus scroll controls; attention-only filtering, actual horizontal state, and live navigation evidence remain incomplete | Partial | +| Sidebar error footer | Persistent, scoped project-ingress error | Folder, clone, remote, and daemon-open failures now persist in a dedicated red sidebar footer independently of transient status. Successful project ingress clears it, wrapped layout preserves long messages, and the deterministic UIA gate verifies the dedicated footer identity plus multi-line bounds below the update offer. | Validated | +| Needs-you section | Navigable list with reason/project and Stop action | Up to four entries now expose selection, explicit reason copy, stable UIA identities, click/UIA navigation, and a dedicated Stop action. Focused routing coverage plus the deterministic UIA gate verify Stop targets the populated entry's real project path and loop ID. | Validated | +| Activity strip | Optional bottom strip, summary, attention-only filter, horizontally scrolling actionable events | Activity events retain project/node identity and timestamps, render timestamped cards, expose stable UIA rows plus scroll controls, and now keep a real horizontal viewport with an attention-only filter. Focused Sidebar coverage and the deterministic UIA gate verify scroll-state changes, attention-only filtering, and card navigation into the selected loop workspace. | Validated | ## Graph overview and project canvas