Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,11 @@ jobs:
fi

if [ "$EXIT_CODE" -ne 0 ]; then
if grep -Fq "The legacy row-ID preference key must not aggregate per-row IDs" /tmp/test-output.txt \
|| grep -Fq "The default workspace sidebar must not publish non-empty row-ID layout preferences" /tmp/test-output.txt; then
echo "Sidebar row-ID preference regression failed"
exit 1
fi
SUMMARY=$(echo "$OUTPUT" | grep "Executed.*tests.*with.*failures" | tail -1)
if echo "$SUMMARY" | grep -q "(0 unexpected)"; then
echo "All failures are expected, treating as pass"
Expand Down
51 changes: 18 additions & 33 deletions Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10585,8 +10585,6 @@ struct VerticalTabsSidebar: View {
// row sitting behind the open menu. See `SidebarShortcutHintFreezePolicy`.
@State private var frozenShortcutHintsTabId: UUID?
@State private var frozenShortcutHintsValue: Bool = false
@State private var laidOutWorkspaceRowIds: Set<UUID> = []
@State private var pendingSelectedWorkspaceScrollId: UUID?
@State private var collapsedExtensionSidebarSectionIds: Set<String> = []
@State private var extensionSidebarWorktreeCreationInFlightSectionIds: Set<String> = []
@State private var extensionSidebarUpdateToken: UInt64 = 0
Expand Down Expand Up @@ -10865,28 +10863,14 @@ struct VerticalTabsSidebar: View {
return KeyboardShortcutSettings.shortcut(for: .selectWorkspaceByNumber)
}

private func requestSelectedWorkspaceScroll(_ proxy: ScrollViewProxy, workspaceIds: [UUID]) {
private func scrollSelectedWorkspaceIfNeeded(_ proxy: ScrollViewProxy, workspaceIds: [UUID]) {
guard let selectedWorkspaceId = tabManager.selectedTabId,
workspaceIds.contains(selectedWorkspaceId) else {
pendingSelectedWorkspaceScrollId = nil
return
}

pendingSelectedWorkspaceScrollId = selectedWorkspaceId
flushPendingSelectedWorkspaceScroll(proxy)
}

private func flushPendingSelectedWorkspaceScroll(
_ proxy: ScrollViewProxy,
laidOutWorkspaceRowIds: Set<UUID>? = nil
) {
guard let selectedWorkspaceId = pendingSelectedWorkspaceScrollId else { return }
let rowIds = laidOutWorkspaceRowIds ?? self.laidOutWorkspaceRowIds
guard rowIds.contains(selectedWorkspaceId) else { return }

// No anchor means SwiftUI scrolls the minimum needed to reveal the row.
proxy.scrollTo(selectedWorkspaceId)
pendingSelectedWorkspaceScrollId = nil
}

private func shouldRequestSelectedWorkspaceScrollAfterWorkspaceIdsChange(
Expand All @@ -10900,14 +10884,13 @@ struct VerticalTabsSidebar: View {
)
}

private func requestSelectedWorkspaceScrollAfterWorkspaceOrderChange(_ notification: Notification) {
private func shouldScrollSelectedWorkspaceAfterWorkspaceOrderChange(_ notification: Notification) -> Bool {
guard let manager = notification.object as? TabManager, manager === tabManager else {
return
return false
}
guard let selectedWorkspaceId = tabManager.selectedTabId else { return }
guard let selectedWorkspaceId = tabManager.selectedTabId else { return false }
let movedWorkspaceIds = notification.userInfo?[WorkspaceOrderChangeNotificationKey.movedWorkspaceIds] as? [UUID] ?? []
guard movedWorkspaceIds.contains(selectedWorkspaceId) else { return }
pendingSelectedWorkspaceScrollId = selectedWorkspaceId
return movedWorkspaceIds.contains(selectedWorkspaceId)
Comment on lines +10887 to +10893

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Scroll fires before re-render on order change

shouldScrollSelectedWorkspaceAfterWorkspaceOrderChange returning true causes scrollSelectedWorkspaceIfNeeded to call proxy.scrollTo synchronously during onReceive, before SwiftUI processes the updated tabManager.tabs into a new render. At that point renderContext.workspaceIds still carries the pre-move ordering and the LazyVStack still places the row at its old offset, so the scroll view jumps to the old position. After the subsequent render the row physically moves to its new slot, leaving the scroll position stale. The old preference-based flush path fired post-layout (via onPreferenceChange), so it always scrolled to the settled position. For visible-row drag reorders the effect is imperceptible, but programmatic moves that shift the selected workspace far in the list can leave scroll state stale.

}

struct WorkspaceListRenderContext {
Expand Down Expand Up @@ -11173,23 +11156,25 @@ struct VerticalTabsSidebar: View {
.background(Color.clear)
.modifier(ClearScrollBackground())
.onAppear {
requestSelectedWorkspaceScroll(scrollProxy, workspaceIds: renderContext.workspaceIds)
scrollSelectedWorkspaceIfNeeded(scrollProxy, workspaceIds: renderContext.workspaceIds)
}
.onChange(of: tabManager.selectedTabId) { _, _ in
requestSelectedWorkspaceScroll(scrollProxy, workspaceIds: renderContext.workspaceIds)
scrollSelectedWorkspaceIfNeeded(scrollProxy, workspaceIds: renderContext.workspaceIds)
}
.onChange(of: renderContext.workspaceIds) { oldWorkspaceIds, newWorkspaceIds in
guard shouldRequestSelectedWorkspaceScrollAfterWorkspaceIdsChange(
from: oldWorkspaceIds,
to: newWorkspaceIds
) else {
flushPendingSelectedWorkspaceScroll(scrollProxy)
return
}
requestSelectedWorkspaceScroll(scrollProxy, workspaceIds: newWorkspaceIds)
scrollSelectedWorkspaceIfNeeded(scrollProxy, workspaceIds: newWorkspaceIds)
}
.onReceive(NotificationCenter.default.publisher(for: .workspaceOrderDidChange)) { notification in
requestSelectedWorkspaceScrollAfterWorkspaceOrderChange(notification)
guard shouldScrollSelectedWorkspaceAfterWorkspaceOrderChange(notification) else {
return
}
scrollSelectedWorkspaceIfNeeded(scrollProxy, workspaceIds: renderContext.workspaceIds)
}
.onReceive(NotificationCenter.default.publisher(for: .workspaceCurrentDirectoryDidChange)) { _ in
// Drive a revision counter that the group-header resolver
Expand Down Expand Up @@ -11236,10 +11221,6 @@ struct VerticalTabsSidebar: View {
lastSidebarSelectionIndex = index
}
}
.onPreferenceChange(SidebarWorkspaceRowIdsPreferenceKey.self) { rowIds in
laidOutWorkspaceRowIds = rowIds
flushPendingSelectedWorkspaceScroll(scrollProxy, laidOutWorkspaceRowIds: rowIds)
}
}
}
}
Expand Down Expand Up @@ -12625,7 +12606,6 @@ struct VerticalTabsSidebar: View {
.equatable()
.id(tab.id)
.accessibilityIdentifier("sidebarWorkspace.\(tab.id.uuidString)")
.preference(key: SidebarWorkspaceRowIdsPreferenceKey.self, value: Set([tab.id]))

row
.sidebarWorkspaceFrameAnchor(id: tab.id, isEnabled: shouldCollectWorkspaceDropTargets)
Expand All @@ -12638,11 +12618,16 @@ struct VerticalTabsSidebar: View {
}
}

/// Legacy row-ID preference key kept for regression coverage.
///
/// The default workspace sidebar must not emit this preference from rows: doing
/// so forces SwiftUI to aggregate a layout-derived value across the lazy list
/// during every transaction, which can feed a non-converging layout loop.
struct SidebarWorkspaceRowIdsPreferenceKey: PreferenceKey {
static let defaultValue: Set<UUID> = []

static func reduce(value: inout Set<UUID>, nextValue: () -> Set<UUID>) {
value.formUnion(nextValue())
value = []
}
}

Expand Down
1 change: 0 additions & 1 deletion Sources/VerticalTabsSidebar+WorkspaceGroups.swift
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,6 @@ extension VerticalTabsSidebar {
.equatable()
.id(group.anchorWorkspaceId)
.accessibilityIdentifier("sidebarWorkspaceGroup.\(group.id.uuidString)")
.preference(key: SidebarWorkspaceRowIdsPreferenceKey.self, value: Set([group.anchorWorkspaceId]))

header
.sidebarWorkspaceFrameAnchor(
Expand Down
4 changes: 4 additions & 0 deletions cmux.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
objects = {

/* Begin PBXBuildFile section */
A5570A000000000000000001 /* AAASidebarWorkspaceRowIdPreferenceRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5570A000000000000000002 /* AAASidebarWorkspaceRowIdPreferenceRegressionTests.swift */; };
A9E020000000000000000006 /* agent-session-react in Resources */ = {isa = PBXBuildFile; fileRef = A9E010000000000000000006 /* agent-session-react */; };
A9E020000000000000000007 /* agent-session-solid in Resources */ = {isa = PBXBuildFile; fileRef = A9E010000000000000000007 /* agent-session-solid */; };
A9F200000000000000000001 /* AgentExecutableResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F100000000000000000001 /* AgentExecutableResolver.swift */; };
Expand Down Expand Up @@ -771,6 +772,7 @@
/* End PBXCopyFilesBuildPhase section */

/* Begin PBXFileReference section */
A5570A000000000000000002 /* AAASidebarWorkspaceRowIdPreferenceRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AAASidebarWorkspaceRowIdPreferenceRegressionTests.swift; sourceTree = "<group>"; };
A9E010000000000000000006 /* agent-session-react */ = {isa = PBXFileReference; lastKnownFileType = folder; path = "agent-session-react"; sourceTree = "<group>"; };
A9E010000000000000000007 /* agent-session-solid */ = {isa = PBXFileReference; lastKnownFileType = folder; path = "agent-session-solid"; sourceTree = "<group>"; };
A9F100000000000000000001 /* AgentExecutableResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentExecutableResolver.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2055,6 +2057,7 @@
F1000003A1B2C3D4E5F60718 /* cmuxTests */ = {
isa = PBXGroup;
children = (
A5570A000000000000000002 /* AAASidebarWorkspaceRowIdPreferenceRegressionTests.swift */,
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */,
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */,
D3571003A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift */,
Expand Down Expand Up @@ -3120,6 +3123,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A5570A000000000000000001 /* AAASidebarWorkspaceRowIdPreferenceRegressionTests.swift in Sources */,
A9E020000000000000000005 /* AgentExecutableResolverTests.swift in Sources */,
D36A00020000000000000001 /* AgentHibernationTests.swift in Sources */,
D3610B010000000000000001 /* AgentSessionAutoResumeSettingsTests.swift in Sources */,
Expand Down
130 changes: 130 additions & 0 deletions cmuxTests/AAASidebarWorkspaceRowIdPreferenceRegressionTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import AppKit
import CmuxUpdater
import SwiftUI
import XCTest

#if canImport(cmux_DEV)
@testable import cmux_DEV
#elseif canImport(cmux)
@testable import cmux
#endif

final class AAASidebarWorkspaceRowIdPreferenceRegressionTests: XCTestCase {
func testAAARowIdPreferenceKeyDoesNotAggregatePerRowIds() {
let rowIds = (0..<32).map { _ in UUID() }
var reducedRowIds: Set<UUID> = []

for rowId in rowIds {
SidebarWorkspaceRowIdsPreferenceKey.reduce(value: &reducedRowIds) {
Set([rowId])
}
}

XCTAssertTrue(
reducedRowIds.isEmpty,
"The legacy row-ID preference key must not aggregate per-row IDs. Aggregating this key performs Set.formUnion across the lazy sidebar rows in the SwiftUI/AttributeGraph layout loop reported in https://github.com/manaflow-ai/cmux/issues/5570 and https://github.com/manaflow-ai/cmux/issues/2586."
)
}

@MainActor
func testDefaultWorkspaceSidebarDoesNotPublishRowIdLayoutPreferences() {
_ = NSApplication.shared

let defaults = UserDefaults.standard
let previousProviderId = defaults.object(forKey: CmuxExtensionSidebarSelection.defaultsKey)
CmuxExtensionSidebarSelection.setProviderId(CmuxExtensionSidebarSelection.defaultProviderId)
defer {
if let previousProviderId {
defaults.set(previousProviderId, forKey: CmuxExtensionSidebarSelection.defaultsKey)
} else {
defaults.removeObject(forKey: CmuxExtensionSidebarSelection.defaultsKey)
}
}

let tabManager = TabManager(
initialWorkspaceTitle: "Workspace 0",
autoWelcomeIfNeeded: false
)
for index in 1..<32 {
tabManager.addWorkspace(
title: "Workspace \(index)",
select: false,
eagerLoadTerminal: false,
autoWelcomeIfNeeded: false,
autoRefreshMetadata: false
)
}

var observedRowIdPreferences: [Set<UUID>] = []
let root = SidebarWorkspaceRowIdPreferenceProbe(
tabManager: tabManager,
onRowIdsPreference: { rowIds in
observedRowIdPreferences.append(rowIds)
}
)

let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 260, height: 520),
styleMask: [.titled, .closable, .resizable],
backing: .buffered,
defer: false
)
let hostingView = MainWindowHostingView(rootView: root)
hostingView.frame = window.contentRect(forFrameRect: window.frame)
window.contentView = hostingView
defer {
window.contentView = nil
window.orderOut(nil)
}

window.makeKeyAndOrderFront(nil)
window.displayIfNeeded()
hostingView.layoutSubtreeIfNeeded()
for _ in 0..<5 {
RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01))
window.displayIfNeeded()
hostingView.layoutSubtreeIfNeeded()
}

let nonEmptyRowIdPreferences = observedRowIdPreferences.filter { !$0.isEmpty }
XCTAssertTrue(
nonEmptyRowIdPreferences.isEmpty,
"The default workspace sidebar must not publish non-empty row-ID layout preferences in steady state. A row-wide layout preference feeds the SwiftUI/AttributeGraph layout loop reported in https://github.com/manaflow-ai/cmux/issues/5570 and https://github.com/manaflow-ai/cmux/issues/2586."
)
}
}

private struct SidebarWorkspaceRowIdPreferenceProbe: View {
@State private var selection: SidebarSelection = .tabs
@State private var selectedTabIds: Set<UUID> = []
@State private var lastSidebarSelectionIndex: Int?

let tabManager: TabManager
let onRowIdsPreference: (Set<UUID>) -> Void
private let updateViewModel = UpdateStateModel()
private let fileExplorerState = FileExplorerState()
private let cmuxConfigStore = CmuxConfigStore()
private let windowId = UUID()

var body: some View {
VerticalTabsSidebar(
updateViewModel: updateViewModel,
fileExplorerState: fileExplorerState,
windowId: windowId,
onSendFeedback: {},
onToggleSidebar: {},
onNewTab: {},
observedWindow: nil,
selection: $selection,
selectedTabIds: $selectedTabIds,
lastSidebarSelectionIndex: $lastSidebarSelectionIndex
)
.environmentObject(tabManager)
.environmentObject(TerminalNotificationStore.shared)
.environmentObject(cmuxConfigStore)
.frame(width: 260, height: 520)
.onPreferenceChange(SidebarWorkspaceRowIdsPreferenceKey.self) { rowIds in
onRowIdsPreference(rowIds)
}
}
}
1 change: 1 addition & 0 deletions cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AppKit
import CmuxUpdater

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check if tests compile without the CmuxUpdater import

# Remove the import and try to build the tests
cd "$(git rev-parse --show-toplevel)"

# Create a temporary file without the import
sed '2d' cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift > /tmp/test_without_import.swift

# Try to compile just this test file
xcodebuild -project cmux.xcodeproj \
  -scheme cmux \
  -configuration Debug \
  -destination 'platform=macOS' \
  -derivedDataPath /tmp/cmux-verify-import \
  build-for-testing \
  2>&1 | grep -i "SidebarWorkspaceSnapshotRefreshPolicyTests"

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

#!/bin/bash
set -euo pipefail

cd "$(git rev-parse --show-toplevel)"

echo "== command availability =="
command -v xcodebuild || true

echo
echo "== file excerpt =="
nl -ba cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift | head -n 200

echo
echo "== direct CmuxUpdater symbol usage in file =="
rg -n "CmuxUpdater|CmuxUpdater\." cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift || true

echo
echo "== quick compile check: build-for-testing baseline =="
# Note: this will likely fail in this sandbox if Xcode isn't available.
xcodebuild -project cmux.xcodeproj \
  -scheme cmux \
  -configuration Debug \
  -destination 'platform=macOS' \
  -derivedDataPath /tmp/cmux-verify-import-baseline \
  build-for-testing 2>&1 | head -n 200 || true

Repository: manaflow-ai/cmux

Length of output: 169


🏁 Script executed:

#!/bin/bash
set -euo pipefail

cd "$(git rev-parse --show-toplevel)"

echo "== tools =="
command -v rg || true
command -v cat || true
command -v sed || true
command -v head || true
command -v xcodebuild || true

echo
echo "== imports in SidebarWorkspaceSnapshotRefreshPolicyTests.swift =="
rg -n "^import " cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift || true

echo
echo "== CmuxUpdater symbol usage in file =="
rg -n "CmuxUpdater(\.|\\b)" cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift || true

echo
echo "== first ~120 lines with numbers (cat -n) =="
cat -n cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift | head -n 160

echo
echo "== whether file imports testable import cmux =="
rg -n "testable import cmux|`@testable` import cmux" cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift || true

echo
echo "== sample: other *Sidebar* test files import CmuxUpdater? =="
rg -n "^import CmuxUpdater" cmuxTests | head -n 50 || true

Repository: manaflow-ai/cmux

Length of output: 7964


🏁 Script executed:

#!/bin/bash
set -euo pipefail

cd "$(git rev-parse --show-toplevel)"

echo "== tools =="
command -v rg || true
command -v cat || true
command -v sed || true
command -v head || true
command -v xcodebuild || true

echo
echo "== imports in SidebarWorkspaceSnapshotRefreshPolicyTests.swift =="
rg -n "^import " cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift || true

echo
echo "== CmuxUpdater symbol usage in file =="
rg -n "CmuxUpdater(\.|\\b)" cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift || true

echo
echo "== first ~160 lines with numbers (cat -n) =="
cat -n cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift | head -n 200

echo
echo "== whether file imports testable import cmux =="
rg -n "testable import cmux|`@testable` import cmux" cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift || true

echo
echo "== sample: other tests import CmuxUpdater? =="
rg -n "^import CmuxUpdater" cmuxTests | head -n 50 || true

Repository: manaflow-ai/cmux

Length of output: 9598


Remove the unused CmuxUpdater import from cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift.

import CmuxUpdater is the only occurrence of CmuxUpdater in this file; all referenced test types come via the existing @testable import cmux. Remove the import to reduce redundant module wiring.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift` at line 2, Remove
the unused top-level import by deleting the line "import CmuxUpdater" from
SidebarWorkspaceSnapshotRefreshPolicyTests.swift; all test types are already
available via "`@testable` import cmux", so removing this redundant import (the
lone occurrence of CmuxUpdater in the file) will eliminate unnecessary module
wiring.

import SwiftUI
import XCTest

Expand Down
Loading