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
11 changes: 11 additions & 0 deletions Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13015,6 +13015,16 @@ private struct ExtensionSidebarBrowserStackEmptyArea: View {
// Do NOT remove .equatable() from the ForEach call site in VerticalTabsSidebar.
struct SidebarWorkspaceSnapshotBuilder {
struct PresentationKey: Equatable {
/// Identity of the workspace this snapshot was built for. Included in the
/// cache-validity key so a `TabItemView`'s memoized snapshot
/// (`workspaceSnapshotStorage` / `workspaceSnapshotScratch`) can never be
/// served for a *different* workspace after SwiftUI recycles the row's
/// `@State` across an identity change — the LazyVStack recycling hazard
/// behind #7519, where sidebar "Running" / "Needs input" pills attached to
/// neighbouring workspaces on cold load. The rest of the key is global
/// presentation settings, so without this field every row's key compared
/// equal and a recycled snapshot passed the guard silently.
let workspaceId: UUID
let showsWorkspaceDescription: Bool
let usesVerticalBranchLayout: Bool
let showsGitBranch: Bool
Expand Down Expand Up @@ -13514,6 +13524,7 @@ struct TabItemView: View, Equatable {

private var workspaceSnapshotPresentationKey: SidebarWorkspaceSnapshotBuilder.PresentationKey {
SidebarWorkspaceSnapshotBuilder.PresentationKey(
workspaceId: tab.id,
showsWorkspaceDescription: settings.showsWorkspaceDescription,
usesVerticalBranchLayout: sidebarBranchVerticalLayout,
showsGitBranch: sidebarShowGitBranch,
Expand Down
65 changes: 65 additions & 0 deletions cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,63 @@ import Testing
#expect(!decision.hasDeferredWorkspaceObservationInvalidation)
}

// MARK: - #7519 cold-load pill mis-attribution

/// `TabItemView.workspaceSnapshot` serves a memoized snapshot when its
/// `presentationKey` matches the row's current key
/// (`Sources/ContentView.swift`). This mirrors that guard so the cache
/// contract can be tested without a live SwiftUI hierarchy.
private static func servedSnapshot(
cached: SidebarWorkspaceSnapshotBuilder.Snapshot?,
currentKey: SidebarWorkspaceSnapshotBuilder.PresentationKey
) -> SidebarWorkspaceSnapshotBuilder.Snapshot? {
guard let cached, cached.presentationKey == currentKey else { return nil }
return cached
}

/// Two rows that differ only by which workspace they render must produce
/// distinct presentation keys. Before #7519 the key held only global
/// presentation settings, so every row's key compared equal and a recycled
/// snapshot silently passed the cache guard.
@Test func presentationKeyDistinguishesWorkspaces() {
let workspaceA = UUID(uuidString: "AAAAAAAA-0000-0000-0000-000000000001")!
let workspaceB = UUID(uuidString: "BBBBBBBB-0000-0000-0000-000000000002")!
let keyA = Self.presentationKey(workspaceId: workspaceA)
let keyB = Self.presentationKey(workspaceId: workspaceB)
#expect(keyA != keyB)
#expect(Self.presentationKey(workspaceId: workspaceA) == keyA)
}

/// Regression for #7519: a workspace's memoized snapshot (carrying its
/// "Running" / "Needs input" pills) must never be served for a *different*
/// workspace after the `LazyVStack` recycles the row's `@State` across an
/// identity change. The recycled snapshot must fail the cache guard so the
/// row rebuilds from its own workspace instead of showing a neighbour's.
@Test func memoizedSnapshotIsNotServedForADifferentWorkspace() {
let workspaceA = UUID(uuidString: "AAAAAAAA-0000-0000-0000-000000000001")!
let workspaceB = UUID(uuidString: "BBBBBBBB-0000-0000-0000-000000000002")!

// Workspace A has three running coding agents (its "Running" pill).
let cachedForA = Self.snapshot(
presentationKey: Self.presentationKey(workspaceId: workspaceA),
title: "workspace-a",
activeCodingAgentCount: 3
)

// The row is now bound to workspace B (idle) under identical global
// presentation settings — exactly the recycled-row state.
let currentKeyForB = Self.presentationKey(workspaceId: workspaceB)

let served = Self.servedSnapshot(cached: cachedForA, currentKey: currentKeyForB)

// Cache must MISS: B never inherits A's running-agent pill.
#expect(served == nil)

// And A's own row still serves its cached snapshot.
let currentKeyForA = Self.presentationKey(workspaceId: workspaceA)
#expect(Self.servedSnapshot(cached: cachedForA, currentKey: currentKeyForA) == cachedForA)
}

static func snapshot(
presentationKey: SidebarWorkspaceSnapshotBuilder.PresentationKey? = nil,
title: String = "workspace",
Expand Down Expand Up @@ -161,7 +218,14 @@ import Testing
)
}

/// Stable default identity so `snapshot()` / `presentationKey()` calls model
/// successive snapshots of ONE workspace (the common same-workspace case).
/// Cross-workspace tests pass distinct ids explicitly; keeping the default
/// random would make key-difference assertions pass on unrelated UUIDs.
static let defaultWorkspaceId = UUID(uuidString: "00000000-0000-0000-0000-000000000001")!

static func presentationKey(
workspaceId: UUID = SidebarWorkspaceSnapshotRefreshPolicyTests.defaultWorkspaceId,
showsWorkspaceDescription: Bool = true,
usesVerticalBranchLayout: Bool = true,
showsGitBranch: Bool = true,
Expand All @@ -177,6 +241,7 @@ import Testing
)
) -> SidebarWorkspaceSnapshotBuilder.PresentationKey {
SidebarWorkspaceSnapshotBuilder.PresentationKey(
workspaceId: workspaceId,
showsWorkspaceDescription: showsWorkspaceDescription,
usesVerticalBranchLayout: usesVerticalBranchLayout,
showsGitBranch: showsGitBranch,
Expand Down