diff --git a/Scripts/Fixtures/test-suite-contract-ledger.tsv b/Scripts/Fixtures/test-suite-contract-ledger.tsv index 70a067722..7c8e8acdd 100644 --- a/Scripts/Fixtures/test-suite-contract-ledger.tsv +++ b/Scripts/Fixtures/test-suite-contract-ledger.tsv @@ -2823,3 +2823,9 @@ root/RepoPromptTests.AgentNavigationHUDSnapshotBuilderTests/testSearchTokensSpli root/RepoPromptTests.AgentModeSidebarNavigationTests/testParentCyclingUsesRootRowsAndMapsChildToNearestRoot root Tests/RepoPromptTests/AgentMode/AgentModeSidebarNavigationTests.swift RepoPromptTests.AgentModeSidebarNavigationTests testParentCyclingUsesRootRowsAndMapsChildToNearestRoot AgentMode/Sidebar agent_sidebar_navigation.parent_cycle_child_to_root keyboard_navigation,subagents,parent_sessions deterministic_regression root_swiftpm 2 Parent-session cycling from child rows advances to the neighboring root row in both forward and backward directions. Keyboard cycling could unexpectedly land on subagents or skip the intended parent session groups. test_case retain 0 PR #352 retained for user-visible keyboard navigation over nested sessions. root/RepoPromptTests.AgentModeSidebarNavigationTests/testParentCyclingWrapsAndFallsBackWithoutActiveTab root Tests/RepoPromptTests/AgentMode/AgentModeSidebarNavigationTests.swift RepoPromptTests.AgentModeSidebarNavigationTests testParentCyclingWrapsAndFallsBackWithoutActiveTab AgentMode/Sidebar agent_sidebar_navigation.parent_cycle_wrap_fallback keyboard_navigation,wraparound,no_active_tab deterministic_regression root_swiftpm 3 Parent-session cycling wraps at list boundaries and chooses a deterministic first/last parent when no active tab is available. Global navigation shortcuts could fail or pick an arbitrary target when focus state is absent or at a boundary. test_case retain 0 PR #352 retained for boundary behavior of sidebar keyboard navigation. root/RepoPromptTests.AppPlatformUtilityRecoveryTests/testInAppAgentSessionRouteReturnsResultWithoutQueueingURL root Tests/RepoPromptTests/App/AppPlatformUtilityRecoveryTests.swift RepoPromptTests.AppPlatformUtilityRecoveryTests testInAppAgentSessionRouteReturnsResultWithoutQueueingURL App/Routing app_deep_link.agent_session_in_app_no_url_queue agent_session_route,in_app_routing,workspace_unavailable deterministic_regression root_swiftpm 1 In-app agent-session routing returns workspaceUnavailable without queueing a URL when no live windows are registered. HUD session jumps could enqueue ephemeral agent-session URLs and replay stale navigation later. shared_singleton WindowStatesManager test_case+defer_restore retain 0 PR #352 retained as a small side-effect regression for non-URL in-app routing. +root/RepoPromptTests.AgentWorkspaceRootsSidebarStoreTests/testWorkspaceRootCodemapActivityMapsAcceptedProgressEvents root Tests/RepoPromptTests/AgentMode/AgentWorkspaceRootsSidebarStoreTests.swift RepoPromptTests.AgentWorkspaceRootsSidebarStoreTests testWorkspaceRootCodemapActivityMapsAcceptedProgressEvents AgentMode agent_sidebar.codemap_progress.event_to_activity_mapping codemap_progress,segment,seal,catalog_completion deterministic_unit root_swiftpm 4 Accepted progress events map to determinate scanning with catalog-completion totals, indeterminate scanning without totals, and ready for sealed or complete-phase events. A wrong mapping could show indefinite scanning or premature ready for roots whose projection state differs. direct_in_process test_case retain 0 Per-root codemap sidebar indicator: event-to-activity mapping contract. +root/RepoPromptTests.AgentWorkspaceRootsSidebarStoreTests/testCodemapProgressStatesJoinActivityByRootUUIDOnlyAndDescribeStates root Tests/RepoPromptTests/AgentMode/AgentWorkspaceRootsSidebarStoreTests.swift RepoPromptTests.AgentWorkspaceRootsSidebarStoreTests testCodemapProgressStatesJoinActivityByRootUUIDOnlyAndDescribeStates AgentMode agent_sidebar.codemap_progress.root_uuid_join codemap_progress,root_identity,worktree_non_cross_wiring,display_text,accessibility deterministic_unit root_swiftpm 4 Progress states join strictly by visible root UUID: hidden physical worktree root activity is dropped, idle roots render nothing, and display/accessibility texts match each state. Path-based or loose joining could cross-wire hidden worktree scan progress onto visible logical root rows. direct_in_process test_case retain 0 Per-root codemap sidebar indicator: UUID-only join and state texts. +root/RepoPromptTests.AgentWorkspaceRootsSidebarStoreTests/testCodemapProgressStatesGlobalDisableOverridesActivity root Tests/RepoPromptTests/AgentMode/AgentWorkspaceRootsSidebarStoreTests.swift RepoPromptTests.AgentWorkspaceRootsSidebarStoreTests testCodemapProgressStatesGlobalDisableOverridesActivity AgentMode agent_sidebar.codemap_progress.global_disable_override codemap_progress,global_disable deterministic_unit root_swiftpm 2 With the global code maps kill-switch on, every visible root maps to the disabled state regardless of per-root activity presence. Scan progress could keep rendering while code maps are globally disabled, contradicting the app-wide setting. direct_in_process test_case retain 0 Per-root codemap sidebar indicator: global disable wins. +root/RepoPromptTests.AgentWorkspaceRootsSidebarStoreTests/testCodemapActivityUpdatesPreserveRowIdentityAndGlobalDisableRoundTrips root Tests/RepoPromptTests/AgentMode/AgentWorkspaceRootsSidebarStoreTests.swift RepoPromptTests.AgentWorkspaceRootsSidebarStoreTests testCodemapActivityUpdatesPreserveRowIdentityAndGlobalDisableRoundTrips AgentMode agent_sidebar.codemap_progress.no_row_churn codemap_progress,row_identity,throttle,global_disable,combine_publisher state_observation root_swiftpm 4 Throttled activity updates publish per-root progress states without changing rootRows value/identity, and toggling global disable overrides then restores activity-derived states. Progress ticks could rebuild sidebar rows and cause reorder/flicker, or a disable toggle could strand stale progress states. direct_in_process;combine_publisher test_case retain 0 Per-root codemap sidebar indicator: live store churn and disable round-trip. +root/RepoPromptTests.WorkspaceFileContextStoreCodemapSeamTests/testAcceptedProjectionSnapshotsYieldRootProgressStreamEvents root Tests/RepoPromptTests/WorkspaceContext/WorkspaceFileContextStoreCodemapSeamTests.swift RepoPromptTests.WorkspaceFileContextStoreCodemapSeamTests testAcceptedProjectionSnapshotsYieldRootProgressStreamEvents WorkspaceContext/CodeMap codemap.root_progress_stream.accepted_snapshot_events codemap_progress,projection_segment,coverage_seal,async_stream durable_publication_contract root_swiftpm 3 CodemapStoreFixture,ReviewGitRepositoryFixture Accepted projection segments yield non-sealed root progress events with processed/total counts and the accepted seal yields a sealed complete-phase event, all keyed to the loaded root UUID. The sidebar progress stream could miss accepted publications, mis-key roots, or never observe completion. filesystem,git_fixture,artifact_store,actor,concurrency store_session+progress_observer+root_unload retain 0 Per-root codemap sidebar indicator: store progress stream propagation. +root/RepoPromptTests.AgentWorkspaceRootsSidebarStoreTests/testCodemapProgressInitialGlobalDisableSuppressesActivitySynchronously root Tests/RepoPromptTests/AgentMode/AgentWorkspaceRootsSidebarStoreTests.swift RepoPromptTests.AgentWorkspaceRootsSidebarStoreTests testCodemapProgressInitialGlobalDisableSuppressesActivitySynchronously AgentMode agent_sidebar.codemap_progress.initial_global_disable codemap_progress,global_disable,initial_state deterministic_unit root_swiftpm 2 A store initialized while global code maps are disabled publishes disabled states synchronously instead of briefly exposing scan activity. The sidebar could flash active codemap progress during Agent Mode initialization even though the global code-map kill-switch is on. direct_in_process test_case retain 0 Per-root codemap sidebar indicator: initial global disable suppression. diff --git a/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentWorkspaceRootsSidebarStore.swift b/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentWorkspaceRootsSidebarStore.swift index 440de66fd..535adf6b2 100644 --- a/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentWorkspaceRootsSidebarStore.swift +++ b/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentWorkspaceRootsSidebarStore.swift @@ -1,6 +1,43 @@ import Combine import Foundation +/// Subtle per-root codemap scanning/loading state rendered under the root +/// identity line in the Agent Mode workspace roots sidebar. +enum AgentRootCodemapProgressDisplayState: Equatable { + /// Projection scan in flight; determinate when `total` is known. + case scanning(processed: UInt64, total: UInt64?) + /// Projection coverage complete for the root's current generation. + case ready + /// The global code maps kill-switch is on; overrides any activity. + case disabledGlobally + + var displayText: String { + switch self { + case let .scanning(processed, total?): + "Codemaps \(processed)/\(total)" + case .scanning: + "Codemaps scanning…" + case .ready: + "Codemaps ready" + case .disabledGlobally: + "Codemaps disabled globally" + } + } + + var accessibilityText: String { + switch self { + case let .scanning(processed, total?): + "Codemaps scanning, \(processed) of \(total) files processed" + case let .scanning(processed, nil): + "Codemaps scanning, \(processed) files processed" + case .ready: + "Codemaps ready" + case .disabledGlobally: + "Codemaps disabled globally" + } + } +} + struct AgentWorkspaceRootRow: Identifiable, Equatable { let id: UUID let name: String @@ -54,17 +91,26 @@ final class AgentWorkspaceRootsSidebarStore: ObservableObject { @Published private(set) var rootRows: [AgentWorkspaceRootRow] = [] @Published private(set) var workspaceLabel = "No Workspace" @Published private(set) var isExitDisabled = true + /// Per-root codemap progress display states keyed by root UUID + /// (`AgentWorkspaceRootRow.id`). Published separately from `rootRows` so + /// progress ticks never rebuild rows or disturb row identity/ordering. + @Published private(set) var codemapProgressByRootID: [UUID: AgentRootCodemapProgressDisplayState] = [:] private let rootProjections: @MainActor () -> [WorkspaceRootShellProjection] private let rootChanges: AnyPublisher private let gitContextLookup: @MainActor (String) -> GitWorktreeContextSummary? private let gitContextChanges: AnyPublisher + private let codemapActivityLookup: @MainActor () -> [UUID: WorkspaceRootCodemapActivity] + private let codemapActivityChanges: AnyPublisher + private let codemapsGloballyDisabled: AnyPublisher + private let codemapActivityThrottleMilliseconds: Int private let workspaceManager: WorkspaceManagerViewModel let windowID: Int private var cancellables: Set = [] private var rootRowsResnapshotTask: Task? private var workspaceMetadataResnapshotTask: Task? + private var isCodemapsGloballyDisabled = false var workspaceManagerForPicker: WorkspaceManagerViewModel { workspaceManager @@ -75,6 +121,11 @@ final class AgentWorkspaceRootsSidebarStore: ObservableObject { rootChanges: AnyPublisher, gitContextLookup: @escaping @MainActor (String) -> GitWorktreeContextSummary? = { _ in nil }, gitContextChanges: AnyPublisher = Empty().eraseToAnyPublisher(), + codemapActivityLookup: @escaping @MainActor () -> [UUID: WorkspaceRootCodemapActivity] = { [:] }, + codemapActivityChanges: AnyPublisher = Empty().eraseToAnyPublisher(), + codemapsGloballyDisabled: AnyPublisher? = nil, + initialCodemapsGloballyDisabled: Bool? = nil, + codemapActivityThrottleMilliseconds: Int = 150, workspaceManager: WorkspaceManagerViewModel, windowID: Int ) { @@ -82,6 +133,13 @@ final class AgentWorkspaceRootsSidebarStore: ObservableObject { self.rootChanges = rootChanges self.gitContextLookup = gitContextLookup self.gitContextChanges = gitContextChanges + self.codemapActivityLookup = codemapActivityLookup + self.codemapActivityChanges = codemapActivityChanges + self.codemapsGloballyDisabled = codemapsGloballyDisabled + ?? GlobalSettingsStore.shared.$codeMapsGloballyDisabled.eraseToAnyPublisher() + isCodemapsGloballyDisabled = initialCodemapsGloballyDisabled + ?? (codemapsGloballyDisabled == nil ? GlobalSettingsStore.shared.globalCodeMapsDisabled() : false) + self.codemapActivityThrottleMilliseconds = max(0, codemapActivityThrottleMilliseconds) self.workspaceManager = workspaceManager self.windowID = windowID @@ -95,6 +153,32 @@ final class AgentWorkspaceRootsSidebarStore: ObservableObject { workspaceMetadataResnapshotTask?.cancel() } + /// Pure mapping from visible root projections + per-root activity to + /// display states. Progress is joined strictly by root UUID; activity + /// entries whose IDs do not match a visible projection (for example hidden + /// physical worktree session roots) are dropped. Global disable wins. + static func codemapProgressStates( + for projections: [WorkspaceRootShellProjection], + activityByRootID: [UUID: WorkspaceRootCodemapActivity], + globallyDisabled: Bool + ) -> [UUID: AgentRootCodemapProgressDisplayState] { + if globallyDisabled { + return Dictionary(uniqueKeysWithValues: projections.map { ($0.id, .disabledGlobally) }) + } + var states: [UUID: AgentRootCodemapProgressDisplayState] = [:] + for projection in projections { + switch activityByRootID[projection.id] { + case let .scanning(processed, total): + states[projection.id] = .scanning(processed: processed, total: total) + case .ready: + states[projection.id] = .ready + case nil: + break + } + } + return states + } + static func rows( from projections: [WorkspaceRootShellProjection], gitContextLookup: (String) -> GitWorktreeContextSummary? = { _ in nil } @@ -174,6 +258,30 @@ final class AgentWorkspaceRootsSidebarStore: ObservableObject { } .store(in: &cancellables) + codemapActivityChanges + .throttle( + for: .milliseconds(codemapActivityThrottleMilliseconds), + scheduler: DispatchQueue.main, + latest: true + ) + .sink { [weak self] in + Task { @MainActor in + self?.resnapshotCodemapProgress() + } + } + .store(in: &cancellables) + + codemapsGloballyDisabled + .removeDuplicates() + .sink { [weak self] disabled in + Task { @MainActor in + guard let self, self.isCodemapsGloballyDisabled != disabled else { return } + self.isCodemapsGloballyDisabled = disabled + self.resnapshotCodemapProgress() + } + } + .store(in: &cancellables) + workspaceManager.objectWillChange .sink { [weak self] _ in Task { @MainActor in @@ -214,6 +322,18 @@ final class AgentWorkspaceRootsSidebarStore: ObservableObject { if rootRows != nextRootRows { rootRows = nextRootRows } + resnapshotCodemapProgress() + } + + private func resnapshotCodemapProgress() { + let next = Self.codemapProgressStates( + for: rootProjections(), + activityByRootID: codemapActivityLookup(), + globallyDisabled: isCodemapsGloballyDisabled + ) + if codemapProgressByRootID != next { + codemapProgressByRootID = next + } } private func resnapshotWorkspaceMetadata() { diff --git a/Sources/RepoPrompt/Features/AgentMode/Views/AgentModeView.swift b/Sources/RepoPrompt/Features/AgentMode/Views/AgentModeView.swift index 59f091c01..3cd1f5093 100644 --- a/Sources/RepoPrompt/Features/AgentMode/Views/AgentModeView.swift +++ b/Sources/RepoPrompt/Features/AgentMode/Views/AgentModeView.swift @@ -38,6 +38,8 @@ struct AgentModeView: View { rootChanges: windowState.workspaceFilesViewModel.rootShellProjectionsChangedPublisher, gitContextLookup: { promptManager.gitViewModel.gitWorktreeContext(forStandardizedRootPath: $0) }, gitContextChanges: promptManager.gitViewModel.gitWorktreeContextChanges, + codemapActivityLookup: { windowState.workspaceFilesViewModel.codemapRootActivityByRootID }, + codemapActivityChanges: windowState.workspaceFilesViewModel.codemapRootActivityChangedPublisher, workspaceManager: windowState.workspaceManager, windowID: windowState.windowID )) diff --git a/Sources/RepoPrompt/Features/AgentMode/Views/Components/AgentWorkspaceRootsSectionView.swift b/Sources/RepoPrompt/Features/AgentMode/Views/Components/AgentWorkspaceRootsSectionView.swift index 8768496e0..b644ce166 100644 --- a/Sources/RepoPrompt/Features/AgentMode/Views/Components/AgentWorkspaceRootsSectionView.swift +++ b/Sources/RepoPrompt/Features/AgentMode/Views/Components/AgentWorkspaceRootsSectionView.swift @@ -211,6 +211,13 @@ struct AgentWorkspaceRootsSectionView: View { private func rowHasContextLine(_ row: AgentWorkspaceRootRow) -> Bool { row.gitContext != nil || row.worktree != nil || mergeAttention(for: row) != nil + || codemapProgress(for: row) != nil + } + + /// Resolves the subtle per-root codemap scanning/loading state for `row` + /// by root UUID. Absent (idle) roots render nothing. + private func codemapProgress(for row: AgentWorkspaceRootRow) -> AgentRootCodemapProgressDisplayState? { + rootsStore.codemapProgressByRootID[row.id] } private var shouldScrollFolderList: Bool { @@ -477,10 +484,37 @@ struct AgentWorkspaceRootsSectionView: View { .layoutPriority(0) } + if let codemapState = codemapProgress(for: row) { + codemapProgressLabel(codemapState) + .layoutPriority(-1) + } + Spacer(minLength: 0) } } + // MARK: - Codemap Progress Label + + /// Subtle per-root codemap scanning/loading indicator rendered under the + /// root identity line. Deliberately quiet: small secondary text, no + /// spinner, truncates before capsules at narrow widths. + private func codemapProgressLabel(_ state: AgentRootCodemapProgressDisplayState) -> some View { + Text(state.displayText) + .font(fontPreset.swiftUIFont(sizeAtNormal: 9)) + .foregroundColor(.secondary) + .opacity(codemapProgressOpacity(state)) + .lineLimit(1) + .truncationMode(.tail) + .accessibilityLabel(state.accessibilityText) + } + + private func codemapProgressOpacity(_ state: AgentRootCodemapProgressDisplayState) -> Double { + switch state { + case .scanning: 0.9 + case .ready, .disabledGlobally: 0.55 + } + } + private func rootActionsOverlay(_ row: AgentWorkspaceRootRow, hasMultipleRoots: Bool) -> some View { HStack(spacing: rootActionOverlaySpacing) { if hasMultipleRoots { diff --git a/Sources/RepoPrompt/Features/WorkspaceFiles/Models/WorkspaceRootCodemapActivity.swift b/Sources/RepoPrompt/Features/WorkspaceFiles/Models/WorkspaceRootCodemapActivity.swift new file mode 100644 index 000000000..55cba868d --- /dev/null +++ b/Sources/RepoPrompt/Features/WorkspaceFiles/Models/WorkspaceRootCodemapActivity.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Compact per-root codemap projection activity derived from accepted +/// `WorkspaceCodemapRootProjectionProgressEvent`s. Keyed by root UUID +/// (`FolderViewModel.id` / `WorkspaceRootShellProjection.id`); never keyed by path. +enum WorkspaceRootCodemapActivity: Equatable { + /// Projection scan/publish in flight. `total` is known only once the + /// projection catalog has completed paging; a nil total renders as + /// indeterminate progress. + case scanning(processed: UInt64, total: UInt64?) + /// Projection coverage is sealed/complete for the root's current generation. + case ready(processed: UInt64) + + init(event: WorkspaceCodemapRootProjectionProgressEvent) { + let processed = event.progress.counts.processedCandidateCount + if event.isSealed || event.progress.phase == .complete { + self = .ready(processed: processed) + } else { + self = .scanning( + processed: processed, + total: event.progress.catalogCompletion?.supportedCandidateCount + ) + } + } +} diff --git a/Sources/RepoPrompt/Features/WorkspaceFiles/ViewModels/WorkspaceFilesViewModel.swift b/Sources/RepoPrompt/Features/WorkspaceFiles/ViewModels/WorkspaceFilesViewModel.swift index cb362bd1b..ca3f403b5 100644 --- a/Sources/RepoPrompt/Features/WorkspaceFiles/ViewModels/WorkspaceFilesViewModel.swift +++ b/Sources/RepoPrompt/Features/WorkspaceFiles/ViewModels/WorkspaceFilesViewModel.swift @@ -737,6 +737,16 @@ class WorkspaceFilesViewModel: ObservableObject { rootShellProjectionsChangedSubject.eraseToAnyPublisher() } + /// Latest accepted codemap projection activity per visible logical root UUID. + /// Consumed by the Agent Mode roots sidebar; not `@Published` so progress + /// ticks never trigger whole-view-model invalidation. + private(set) var codemapRootActivityByRootID: [UUID: WorkspaceRootCodemapActivity] = [:] + private let codemapRootActivityChangedSubject = PassthroughSubject() + + var codemapRootActivityChangedPublisher: AnyPublisher { + codemapRootActivityChangedSubject.eraseToAnyPublisher() + } + @MainActor func beginRootShellProjectionChangeBatch() { rootShellProjectionChangeBatchDepth += 1 @@ -762,6 +772,7 @@ class WorkspaceFilesViewModel: ObservableObject { hasPendingRootShellProjectionChange = true return } + pruneCodemapRootActivityToVisibleRoots() publishRootFoldersChanged() rootShellProjectionsChangedSubject.send(()) } @@ -1222,6 +1233,7 @@ class WorkspaceFilesViewModel: ObservableObject { private var workspaceStoreDeltaBridgeTask: Task? private var codemapSelectionGraphReadinessTask: Task? private var codemapMarkerReadinessTask: Task? + private var codemapRootProjectionProgressTask: Task? private let alwaysReadableHomeDirectoryURL: URL private let automaticCodemapSelectionRequestPolicy: WorkspaceCodemapAutomaticSelectionRequestPolicy private let automaticCodemapSelectionWaiter: WorkspaceCodemapAutomaticSelectionWaiter @@ -1250,6 +1262,7 @@ class WorkspaceFilesViewModel: ObservableObject { subscribeToWorkspaceStoreDeltaEvents() subscribeToCodemapSelectionGraphReadinessUpdates() subscribeToCodemapMarkerReadinessUpdates() + subscribeToCodemapRootProjectionProgressUpdates() subscribeToPartitionStoreSaves() subscribeToFileSystemPreferenceChanges() } @@ -1268,6 +1281,7 @@ class WorkspaceFilesViewModel: ObservableObject { workspaceStoreDeltaBridgeTask?.cancel() codemapSelectionGraphReadinessTask?.cancel() codemapMarkerReadinessTask?.cancel() + codemapRootProjectionProgressTask?.cancel() autoCodemapSyncTask?.cancel() for task in sliceRebaseTasksByFullPath.values { task.cancel() @@ -1465,6 +1479,16 @@ class WorkspaceFilesViewModel: ObservableObject { } } + private func subscribeToCodemapRootProjectionProgressUpdates() { + codemapRootProjectionProgressTask = Task { [weak self] in + guard let self else { return } + let stream = await workspaceFileContextStore.codemapRootProjectionProgressUpdates() + for await event in stream { + handleCodemapRootProjectionProgress(event) + } + } + } + private struct WorkspaceAppliedIndexModificationTargets { let filesByID: [UUID: FileViewModel] let foldersByID: [UUID: FolderViewModel] @@ -2423,6 +2447,33 @@ class WorkspaceFilesViewModel: ObservableObject { codemapMarkerReadinessRevision &+= 1 } + @MainActor + private func handleCodemapRootProjectionProgress( + _ event: WorkspaceCodemapRootProjectionProgressEvent + ) { + // Progress is keyed strictly by visible logical root UUID. Hidden + // physical worktree session roots have distinct root IDs and are + // filtered out here; paths are never consulted. + let visibleRootIDs = Set(visibleRootFolders.map(\.id)) + guard visibleRootIDs.contains(event.rootEpoch.rootID) else { return } + var next = codemapRootActivityByRootID.filter { visibleRootIDs.contains($0.key) } + next[event.rootEpoch.rootID] = WorkspaceRootCodemapActivity(event: event) + publishCodemapRootActivity(next) + } + + @MainActor + private func pruneCodemapRootActivityToVisibleRoots() { + let visibleRootIDs = Set(visibleRootFolders.map(\.id)) + publishCodemapRootActivity(codemapRootActivityByRootID.filter { visibleRootIDs.contains($0.key) }) + } + + @MainActor + private func publishCodemapRootActivity(_ next: [UUID: WorkspaceRootCodemapActivity]) { + guard next != codemapRootActivityByRootID else { return } + codemapRootActivityByRootID = next + codemapRootActivityChangedSubject.send(()) + } + func cancelAllLoadingTasks() { invalidateAllRootLoadTokens() // Cancel the currently running folder loading task, if any. diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Models/WorkspaceCodemapStoreSelectionGraphModels.swift b/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Models/WorkspaceCodemapStoreSelectionGraphModels.swift index 63107a2f7..6fef48c32 100644 --- a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Models/WorkspaceCodemapStoreSelectionGraphModels.swift +++ b/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Models/WorkspaceCodemapStoreSelectionGraphModels.swift @@ -44,6 +44,18 @@ struct WorkspaceCodemapSelectionGraphReadinessEvent: Hashable { let rootEpoch: WorkspaceCodemapRootEpoch } +/// Production observation event describing accepted per-root codemap projection +/// progress. Yielded by `WorkspaceFileContextStore` whenever a projection +/// snapshot (segment or seal) is accepted for a root, so UI surfaces can render +/// lightweight per-root scanning progress without creating codemap demand. +struct WorkspaceCodemapRootProjectionProgressEvent: Hashable { + let rootEpoch: WorkspaceCodemapRootEpoch + /// Cumulative accepted progress for the root's current projection generation. + let progress: WorkspaceCodemapProjectionProgress + /// True when the accepted snapshot was a coverage seal (projection complete). + let isSealed: Bool +} + struct WorkspaceCodemapStoreSelectionGraphQueryBudgetPolicy: Hashable { static let initial = Self( maximumTargetCount: 100_000, diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/WorkspaceFileContextStore.swift b/Sources/RepoPrompt/Infrastructure/WorkspaceContext/WorkspaceFileContextStore.swift index f31211a21..31014d1e5 100644 --- a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/WorkspaceFileContextStore.swift +++ b/Sources/RepoPrompt/Infrastructure/WorkspaceContext/WorkspaceFileContextStore.swift @@ -2302,6 +2302,9 @@ actor WorkspaceFileContextStore { private var codemapMarkerReadinessContinuations: [ UUID: AsyncStream.Continuation ] = [:] + private var codemapRootProjectionProgressContinuations: [ + UUID: AsyncStream.Continuation + ] = [:] private var fileSystemDeltaContinuations: [UUID: AsyncStream.Continuation] = [:] private var appliedIndexContinuations: [UUID: AsyncStream.Continuation] = [:] private var appliedIndexGenerationsByRootID: [UUID: UInt64] = [:] @@ -2533,6 +2536,9 @@ actor WorkspaceFileContextStore { for continuation in codemapMarkerReadinessContinuations.values { continuation.finish() } + for continuation in codemapRootProjectionProgressContinuations.values { + continuation.finish() + } for continuation in fileSystemDeltaContinuations.values { continuation.finish() } @@ -8911,6 +8917,23 @@ actor WorkspaceFileContextStore { codemapMarkerReadinessContinuations.removeValue(forKey: id) } + /// Production observation stream of accepted per-root codemap projection + /// progress. Events are only emitted for accepted projection snapshots + /// (segments and coverage seals); observers never create codemap demand. + func codemapRootProjectionProgressUpdates() -> AsyncStream { + let id = UUID() + return AsyncStream { continuation in + codemapRootProjectionProgressContinuations[id] = continuation + continuation.onTermination = { _ in + Task { await self.removeCodemapRootProjectionProgressContinuation(id) } + } + } + } + + private func removeCodemapRootProjectionProgressContinuation(_ id: UUID) { + codemapRootProjectionProgressContinuations.removeValue(forKey: id) + } + @discardableResult func loadRoot( path: String, @@ -17225,6 +17248,14 @@ actor WorkspaceFileContextStore { { yieldCodemapSelectionGraphReadiness(rootEpoch: authority.rootEpoch) } + if case let .accepted(acceptedProgress) = disposition { + let isSealed = if case .seal = snapshot { true } else { false } + yieldCodemapRootProjectionProgress(WorkspaceCodemapRootProjectionProgressEvent( + rootEpoch: authority.rootEpoch, + progress: acceptedProgress, + isSealed: isSealed + )) + } return disposition } @@ -21376,6 +21407,12 @@ actor WorkspaceFileContextStore { } } + private func yieldCodemapRootProjectionProgress(_ event: WorkspaceCodemapRootProjectionProgressEvent) { + for continuation in codemapRootProjectionProgressContinuations.values { + continuation.yield(event) + } + } + private func isRootLifetimeCurrent(rootID: UUID, expectedLifetimeID: UUID?) -> Bool { guard let state = rootStatesByID[rootID] else { return false } guard let expectedLifetimeID else { return true } diff --git a/Tests/RepoPromptTests/AgentMode/AgentWorkspaceRootsSidebarStoreTests.swift b/Tests/RepoPromptTests/AgentMode/AgentWorkspaceRootsSidebarStoreTests.swift index 74986b181..aacc813ba 100644 --- a/Tests/RepoPromptTests/AgentMode/AgentWorkspaceRootsSidebarStoreTests.swift +++ b/Tests/RepoPromptTests/AgentMode/AgentWorkspaceRootsSidebarStoreTests.swift @@ -108,6 +108,229 @@ final class AgentWorkspaceRootsSidebarStoreTests: XCTestCase { XCTAssertEqual(store.rootRows.map(\.id), [rootB.id]) } + // MARK: - Codemap progress (per-root scanning indicator) + + func testWorkspaceRootCodemapActivityMapsAcceptedProgressEvents() { + let rootEpoch = WorkspaceCodemapRootEpoch(rootID: UUID(), rootLifetimeID: UUID()) + + let determinate = WorkspaceRootCodemapActivity(event: WorkspaceCodemapRootProjectionProgressEvent( + rootEpoch: rootEpoch, + progress: makeProjectionProgress( + rootEpoch: rootEpoch, + phase: .publishingProjectionSegment, + processed: 42, + total: 120 + ), + isSealed: false + )) + XCTAssertEqual(determinate, .scanning(processed: 42, total: 120)) + + let indeterminate = WorkspaceRootCodemapActivity(event: WorkspaceCodemapRootProjectionProgressEvent( + rootEpoch: rootEpoch, + progress: makeProjectionProgress( + rootEpoch: rootEpoch, + phase: .publishingProjectionSegment, + processed: 7, + total: nil + ), + isSealed: false + )) + XCTAssertEqual(indeterminate, .scanning(processed: 7, total: nil)) + + let sealed = WorkspaceRootCodemapActivity(event: WorkspaceCodemapRootProjectionProgressEvent( + rootEpoch: rootEpoch, + progress: makeProjectionProgress( + rootEpoch: rootEpoch, + phase: .publishingProjectionSegment, + processed: 120, + total: 120 + ), + isSealed: true + )) + XCTAssertEqual(sealed, .ready(processed: 120)) + + let completePhase = WorkspaceRootCodemapActivity(event: WorkspaceCodemapRootProjectionProgressEvent( + rootEpoch: rootEpoch, + progress: makeProjectionProgress( + rootEpoch: rootEpoch, + phase: .complete, + processed: 120, + total: 120 + ), + isSealed: false + )) + XCTAssertEqual(completePhase, .ready(processed: 120)) + } + + func testCodemapProgressStatesJoinActivityByRootUUIDOnlyAndDescribeStates() { + let rootA = makeProjection(name: "A", path: "/tmp/A") + let rootB = makeProjection(name: "B", path: "/tmp/B") + // Activity for a hidden physical worktree session root: distinct UUID, + // must never cross-wire onto a visible logical root row. + let hiddenPhysicalRootID = UUID() + + let states = AgentWorkspaceRootsSidebarStore.codemapProgressStates( + for: [rootA, rootB], + activityByRootID: [ + rootA.id: .scanning(processed: 42, total: 120), + hiddenPhysicalRootID: .ready(processed: 9) + ], + globallyDisabled: false + ) + + XCTAssertEqual(states, [rootA.id: .scanning(processed: 42, total: 120)]) + XCTAssertNil(states[rootB.id], "Idle roots render nothing") + XCTAssertNil(states[hiddenPhysicalRootID]) + + XCTAssertEqual( + AgentRootCodemapProgressDisplayState.scanning(processed: 42, total: 120).displayText, + "Codemaps 42/120" + ) + XCTAssertEqual( + AgentRootCodemapProgressDisplayState.scanning(processed: 7, total: nil).displayText, + "Codemaps scanning…" + ) + XCTAssertEqual(AgentRootCodemapProgressDisplayState.ready.displayText, "Codemaps ready") + XCTAssertEqual( + AgentRootCodemapProgressDisplayState.disabledGlobally.displayText, + "Codemaps disabled globally" + ) + XCTAssertEqual( + AgentRootCodemapProgressDisplayState.scanning(processed: 42, total: 120).accessibilityText, + "Codemaps scanning, 42 of 120 files processed" + ) + } + + func testCodemapProgressStatesGlobalDisableOverridesActivity() { + let rootA = makeProjection(name: "A", path: "/tmp/A") + let rootB = makeProjection(name: "B", path: "/tmp/B") + + let states = AgentWorkspaceRootsSidebarStore.codemapProgressStates( + for: [rootA, rootB], + activityByRootID: [ + rootA.id: .scanning(processed: 1, total: 10) + ], + globallyDisabled: true + ) + + XCTAssertEqual(states, [ + rootA.id: .disabledGlobally, + rootB.id: .disabledGlobally + ]) + } + + func testCodemapProgressInitialGlobalDisableSuppressesActivitySynchronously() { + let rootA = makeProjection(name: "A", path: "/tmp/A") + let manager = makeWorkspaceManager() + let store = AgentWorkspaceRootsSidebarStore( + rootProjections: { [rootA] }, + rootChanges: Empty().eraseToAnyPublisher(), + codemapActivityLookup: { [rootA.id: .scanning(processed: 1, total: 10)] }, + codemapsGloballyDisabled: CurrentValueSubject(true).eraseToAnyPublisher(), + initialCodemapsGloballyDisabled: true, + codemapActivityThrottleMilliseconds: 1, + workspaceManager: manager, + windowID: -1 + ) + + XCTAssertEqual(store.codemapProgressByRootID, [rootA.id: .disabledGlobally]) + } + + func testCodemapActivityUpdatesPreserveRowIdentityAndGlobalDisableRoundTrips() async { + let rootA = makeProjection(name: "A", path: "/tmp/A") + let rootB = makeProjection(name: "B", path: "/tmp/B") + var activity: [UUID: WorkspaceRootCodemapActivity] = [:] + let activityChanges = PassthroughSubject() + let globallyDisabled = CurrentValueSubject(false) + let manager = makeWorkspaceManager() + let store = AgentWorkspaceRootsSidebarStore( + rootProjections: { [rootA, rootB] }, + rootChanges: Empty().eraseToAnyPublisher(), + codemapActivityLookup: { activity }, + codemapActivityChanges: activityChanges.eraseToAnyPublisher(), + codemapsGloballyDisabled: globallyDisabled.eraseToAnyPublisher(), + codemapActivityThrottleMilliseconds: 1, + workspaceManager: manager, + windowID: -1 + ) + let rowsBefore = store.rootRows + XCTAssertEqual(rowsBefore.map(\.id), [rootA.id, rootB.id]) + XCTAssertEqual(store.codemapProgressByRootID, [:]) + + activity[rootA.id] = .scanning(processed: 3, total: nil) + activityChanges.send(()) + await waitUntil { store.codemapProgressByRootID == [rootA.id: .scanning(processed: 3, total: nil)] } + + activity[rootA.id] = .scanning(processed: 42, total: 120) + activity[rootB.id] = .ready(processed: 12) + activityChanges.send(()) + await waitUntil { + store.codemapProgressByRootID == [ + rootA.id: .scanning(processed: 42, total: 120), + rootB.id: .ready + ] + } + + // Progress ticks must not rebuild rows or disturb identity/ordering. + XCTAssertEqual(store.rootRows, rowsBefore) + + globallyDisabled.send(true) + await waitUntil { + store.codemapProgressByRootID == [ + rootA.id: .disabledGlobally, + rootB.id: .disabledGlobally + ] + } + + globallyDisabled.send(false) + await waitUntil { + store.codemapProgressByRootID == [ + rootA.id: .scanning(processed: 42, total: 120), + rootB.id: .ready + ] + } + XCTAssertEqual(store.rootRows, rowsBefore) + } + + private func makeProjectionProgress( + rootEpoch: WorkspaceCodemapRootEpoch, + phase: WorkspaceCodemapProjectionPreloadPhase, + processed: UInt64, + total: UInt64? + ) -> WorkspaceCodemapProjectionProgress { + let completion = total.map { total in + WorkspaceCodemapProjectionCatalogCompletion( + token: WorkspaceCodemapProjectionCatalogToken( + rootEpoch: rootEpoch, + topologyGeneration: 1, + appliedIndexGeneration: 1, + catalogGeneration: 1, + ingressGeneration: 1, + projectionInvalidationGeneration: 0 + ), + finalCursor: nil, + supportedCandidateCount: total + ) + } + return WorkspaceCodemapProjectionProgress( + phase: phase, + counts: WorkspaceCodemapProjectionCounts( + supportedCandidateCount: total ?? processed, + processedCandidateCount: processed, + contributedCount: processed, + emptyCount: 0, + terminalArtifactCount: 0, + terminalExcludedCount: 0, + transientCount: 0 + ), + catalogPageCount: 1, + catalogPathByteCount: 1, + publishedSegmentCount: 1, + publishedSegmentByteCount: 1, + catalogCompletion: completion + ) + } + // MARK: - Root context actions func testRootContextActionCopiesRawBranchInsteadOfDisplayText() { diff --git a/Tests/RepoPromptTests/WorkspaceContext/WorkspaceFileContextStoreCodemapSeamTests.swift b/Tests/RepoPromptTests/WorkspaceContext/WorkspaceFileContextStoreCodemapSeamTests.swift index 29d12bf35..9325155cf 100644 --- a/Tests/RepoPromptTests/WorkspaceContext/WorkspaceFileContextStoreCodemapSeamTests.swift +++ b/Tests/RepoPromptTests/WorkspaceContext/WorkspaceFileContextStoreCodemapSeamTests.swift @@ -5405,6 +5405,75 @@ final class WorkspaceFileContextStoreCodemapSeamTests: WorkspaceFileContextStore await store.unloadRoot(id: loaded.id) } + func testAcceptedProjectionSnapshotsYieldRootProgressStreamEvents() async throws { + let repositoryFixture = try ReviewGitRepositoryFixture(name: #function) + let root = try repositoryFixture.makeRepository( + named: "repository", + files: ["Sources/Survivor.swift": "struct Survivor {}\n"] + ) + let fixture = try CodemapStoreFixture( + name: #function, + projectionAuthority: .manual + ) + let buildGate = CodemapSelectionGraphBuildGate() + let graphProbe = CodemapSelectionGraphProbe(buildGate: buildGate) + addTeardownBlock { + buildGate.releaseAll() + await fixture.shutdown() + repositoryFixture.cleanup() + } + let store = fixture.makeStore(selectionGraphFactory: graphProbe.factory) + let loaded = try await store.loadRoot(path: root.path) + let files = await store.files(inRoot: loaded.id) + let survivor = try XCTUnwrap(files.first { $0.standardizedRelativePath == "Sources/Survivor.swift" }) + + let progressStream = await store.codemapRootProjectionProgressUpdates() + let collector = Task { () -> [WorkspaceCodemapRootProjectionProgressEvent] in + var events: [WorkspaceCodemapRootProjectionProgressEvent] = [] + for await event in progressStream { + events.append(event) + if event.isSealed { break } + } + return events + } + + let ticket = try await pendingTicket(store.requestCodemapArtifact(forFileID: survivor.id)) + let ready = try await readyResult(settledResult(store: store, ticket: ticket)) + let initialGeneration = try XCTUnwrap(buildGate.waitUntilFirstBlocked()) + buildGate.release(generation: initialGeneration) + let initialGraphPublished = await graphProbe.waitUntilPublished( + rootEpoch: ticket.rootEpoch, + minimumNodeCount: 1 + ) + XCTAssertTrue(initialGraphPublished) + _ = try await publishCompleteAutomaticSelectionProjection( + fixture: fixture, + graphProbe: graphProbe, + ticket: ticket, + contributionsByFileID: [ + survivor.id: CodeMapSelectionGraphContribution( + artifactKey: ready.snapshot.artifactKey, + definitions: ["Survivor"], + references: [] + ) + ] + ) + + let events = await collector.value + XCTAssertGreaterThanOrEqual(events.count, 2) + XCTAssertTrue(events.allSatisfy { $0.rootEpoch.rootID == loaded.id }) + let segmentEvent = try XCTUnwrap(events.first) + XCTAssertFalse(segmentEvent.isSealed) + XCTAssertEqual(segmentEvent.progress.counts.processedCandidateCount, 1) + XCTAssertEqual(segmentEvent.progress.catalogCompletion?.supportedCandidateCount, 1) + let sealedEvent = try XCTUnwrap(events.last) + XCTAssertTrue(sealedEvent.isSealed) + XCTAssertEqual(sealedEvent.progress.phase, .complete) + XCTAssertEqual(sealedEvent.progress.counts.processedCandidateCount, 1) + XCTAssertEqual(sealedEvent.progress.catalogCompletion?.supportedCandidateCount, 1) + await store.unloadRoot(id: loaded.id) + } + func testPathRepairPublishesReadyContributionCompletedDuringRebuild() async throws { let repositoryFixture = try ReviewGitRepositoryFixture(name: #function) let root = try repositoryFixture.makeRepository(