diff --git a/docs/components/active-agents.md b/docs/components/active-agents.md index f61b84e7..db9a34be 100644 --- a/docs/components/active-agents.md +++ b/docs/components/active-agents.md @@ -48,6 +48,15 @@ Rows appear in the order agents are first detected. (See - **Click a row** → focuses that worktree + tab + pane and brings Prowl forward. A **Done** row downgrades to **Idle** once focused. +- **Right-click a row** for the context menu: + - **Hand Off…** — opens the staged Hand Off HUD for that agent's pane + (selecting and focusing it first), regardless of which pane currently has + focus. Same flow as the toolbar Agents capsule; see + [handoff](handoff.md). + - **Mark as Read** — clears the pane's unread notifications without + switching to it. + - **Copy Path** / **Reveal in Finder** — the agent's working directory (or + its owning worktree's directory when the agent hasn't reported one). - **Keyboard navigation:** `⌥⌃↓` next agent, `⌥⌃↑` previous agent (wraps). - **Resize** the panel by dragging its top edge (height is remembered). - **Auto-show:** if `autoShowActiveAgentsPanel` is on and the panel is hidden, a diff --git a/docs/components/handoff.md b/docs/components/handoff.md index 97f9d132..6bc22526 100644 --- a/docs/components/handoff.md +++ b/docs/components/handoff.md @@ -163,7 +163,10 @@ The HUD runs in two steps: The Command Palette (`⌘P`) offers the same flow as a single **Hand Off…** row for any selected workspace, git repository, worktree, or plain folder; -it opens the same HUD. +it opens the same HUD. So does right-clicking a row in the +[Active Agents panel](active-agents.md) and choosing **Hand Off…** — that +entry point targets the row's own pane (selecting and focusing it first), +so you can hand off an agent that isn't currently focused. ## Safety diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 4eafe776..75aa62dc 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -338,16 +338,17 @@ struct SupacodeApp: App { let tabID = state.tabManager.selectedTabId, let surfaceID = state.activeSurfaceID(for: tabID) else { return nil } - let agentState = state.surfaceAgentStates[surfaceID] - return HandoffSourceContext( - sessionContext: makeHandoffSessionContext( - worktreeID: worktreeID, - paneID: surfaceID, - paneTitle: nil, - terminalManager: terminalManager - ), - observation: agentState?.launchObservation, - session: agentState?.session + return makeHandoffSourceContext( + worktreeID: worktreeID, + surfaceID: surfaceID, + terminalManager: terminalManager + ) + }, + handoffSourceContextForSurface: { worktreeID, surfaceID in + makeHandoffSourceContext( + worktreeID: worktreeID, + surfaceID: surfaceID, + terminalManager: terminalManager ) }, handoffSessionContextForSurface: { worktreeID, surfaceID in @@ -430,6 +431,25 @@ struct SupacodeApp: App { return client } + private static func makeHandoffSourceContext( + worktreeID: Worktree.ID, + surfaceID: UUID, + terminalManager: WorktreeTerminalManager + ) -> HandoffSourceContext? { + guard let state = terminalManager.stateIfExists(for: worktreeID) else { return nil } + let agentState = state.surfaceAgentStates[surfaceID] + return HandoffSourceContext( + sessionContext: makeHandoffSessionContext( + worktreeID: worktreeID, + paneID: surfaceID, + paneTitle: nil, + terminalManager: terminalManager + ), + observation: agentState?.launchObservation, + session: agentState?.session + ) + } + private static func makeHandoffSessionContext( worktreeID: Worktree.ID, paneID: UUID, diff --git a/supacode/Clients/Terminal/TerminalClient.swift b/supacode/Clients/Terminal/TerminalClient.swift index d6bf235a..5316a8ce 100644 --- a/supacode/Clients/Terminal/TerminalClient.swift +++ b/supacode/Clients/Terminal/TerminalClient.swift @@ -13,6 +13,9 @@ struct TerminalClient { /// one synchronous read: session context for the artifact, launch /// observation, and the pid-anchored native session. var handoffSourceContext: @MainActor @Sendable (Worktree.ID) -> HandoffSourceContext? + /// Same capture as `handoffSourceContext`, but for an explicit pane instead of + /// the selected one — the Active Agents context menu targets a specific row. + var handoffSourceContextForSurface: @MainActor @Sendable (Worktree.ID, UUID) -> HandoffSourceContext? var handoffSessionContextForSurface: @MainActor @Sendable (Worktree.ID, UUID) -> HandoffStore.SessionContext? var latestUnreadNotification: @MainActor @Sendable () -> NotificationLocation? var focusSurface: @MainActor @Sendable (Worktree.ID, UUID) -> Bool @@ -90,6 +93,7 @@ extension TerminalClient: DependencyKey { canvasFocusedWorktreeID: { nil }, selectedSurfaceID: { _ in nil }, handoffSourceContext: { _ in nil }, + handoffSourceContextForSurface: { _, _ in nil }, handoffSessionContextForSurface: { _, _ in nil }, latestUnreadNotification: { nil }, focusSurface: { _, _ in false }, @@ -103,6 +107,7 @@ extension TerminalClient: DependencyKey { canvasFocusedWorktreeID: { nil }, selectedSurfaceID: { _ in nil }, handoffSourceContext: { _ in nil }, + handoffSourceContextForSurface: { _, _ in nil }, handoffSessionContextForSurface: { _, _ in nil }, latestUnreadNotification: { nil }, focusSurface: { _, _ in false }, diff --git a/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift b/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift index 3e5c802f..7cb0b085 100644 --- a/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift +++ b/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift @@ -29,6 +29,11 @@ struct ActiveAgentsFeature { case agentEntryChanged(ActiveAgentEntry, autoShowPanel: Bool) case agentEntryRemoved(ActiveAgentEntry.ID) case entryTapped(ActiveAgentEntry.ID) + /// Context-menu "Hand Off…": parents perform the selection (Repositories) + /// and open the HUD for this entry's pane (App). + case handOffTapped(ActiveAgentEntry.ID) + /// Context-menu "Mark as Read": handled by RepositoriesFeature. + case markAsReadTapped(ActiveAgentEntry.ID) case focusedSurfaceChanged(UUID?) case selectNextEntry case selectPreviousEntry @@ -50,7 +55,7 @@ struct ActiveAgentsFeature { state.entries.remove(id: id) return .none - case .entryTapped(let id): + case .entryTapped(let id), .handOffTapped(let id): // Mirror the tapped surface into the focus anchor so the panel highlight and // keyboard navigation step from the just-selected agent immediately. The async // `focusChanged` event can't be relied on here: it is deduplicated per worktree @@ -59,6 +64,9 @@ struct ActiveAgentsFeature { state.focusedSurfaceID = state.entries[id: id]?.surfaceID return .none + case .markAsReadTapped: + return .none + case .focusedSurfaceChanged(let surfaceID): state.focusedSurfaceID = surfaceID return .none diff --git a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift index 2f007295..56df370f 100644 --- a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift +++ b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift @@ -62,6 +62,9 @@ struct ActiveAgentsPanel: View { } .buttonStyle(.plain) .help(helpText(for: entry)) + .contextMenu { + contextMenu(for: entry) + } } } } @@ -121,6 +124,30 @@ struct ActiveAgentsPanel: View { min(maximumHeight, max(ActiveAgentsFeature.minimumPanelHeight, height)) } + @ViewBuilder + private func contextMenu(for entry: ActiveAgentEntry) -> some View { + Button("Hand Off…") { + store.send(.handOffTapped(entry.id)) + } + .help("Save this agent's progress and hand the task off to another agent") + Button("Mark as Read") { + store.send(.markAsReadTapped(entry.id)) + } + .help("Clear this agent's unread notifications without switching to it") + if let directory = rowDisplays[entry.id]?.directory { + Divider() + Button("Copy Path") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(directory.path, forType: .string) + } + .help("Copy the agent's working directory path") + Button("Reveal in Finder") { + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: directory.path) + } + .help("Reveal the agent's working directory in Finder") + } + } + private func repositoryName(for entry: ActiveAgentEntry) -> String { rowDisplays[entry.id]?.repositoryName ?? entry.worktreeName } diff --git a/supacode/Features/App/Reducer/AppFeature+Handoff.swift b/supacode/Features/App/Reducer/AppFeature+Handoff.swift index f4f04b0e..fc56b8e1 100644 --- a/supacode/Features/App/Reducer/AppFeature+Handoff.swift +++ b/supacode/Features/App/Reducer/AppFeature+Handoff.swift @@ -15,4 +15,21 @@ extension AppFeature { state.handoffHud = hudState return .none } + + /// Open the hand-off HUD for a specific Active Agents entry (context menu). + /// The source is captured from the entry's own pane, so it works regardless + /// of which pane currently holds focus; RepositoriesFeature selects and + /// focuses the entry's worktree from the same action. + func openHandoffHud(state: inout State, entryID: ActiveAgentEntry.ID) -> Effect { + guard state.handoffHud == nil else { return .none } + guard let entry = state.repositories.activeAgents.entries[id: entryID], + let worktree = state.repositories.terminalWorktree(for: entry.worktreeID) + else { return .none } + let source = terminalClient.handoffSourceContextForSurface(entry.worktreeID, entry.surfaceID) + guard let hudState = HandoffHudFeature.State.make(worktree: worktree, source: source) else { + return .send(.repositories(.showToast(.warning("No agent detected in this pane")))) + } + state.handoffHud = hudState + return .none + } } diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index a235930f..82c5b35b 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -961,6 +961,9 @@ struct AppFeature { case .alert: return .none + case .repositories(.activeAgents(.handOffTapped(let entryID))): + return openHandoffHud(state: &state, entryID: entryID) + case .repositories: return .none diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift index 21981627..f999b4e5 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift @@ -15,7 +15,7 @@ extension RepositoriesFeature { .workspaceCreation: return .none - case .activeAgents(.entryTapped(let id)): + case .activeAgents(.entryTapped(let id)), .activeAgents(.handOffTapped(let id)): guard let entry = state.activeAgents.entries[id: id] else { return .none } if state.isShowingCanvas { requestCanvasFocus(.tab(entry.tabID), openedWorktreeID: entry.worktreeID, state: &state) @@ -42,6 +42,12 @@ extension RepositoriesFeature { } } + case .activeAgents(.markAsReadTapped(let id)): + guard let entry = state.activeAgents.entries[id: id] else { return .none } + return .run { _ in + await terminalClient.markNotificationsReadForSurface(entry.worktreeID, entry.surfaceID) + } + case .activeAgents: return .none diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift index 59a30a46..26a11188 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift @@ -37,12 +37,32 @@ extension RepositoriesFeature.State { else { return nil } - return Worktree( - id: selectedRepository.id, - name: selectedRepository.name, - detail: selectedRepository.rootURL.path(percentEncoded: false), - workingDirectory: selectedRepository.rootURL, - repositoryRootURL: selectedRepository.rootURL + return Self.plainFolderWorktree(for: selectedRepository) + } + + /// Resolves a terminal target ID to its runnable worktree: a real worktree, + /// or the synthesized worktree representing a runnable plain-folder + /// repository (whose repository ID doubles as its terminal target ID). + func terminalWorktree(for id: Worktree.ID) -> Worktree? { + if let worktree = worktree(for: id) { + return worktree + } + guard let repository = repositories[id: id], + repository.capabilities.supportsRunnableFolderActions, + !repository.capabilities.supportsWorktrees + else { + return nil + } + return Self.plainFolderWorktree(for: repository) + } + + static func plainFolderWorktree(for repository: Repository) -> Worktree { + Worktree( + id: repository.id, + name: repository.name, + detail: repository.rootURL.path(percentEncoded: false), + workingDirectory: repository.rootURL, + repositoryRootURL: repository.rootURL ) } @@ -240,13 +260,7 @@ extension RepositoriesFeature.State { .first } guard repository.capabilities.supportsRunnableFolderActions else { return nil } - return Worktree( - id: repository.id, - name: repository.name, - detail: repository.rootURL.path(percentEncoded: false), - workingDirectory: repository.rootURL, - repositoryRootURL: repository.rootURL - ) + return Self.plainFolderWorktree(for: repository) } func pendingWorktree(for id: Worktree.ID?) -> PendingWorktree? { diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index 5baf812d..e412d652 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -600,16 +600,23 @@ struct SidebarListView: View { return ActiveAgentRowDisplay( repositoryName: metadata.repositoryNamesByWorktreeID[key] ?? fallbackName, branchName: metadata.branchNamesByWorktreeID[key] ?? fallbackName, - color: metadata.repositoryColorsByWorktreeID[key] + color: metadata.repositoryColorsByWorktreeID[key], + directory: workingDirectory ) } let name = Repository.name(for: workingDirectory) - return ActiveAgentRowDisplay(repositoryName: name, branchName: name, color: nil) + return ActiveAgentRowDisplay( + repositoryName: name, + branchName: name, + color: nil, + directory: workingDirectory + ) } return ActiveAgentRowDisplay( repositoryName: metadata.repositoryNamesByWorktreeID[entry.worktreeID] ?? entry.worktreeName, branchName: metadata.branchNamesByWorktreeID[entry.worktreeID] ?? entry.worktreeName, - color: metadata.repositoryColorsByWorktreeID[entry.worktreeID] + color: metadata.repositoryColorsByWorktreeID[entry.worktreeID], + directory: directory(forWorktreeID: entry.worktreeID, in: repositories) ) } @@ -640,6 +647,23 @@ struct SidebarListView: View { } return best?.id } + + /// Directory of the surface's owning worktree, used when the agent hasn't + /// reported a working directory. Plain folders are keyed by repository id. + static func directory( + forWorktreeID worktreeID: Worktree.ID, + in repositories: IdentifiedArrayOf + ) -> URL? { + for repository in repositories { + if let worktree = repository.worktrees[id: worktreeID] { + return worktree.workingDirectory + } + } + guard let repository = repositories[id: worktreeID], + repository.capabilities.supportsRunnableFolderActions + else { return nil } + return repository.rootURL + } } struct ActiveAgentWorktreeMetadata: Equatable { @@ -652,6 +676,9 @@ struct ActiveAgentRowDisplay: Equatable { let repositoryName: String let branchName: String let color: RepositoryColorChoice? + /// The directory the agent runs in (or its owning worktree's directory as a + /// fallback); drives the context menu's Copy Path / Reveal in Finder. + let directory: URL? } extension SidebarItem { diff --git a/supacodeTests/ActiveAgentsFeatureTests.swift b/supacodeTests/ActiveAgentsFeatureTests.swift index d6b4620c..36cb2280 100644 --- a/supacodeTests/ActiveAgentsFeatureTests.swift +++ b/supacodeTests/ActiveAgentsFeatureTests.swift @@ -175,6 +175,32 @@ struct ActiveAgentsFeatureTests { } } + @Test func handOffTappedUpdatesFocusAnchorLikeEntryTapped() async { + var state = ActiveAgentsFeature.State() + state.entries = sampleEntries() + let store = TestStore(initialState: state) { + ActiveAgentsFeature() + } + + // The context-menu hand off selects the entry's pane (parents focus it), + // so the keyboard-nav anchor must move with it exactly like a tap. + await store.send(.handOffTapped(UUID(2))) { + $0.focusedSurfaceID = UUID(2) + } + } + + @Test func markAsReadTappedIsLocalNoOp() async { + var state = ActiveAgentsFeature.State() + state.entries = sampleEntries() + let store = TestStore(initialState: state) { + ActiveAgentsFeature() + } + + // Handled by RepositoriesFeature; the panel reducer itself must not move + // the anchor or mutate entries. + await store.send(.markAsReadTapped(UUID(1))) + } + @Test func focusedSurfaceChangedUpdatesAnchor() async { let store = TestStore(initialState: ActiveAgentsFeature.State()) { ActiveAgentsFeature() diff --git a/supacodeTests/AppFeatureHandoffTests.swift b/supacodeTests/AppFeatureHandoffTests.swift index 71992db0..d47bfb61 100644 --- a/supacodeTests/AppFeatureHandoffTests.swift +++ b/supacodeTests/AppFeatureHandoffTests.swift @@ -174,6 +174,98 @@ struct AppFeatureHandoffTests { #expect(store.state.handoffHud == nil) } + @Test(.dependencies) func agentRowHandOffPresentsHudForEntryPane() async throws { + let repositoryRoot = try makeTempRoot() + defer { remove(repositoryRoot) } + let worktreeRoot = try makeTempRoot() + defer { remove(worktreeRoot) } + var repositories = makeGitWorktreeState(repositoryRoot: repositoryRoot, worktreeRoot: worktreeRoot) + // Deselect: the context menu must not depend on the entry being selected. + repositories.selection = nil + let worktreeID = worktreeRoot.standardizedFileURL.path(percentEncoded: false) + let surfaceID = uuid(7) + let entry = activeAgentEntry( + id: uuid(1), + worktreeID: worktreeID, + surfaceID: surfaceID, + displayState: .working + ) + repositories.activeAgents.entries = [entry] + let state = AppFeature.State( + repositories: repositories, + settings: SettingsFeature.State() + ) + + let capturedSurface = LockIsolated<(Worktree.ID, UUID)?>(nil) + let store = TestStore(initialState: state) { + AppFeature() + } withDependencies: { + $0.terminalClient.handoffSourceContextForSurface = { worktreeID, surfaceID in + capturedSurface.setValue((worktreeID, surfaceID)) + return HandoffSourceContext( + sessionContext: HandoffStore.SessionContext( + agent: "codex", + paneID: surfaceID.uuidString, + paneTitle: "codex", + source: "terminal-scrollback", + confidence: "fallback", + excerptText: nil + ), + observation: nil, + session: nil + ) + } + } + store.exhaustivity = .off + + await store.send(.repositories(.activeAgents(.handOffTapped(entry.id)))) { + let hud = try #require($0.handoffHud) + #expect(hud.source.agentToken == "codex") + #expect(hud.worktree.id == worktreeID) + #expect(hud.phase == .choosing) + } + // The source is captured from the entry's own pane, not the focused one. + #expect(capturedSurface.value?.0 == worktreeID) + #expect(capturedSurface.value?.1 == surfaceID) + } + + @Test(.dependencies) func agentRowHandOffWarnsWithoutDetectedAgent() async throws { + let repositoryRoot = try makeTempRoot() + defer { remove(repositoryRoot) } + let worktreeRoot = try makeTempRoot() + defer { remove(worktreeRoot) } + var repositories = makeGitWorktreeState(repositoryRoot: repositoryRoot, worktreeRoot: worktreeRoot) + let worktreeID = worktreeRoot.standardizedFileURL.path(percentEncoded: false) + let entry = activeAgentEntry( + id: uuid(1), + worktreeID: worktreeID, + surfaceID: uuid(7), + displayState: .working + ) + repositories.activeAgents.entries = [entry] + let state = AppFeature.State( + repositories: repositories, + settings: SettingsFeature.State() + ) + + let store = TestStore(initialState: state) { + AppFeature() + } withDependencies: { + $0.terminalClient.handoffSourceContextForSurface = { _, _ in nil } + } + store.exhaustivity = .off + + await store.send(.repositories(.activeAgents(.handOffTapped(entry.id)))) + await store.receive(\.repositories.showToast) { + guard case .warning(let message) = $0.repositories.statusToast else { + Issue.record("Expected warning toast") + return + } + #expect(message.contains("No agent detected")) + } + #expect(store.state.handoffHud == nil) + } + // MARK: - Palette delegate opens the HUD; the HUD runs the hand-off @Test(.dependencies) func paletteHandOffRunsThroughHud() async throws { diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index e7b5fd52..95e8c346 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -2660,6 +2660,93 @@ struct RepositoriesFeatureTests { #expect(focusedSurface.value?.1 == surfaceID) } + @Test func activeAgentHandOffTappedFocusesSurfaceAndSelectsWorktree() async { + let worktree = makeWorktree(id: "/tmp/repo/wt", name: "wt") + let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) + var state = makeState(repositories: [repository]) + let surfaceID = UUID() + let entry = ActiveAgentEntry( + id: surfaceID, + worktreeID: worktree.id, + worktreeName: worktree.name, + workingDirectory: nil, + tabID: TerminalTabID(rawValue: UUID()), + paneTitle: "agent", + surfaceID: surfaceID, + paneIndex: 0, + iconLookupToken: DetectedAgent.codex.iconLookupToken, + agent: .codex, + rawState: .working, + displayState: .working, + lastChangedAt: Date(timeIntervalSince1970: 0) + ) + state.activeAgents.entries = [entry] + + let focusedSurface = LockIsolated<(Worktree.ID, UUID)?>(nil) + let store = TestStore(initialState: state) { + RepositoriesFeature() + } withDependencies: { + $0.terminalClient.focusSurface = { worktreeID, surface in + focusedSurface.setValue((worktreeID, surface)) + return true + } + } + + // The context-menu hand off shares the tap path: anchor first, then the + // worktree selection so the HUD opens over the entry's own terminal. + await store.send(.activeAgents(.handOffTapped(entry.id))) { + $0.activeAgents.focusedSurfaceID = surfaceID + } + await store.receive(\.selectWorktree) { + $0.selection = .worktree(worktree.id) + $0.sidebarSelectedWorktreeIDs = [worktree.id] + $0.openedWorktreeIDs = [worktree.id] + $0.pendingTerminalFocusWorktreeIDs = [worktree.id] + } + await store.receive(\.delegate.selectedWorktreeChanged) + + #expect(focusedSurface.value?.0 == worktree.id) + #expect(focusedSurface.value?.1 == surfaceID) + } + + @Test func activeAgentMarkAsReadTappedMarksSurfaceNotificationsRead() async { + let worktree = makeWorktree(id: "/tmp/repo/wt", name: "wt") + let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) + var state = makeState(repositories: [repository]) + let surfaceID = UUID() + let entry = ActiveAgentEntry( + id: surfaceID, + worktreeID: worktree.id, + worktreeName: worktree.name, + workingDirectory: nil, + tabID: TerminalTabID(rawValue: UUID()), + paneTitle: "agent", + surfaceID: surfaceID, + paneIndex: 0, + iconLookupToken: DetectedAgent.codex.iconLookupToken, + agent: .codex, + rawState: .working, + displayState: .working, + lastChangedAt: Date(timeIntervalSince1970: 0) + ) + state.activeAgents.entries = [entry] + + let markedRead = LockIsolated<(Worktree.ID, UUID)?>(nil) + let store = TestStore(initialState: state) { + RepositoriesFeature() + } withDependencies: { + $0.terminalClient.markNotificationsReadForSurface = { worktreeID, surface in + markedRead.setValue((worktreeID, surface)) + } + } + + // Mark as Read clears the entry's pane without selecting or focusing it. + await store.send(.activeAgents(.markAsReadTapped(entry.id))) + + #expect(markedRead.value?.0 == worktree.id) + #expect(markedRead.value?.1 == surfaceID) + } + @Test func selectWorktreeCollapsesSidebarSelectedWorktreeIDs() async { let wt1 = makeWorktree(id: "/tmp/repo/wt1", name: "wt1", repoRoot: "/tmp/repo") let wt2 = makeWorktree(id: "/tmp/repo/wt2", name: "wt2", repoRoot: "/tmp/repo")