diff --git a/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts b/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts index b3b8aadea5c..6d1040f09ae 100644 --- a/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts +++ b/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts @@ -7,7 +7,7 @@ import { prepareLoadedProfileSettings } from './prepare-loaded-profile-settings' import type { GlobalSettings } from '../../../shared/global-settings-types' import type { PersistedState } from '../../../shared/persisted-state-types' -// Simulates a pre-graduation profile: showAgentsSidebar was never persisted. +// Simulates a profile created before the dedicated Experimental switch was persisted. function normalizeLegacyProfile(overrides: Partial): PersistedState['settings'] { const defaults = getDefaultPersistedState(homedir()) const settings: Partial = { ...defaults.settings } @@ -22,8 +22,8 @@ function normalizeLegacyProfile(overrides: Partial): PersistedSt return normalizeLoadedGlobalSettings(parsed, terminal, profile) } -describe('showAgentsSidebar graduation migration', () => { - it('keeps the sidebar for Agents-view opt-ins regardless of the popout experiment', () => { +describe('showAgentsSidebar experimental-setting migration', () => { + it('keeps the sidebar for Agents-view opt-ins regardless of the dashboard experiment', () => { const normalized = normalizeLegacyProfile({ experimentalActivity: true, experimentalAgentDashboardPopout: false @@ -32,18 +32,15 @@ describe('showAgentsSidebar graduation migration', () => { expect(normalized.agentsSidebarMigratedFromExperimental).toBe(true) }) - it('graduates either legacy opt-in to the sidebar', () => { + it('carries the legacy Agents-view opt-in into the sidebar', () => { expect(normalizeLegacyProfile({ experimentalActivity: true }).showAgentsSidebar).toBe(true) - expect( - normalizeLegacyProfile({ experimentalAgentDashboardPopout: true }).showAgentsSidebar - ).toBe(true) }) - it('uses migration copy for legacy Agent Dashboard opt-ins', () => { + it('does not show Agents migration copy for a dashboard-only opt-in', () => { expect( normalizeLegacyProfile({ experimentalAgentDashboardPopout: true }) .agentsSidebarMigratedFromExperimental - ).toBe(true) + ).toBe(false) }) it('defaults profiles with no legacy signal to the sidebar', () => { @@ -52,16 +49,10 @@ describe('showAgentsSidebar graduation migration', () => { expect(normalized.agentsSidebarMigratedFromExperimental).toBe(false) }) - it('keeps an explicit popout opt-out hidden when the Agents view was never used', () => { + it('does not treat a dashboard opt-out as an Agents-tab opt-out', () => { expect( normalizeLegacyProfile({ experimentalAgentDashboardPopout: false }).showAgentsSidebar - ).toBe(false) - expect( - normalizeLegacyProfile({ - experimentalActivity: false, - experimentalAgentDashboardPopout: false - }).showAgentsSidebar - ).toBe(false) + ).toBe(true) }) it('ignores a pre-stamp forced-default experimentalActivity true (not an opt-in)', () => { diff --git a/src/main/persistence/loading-store/normalize-loaded-global-settings.ts b/src/main/persistence/loading-store/normalize-loaded-global-settings.ts index dd729ba27e8..9370ec4febc 100644 --- a/src/main/persistence/loading-store/normalize-loaded-global-settings.ts +++ b/src/main/persistence/loading-store/normalize-loaded-global-settings.ts @@ -86,22 +86,16 @@ export function normalizeLoadedGlobalSettings( ...migratedTerminalTuiScrollSensitivity.settings, experimentalActivity: migratedExperimentalActivity, experimentalActivityDefaultedOffForAllUsers: true, - // The dashboard graduated from Experimental; default every profile to the new - // left-sidebar tab. An Agents-view opt-in always graduates; only an explicit - // popout opt-out (without that opt-in) stays hidden. - // Why migrated, not raw: a pre-stamp profile's stored true is the old forced default, - // not an opt-in (see prepareLoadedProfileSettings). + // Keep the experimental Agents tab's rollout default for older profiles while + // preserving any choice made through its dedicated Experimental setting. showAgentsSidebar: resolveAgentsSidebarVisible({ - showAgentsSidebar: parsed.settings?.showAgentsSidebar, - experimentalActivity: migratedExperimentalActivity, - experimentalAgentDashboardPopout: parsed.settings?.experimentalAgentDashboardPopout + showAgentsSidebar: parsed.settings?.showAgentsSidebar }), // Preserve the legacy opt-in before the experimental setting is normalized away. This // drives the migration-specific introduction copy without changing runtime behavior. agentsSidebarMigratedFromExperimental: parsed.settings?.agentsSidebarMigratedFromExperimental === true || - migratedExperimentalActivity || - parsed.settings?.experimentalAgentDashboardPopout === true, + migratedExperimentalActivity, // Why: compact worktree cards graduated from Experimental; preserve the old opt-in for rollout-era profiles. compactWorktreeCards: loadedCompactWorktreeCards, experimentalCompactWorktreeCards: undefined, diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index ff07846813a..447fbfb3780 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -35,7 +35,6 @@ import { import { mapWithConcurrency } from '../../../shared/map-with-concurrency' import type { OnboardingState } from '../../../shared/onboarding-state-types' import { restoreLocalStructuredSessionTabsOnce } from '../runtime/local-structured-session-tabs-sync' -import { seedDevActivityFixture } from '../components/activity/dev-activity-fixture' async function listRuntimeSessionHostIdsForStartup(): Promise { try { @@ -276,6 +275,8 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta import.meta.env.DEV && String(import.meta.env.VITE_ACTIVITY_DEV_FIXTURE).toLowerCase() === 'true' ) { + const { seedDevActivityFixture } = + await import('../components/activity/dev-activity-fixture') seedDevActivityFixture() } logRendererStartupDiagnostic('startup-hydration-done', { diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index c87a0ef591f..d4899c55775 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -81,7 +81,7 @@ export default function ActivityPrototypePage(): React.JSX.Element { : null const selectedTabId = selectedThread?.tab.id ?? null const selectedWorktreeAvailable = selectedThread - ? hasActivityThreadWorkspace(selectedThread) + ? hasActivityThreadWorkspace(selectedThread, storeData) : false // Why: repo-less terminal buckets can produce Activity rows, but the workspace Terminal tree only portals real worktrees. const selectedHasLiveTab = @@ -95,7 +95,7 @@ export default function ActivityPrototypePage(): React.JSX.Element { : null const displayedTabId = displayedThread?.tab.id ?? null const displayedWorktreeAvailable = displayedThread - ? hasActivityThreadWorkspace(displayedThread) + ? hasActivityThreadWorkspace(displayedThread, storeData) : false const displayedHasLiveTab = displayedThread && displayedTabId && displayedWorktreeAvailable @@ -260,7 +260,15 @@ export default function ActivityPrototypePage(): React.JSX.Element { setSelectedPaneKey }) - const canJumpToWorkspace = hasActivityThreadWorkspace + const canJumpToWorkspace = useCallback( + (thread: Parameters[0]) => + hasActivityThreadWorkspace(thread, { + worktreesByRepo: storeData.worktreesByRepo, + detectedWorktreesByRepo: storeData.detectedWorktreesByRepo, + folderWorkspaces: storeData.folderWorkspaces + }), + [storeData.worktreesByRepo, storeData.detectedWorktreesByRepo, storeData.folderWorkspaces] + ) useEffect(() => { if ( diff --git a/src/renderer/src/components/activity/ActivityTitlebarControls.tsx b/src/renderer/src/components/activity/ActivityTitlebarControls.tsx index 93ca4c65c1c..c6194ffcc96 100644 --- a/src/renderer/src/components/activity/ActivityTitlebarControls.tsx +++ b/src/renderer/src/components/activity/ActivityTitlebarControls.tsx @@ -8,7 +8,7 @@ import { useActivityUnreadCount } from './useActivityUnreadCount' import { translate } from '@/i18n/i18n' export function ActivityTitlebarControls(): React.JSX.Element { - const unreadCount = useActivityUnreadCount(true, 'agent-events') + const unreadCount = useActivityUnreadCount() const closeActivityPage = useAppStore((s) => s.closeActivityPage) return ( diff --git a/src/renderer/src/components/activity/activity-thread-actions.test.ts b/src/renderer/src/components/activity/activity-thread-actions.test.ts index 67b028c29a2..f5c124898da 100644 --- a/src/renderer/src/components/activity/activity-thread-actions.test.ts +++ b/src/renderer/src/components/activity/activity-thread-actions.test.ts @@ -51,6 +51,19 @@ describe('activity thread host routing', () => { getKnownWorktreeById.mockReturnValue(thread.worktree) mocks.getState.mockReturnValue({ getKnownWorktreeById, + worktreesByRepo: { [thread.worktree.repoId]: [thread.worktree] }, + detectedWorktreesByRepo: {}, + folderWorkspaces: [], + showSleepingWorkspaces: true, + filterRepoIds: [], + hideDefaultBranchWorkspace: false, + hideAutomationGeneratedWorkspaces: false, + hideCliCreatedWorkspaces: false, + hideDetachedHeadWorkspaces: false, + hideWorkspacesFromOtherDevices: false, + alwaysShowDefaultBranchWorkspace: true, + visibleWorkspaceHostIds: null, + workspaceHostScope: 'all', tabsByWorktree: { [thread.worktree.id]: [thread.tab] }, activeRepoId: thread.worktree.repoId, activeWorktreeId: thread.worktree.id, diff --git a/src/renderer/src/components/activity/activity-thread-actions.ts b/src/renderer/src/components/activity/activity-thread-actions.ts index 7181b618133..93910512eba 100644 --- a/src/renderer/src/components/activity/activity-thread-actions.ts +++ b/src/renderer/src/components/activity/activity-thread-actions.ts @@ -3,16 +3,25 @@ import { jumpToWorktreeFromSidebar } from '@/lib/worktree-jump-navigation' import { useAppStore } from '@/store' import { getWorktreeExecutionHostId } from '../../../../shared/execution-host' import { parsePaneKey } from '../../../../shared/stable-pane-id' +import { findKnownWorktreeById } from '@/store/slices/worktrees/listing/detected-worktree-meta' +import type { AppState } from '@/store/types' import type { AgentPaneThread } from './activity-thread-types' function getActivityThreadExecutionHostId(thread: AgentPaneThread) { return getWorktreeExecutionHostId(thread.worktree, thread.repo ?? undefined) } -export function hasActivityThreadWorkspace(thread: AgentPaneThread): boolean { - const state = useAppStore.getState() +type ActivityThreadWorkspaceCatalog = Pick< + AppState, + 'worktreesByRepo' | 'detectedWorktreesByRepo' | 'folderWorkspaces' +> + +export function hasActivityThreadWorkspace( + thread: AgentPaneThread, + catalog: ActivityThreadWorkspaceCatalog = useAppStore.getState() +): boolean { return Boolean( - state.getKnownWorktreeById(thread.worktree.id, getActivityThreadExecutionHostId(thread)) + findKnownWorktreeById(catalog, thread.worktree.id, getActivityThreadExecutionHostId(thread)) ) } diff --git a/src/renderer/src/components/activity/use-agent-pane-threads.ts b/src/renderer/src/components/activity/use-agent-pane-threads.ts index 321d9f40e7e..5e38b230331 100644 --- a/src/renderer/src/components/activity/use-agent-pane-threads.ts +++ b/src/renderer/src/components/activity/use-agent-pane-threads.ts @@ -31,6 +31,7 @@ export type AgentPaneThreadsStoreData = Pick< | 'tabsByWorktree' | 'unifiedTabsByWorktree' | 'repos' + | 'worktreesByRepo' | 'folderWorkspaces' | 'detectedWorktreesByRepo' | 'getKnownWorktreeById' @@ -79,6 +80,7 @@ export function useAgentPaneThreads(args: { tabsByWorktree: s.tabsByWorktree, unifiedTabsByWorktree: s.unifiedTabsByWorktree, repos: s.repos, + worktreesByRepo: s.worktreesByRepo, folderWorkspaces: s.folderWorkspaces, detectedWorktreesByRepo: s.detectedWorktreesByRepo, getKnownWorktreeById: s.getKnownWorktreeById, diff --git a/src/renderer/src/components/activity/useActivityUnreadCount.test.ts b/src/renderer/src/components/activity/useActivityUnreadCount.test.ts index 20d2329d77d..9c1330a2cd5 100644 --- a/src/renderer/src/components/activity/useActivityUnreadCount.test.ts +++ b/src/renderer/src/components/activity/useActivityUnreadCount.test.ts @@ -27,23 +27,21 @@ function makeSource(entry: AgentStatusEntry, ackAt = 0) { } describe('countActivityUnread session-boundary rows (STA-3386)', () => { - it('does not count a session-boundary done as unread in either mode', () => { + it('does not count a session-boundary done as unread', () => { const source = makeSource(makeEntry({ sessionBoundary: true })) - expect(countActivityUnread(source, 'sidebar-badge')).toBe(0) - expect(countActivityUnread(source, 'agent-events')).toBe(0) + expect(countActivityUnread(source)).toBe(0) }) it('keeps counting a real completion displaced into history by a session boundary', () => { // Why: agent finished (unacknowledged), then the user resumed the session — the - // boundary row replaces the live done but the finish must stay unread in both badges. + // boundary row replaces the live done but the finish must stay unread. const source = makeSource( makeEntry({ sessionBoundary: true, stateHistory: [{ state: 'done', prompt: 'fix bug', startedAt: 1_000 }] }) ) - expect(countActivityUnread(source, 'sidebar-badge')).toBe(1) - expect(countActivityUnread(source, 'agent-events')).toBe(1) + expect(countActivityUnread(source)).toBe(1) }) it('stops counting the displaced completion once acknowledged', () => { @@ -54,47 +52,12 @@ describe('countActivityUnread session-boundary rows (STA-3386)', () => { }), 1_500 ) - expect(countActivityUnread(source, 'sidebar-badge')).toBe(0) - expect(countActivityUnread(source, 'agent-events')).toBe(0) + expect(countActivityUnread(source)).toBe(0) }) - it('still counts an ordinary unacknowledged done in sidebar-badge mode', () => { + it('still counts an ordinary unacknowledged done', () => { const source = makeSource(makeEntry({})) - expect(countActivityUnread(source, 'sidebar-badge')).toBe(1) - }) -}) - -describe('countActivityUnread child-agent filtering (sidebar-badge)', () => { - const PARENT_PANE = 'tab-2:22222222-2222-4222-8222-222222222222' - - function makeChildSource(parentPaneKey: string) { - const parent = makeEntry({ state: 'working', paneKey: PARENT_PANE }) - const child = makeEntry({ - orchestration: { taskId: 'task-1', dispatchId: 'dispatch-1', parentPaneKey } - }) - return { - acknowledgedAgentsByPaneKey: {}, - agentStatusByPaneKey: { [PARENT_PANE]: parent, [PANE]: child }, - migrationUnsupportedByPtyId: {}, - retainedAgentsByPaneKey: {} - } - } - - it('excludes a child of a listed parent so the badge matches what Mark all read clears', () => { - const source = makeChildSource(PARENT_PANE) - expect(countActivityUnread(source, 'sidebar-badge')).toBe(0) - // The full-page event badge keeps counting every event. - expect(countActivityUnread(source, 'agent-events')).toBe(1) - }) - - it('counts the child when child agents are shown', () => { - const source = { ...makeChildSource(PARENT_PANE), showChildAgents: true } - expect(countActivityUnread(source, 'sidebar-badge')).toBe(1) - }) - - it('counts an orphaned child whose parent pane is gone (promoted to top level)', () => { - const source = makeChildSource('tab-9:99999999-9999-4999-8999-999999999999') - expect(countActivityUnread(source, 'sidebar-badge')).toBe(1) + expect(countActivityUnread(source)).toBe(1) }) }) @@ -109,8 +72,7 @@ describe('countActivityUnread with Clear completed cutoffs', () => { activityClearedAtByPaneKey: { [PANE]: 2_000 } } // Both the history event (1_000) and the live done (2_000) are at or before the cutoff. - expect(countActivityUnread(source, 'agent-events')).toBe(0) - expect(countActivityUnread(source, 'sidebar-badge')).toBe(0) + expect(countActivityUnread(source)).toBe(0) }) it('keeps counting turns newer than the cutoff', () => { @@ -123,7 +85,6 @@ describe('countActivityUnread with Clear completed cutoffs', () => { ), activityClearedAtByPaneKey: { [PANE]: 2_000 } } - expect(countActivityUnread(source, 'agent-events')).toBe(1) - expect(countActivityUnread(source, 'sidebar-badge')).toBe(1) + expect(countActivityUnread(source)).toBe(1) }) }) diff --git a/src/renderer/src/components/activity/useActivityUnreadCount.ts b/src/renderer/src/components/activity/useActivityUnreadCount.ts index 5ff8d68d77c..fb1628db559 100644 --- a/src/renderer/src/components/activity/useActivityUnreadCount.ts +++ b/src/renderer/src/components/activity/useActivityUnreadCount.ts @@ -2,18 +2,9 @@ import { useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' -import { entryWithRuntimeOrchestration } from '../sidebar/worktree-agent-row-orchestration' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' -import type { - AgentStatusEntry, - AgentStatusOrchestrationContext, - AgentStatusState -} from '../../../../shared/agent-status-types' -import { - collectChildAgentPaneKeys, - type ChildAgentClassifiableThread -} from './activity-thread-child-agent' +import type { AgentStatusEntry, AgentStatusState } from '../../../../shared/agent-status-types' type ActivityUnreadCountSource = Pick< AppState, @@ -24,135 +15,49 @@ type ActivityUnreadCountSource = Pick< > & { /** Per-pane "Clear completed" cutoffs; hidden events must not count as unread. */ activityClearedAtByPaneKey?: Record - runtimeAgentOrchestrationByPaneKey?: Record - /** Mirrors the Agents list's child filter: children of listed parents are - * excluded from sidebar-badge counts unless the user shows child agents. */ - showChildAgents?: boolean -} - -type ActivityUnreadCountMode = 'agent-events' | 'sidebar-badge' - -const EMPTY_MIGRATION_UNSUPPORTED: AppState['migrationUnsupportedByPtyId'] = {} -const EMPTY_RETAINED_AGENTS: AppState['retainedAgentsByPaneKey'] = {} -const EMPTY_ACKNOWLEDGED_AGENTS: AppState['acknowledgedAgentsByPaneKey'] = {} -const EMPTY_ACTIVITY_CLEARED_AT: Record = {} -const EMPTY_RUNTIME_ORCHESTRATION: Record = {} - -const DISABLED_ACTIVITY_UNREAD_INPUTS = { - sortEpoch: 0, - migrationUnsupportedByPtyId: EMPTY_MIGRATION_UNSUPPORTED, - retainedAgentsByPaneKey: EMPTY_RETAINED_AGENTS, - acknowledgedAgentsByPaneKey: EMPTY_ACKNOWLEDGED_AGENTS, - activityClearedAtByPaneKey: EMPTY_ACTIVITY_CLEARED_AT, - runtimeAgentOrchestrationByPaneKey: EMPTY_RUNTIME_ORCHESTRATION, - showChildAgents: false } function isUnreadAgentState(state: AgentStatusState): boolean { return state === 'done' || state === 'blocked' || state === 'waiting' } -/** Minimal rows stand in for threads so the always-mounted badge never runs the - * full thread-building pipeline. */ -function toChildClassifierRow(entry: AgentStatusEntry): ChildAgentClassifiableThread { - return { - paneKey: entry.paneKey, - currentAgentEntry: entry - } -} - -function collectHiddenChildPaneKeys(source: ActivityUnreadCountSource): ReadonlySet { - const rows: ChildAgentClassifiableThread[] = [] - const seenPaneKeys = new Set() - const push = (entry: AgentStatusEntry | null): void => { - if (!entry || seenPaneKeys.has(entry.paneKey)) { - return - } - seenPaneKeys.add(entry.paneKey) - rows.push( - toChildClassifierRow( - entryWithRuntimeOrchestration(entry, source.runtimeAgentOrchestrationByPaneKey) - ) - ) - } - for (const entry of Object.values(source.agentStatusByPaneKey)) { - push(entry) - } - for (const retained of Object.values(source.retainedAgentsByPaneKey)) { - push(retained.entry) - } - for (const unsupported of Object.values(source.migrationUnsupportedByPtyId)) { - push(migrationUnsupportedToAgentStatusEntry(unsupported)) - } - return collectChildAgentPaneKeys(rows) -} - -export function countActivityUnread( - source: ActivityUnreadCountSource, - mode: ActivityUnreadCountMode -): number { +/** Counts unread done/blocked/waiting events for the Activity page titlebar badge. */ +export function countActivityUnread(source: ActivityUnreadCountSource): number { let count = 0 - // Why no worktree.isUnread here: the Agents tab lists only agent threads, so a - // worktree unread would light a badge with no row to read and no way to clear it. - // Why the child exclusion: the badge counts exactly what Mark all read can clear; - // a child hidden by the default filter must not keep the badge lit forever. - const hiddenChildPaneKeys = - mode === 'sidebar-badge' && source.showChildAgents !== true - ? collectHiddenChildPaneKeys(source) - : null - + // Why no worktree.isUnread here: Activity lists only agent threads, so a worktree + // unread would light a badge with no row to read and no way to clear it. const countEntry = (entry: AgentStatusEntry, ackAt: number): void => { // Why: "Clear completed" hides events at or before the pane's cutoff from the feed, // so a hidden event must not keep the badge lit; treat the cutoff like an ack floor. const clearedAt = source.activityClearedAtByPaneKey?.[entry.paneKey] ?? 0 const mutedAt = Math.max(ackAt, clearedAt) - if (mode === 'agent-events') { - // Why: Activity feed surfaces historical done/blocked/waiting events - // from stateHistory, so the titlebar badge must mirror that event count. - for (const history of entry.stateHistory) { - if (isUnreadAgentState(history.state) && mutedAt < history.startedAt) { - count += 1 - } + // Why: Activity feed surfaces historical done/blocked/waiting events + // from stateHistory, so the titlebar badge must mirror that event count. + for (const history of entry.stateHistory) { + if (isUnreadAgentState(history.state) && mutedAt < history.startedAt) { + count += 1 } } // Why: a session-boundary done is an idle connect (STA-3386), not an event to read. - // History never contains a boundary, but it DOES keep the real completion a boundary - // displaced (the slice pushes it on done→done), so sidebar-badge mode — which skips the - // history loop above — must still count that displaced completion or the badge silently - // drops an unacknowledged finish the moment its session is resumed. if ( isUnreadAgentState(entry.state) && entry.sessionBoundary !== true && mutedAt < entry.stateStartedAt ) { count += 1 - } else if (mode === 'sidebar-badge' && entry.state === 'done' && entry.sessionBoundary) { - const displaced = entry.stateHistory.at(-1) - if (displaced && isUnreadAgentState(displaced.state) && mutedAt < displaced.startedAt) { - count += 1 - } } } for (const [paneKey, entry] of Object.entries(source.agentStatusByPaneKey)) { - if (hiddenChildPaneKeys?.has(paneKey)) { - continue - } countEntry(entry, source.acknowledgedAgentsByPaneKey[paneKey] ?? 0) } for (const [paneKey, retained] of Object.entries(source.retainedAgentsByPaneKey)) { - if (mode === 'sidebar-badge' && retained.entry.state !== 'done') { - continue - } - if (hiddenChildPaneKeys?.has(paneKey)) { - continue - } countEntry(retained.entry, source.acknowledgedAgentsByPaneKey[paneKey] ?? 0) } for (const unsupported of Object.values(source.migrationUnsupportedByPtyId)) { const entry = migrationUnsupportedToAgentStatusEntry(unsupported) - if (entry && !hiddenChildPaneKeys?.has(entry.paneKey)) { + if (entry) { countEntry(entry, source.acknowledgedAgentsByPaneKey[entry.paneKey] ?? 0) } } @@ -160,61 +65,40 @@ export function countActivityUnread( return count } -export function useActivityUnreadCount(enabled: boolean, mode: ActivityUnreadCountMode): number { +export function useActivityUnreadCount(): number { const { sortEpoch, migrationUnsupportedByPtyId, retainedAgentsByPaneKey, acknowledgedAgentsByPaneKey, - activityClearedAtByPaneKey, - runtimeAgentOrchestrationByPaneKey, - showChildAgents + activityClearedAtByPaneKey } = useAppStore( - useShallow((state) => { - if (!enabled) { - return DISABLED_ACTIVITY_UNREAD_INPUTS - } - return { - // Why: live status prompt/tool updates churn agentStatusByPaneKey but - // cannot change unread count unless a sort-relevant state transition - // or removal occurred. sortEpoch is the cheap invalidation signal. - sortEpoch: state.sortEpoch, - migrationUnsupportedByPtyId: state.migrationUnsupportedByPtyId, - retainedAgentsByPaneKey: state.retainedAgentsByPaneKey, - acknowledgedAgentsByPaneKey: state.acknowledgedAgentsByPaneKey, - activityClearedAtByPaneKey: state.activityClearedAtByPaneKey, - runtimeAgentOrchestrationByPaneKey: state.runtimeAgentOrchestrationByPaneKey, - showChildAgents: state.agentsShowChildAgents - } - }) + useShallow((state) => ({ + // Why: live status prompt/tool updates churn agentStatusByPaneKey but + // cannot change unread count unless a sort-relevant state transition + // or removal occurred. sortEpoch is the cheap invalidation signal. + sortEpoch: state.sortEpoch, + migrationUnsupportedByPtyId: state.migrationUnsupportedByPtyId, + retainedAgentsByPaneKey: state.retainedAgentsByPaneKey, + acknowledgedAgentsByPaneKey: state.acknowledgedAgentsByPaneKey, + activityClearedAtByPaneKey: state.activityClearedAtByPaneKey + })) ) return useMemo(() => { - if (!enabled) { - return 0 - } void sortEpoch - return countActivityUnread( - { - agentStatusByPaneKey: useAppStore.getState().agentStatusByPaneKey, - migrationUnsupportedByPtyId, - retainedAgentsByPaneKey, - acknowledgedAgentsByPaneKey, - activityClearedAtByPaneKey, - runtimeAgentOrchestrationByPaneKey, - showChildAgents - }, - mode - ) + return countActivityUnread({ + agentStatusByPaneKey: useAppStore.getState().agentStatusByPaneKey, + migrationUnsupportedByPtyId, + retainedAgentsByPaneKey, + acknowledgedAgentsByPaneKey, + activityClearedAtByPaneKey + }) }, [ acknowledgedAgentsByPaneKey, activityClearedAtByPaneKey, - enabled, migrationUnsupportedByPtyId, - mode, retainedAgentsByPaneKey, - runtimeAgentOrchestrationByPaneKey, - showChildAgents, sortEpoch ]) } diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index 626d119bc9a..3f2b571d01c 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -304,7 +304,6 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ /> diff --git a/src/renderer/src/components/dashboard/DashboardAgentRowMessage.tsx b/src/renderer/src/components/dashboard/DashboardAgentRowMessage.tsx index c439f0e6999..6b107086523 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRowMessage.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRowMessage.tsx @@ -4,21 +4,18 @@ import { translate } from '@/i18n/i18n' type DashboardAgentRowMessageProps = { expanded: boolean - isWorking: boolean isInterrupted: boolean lastAssistantMessage: string } export function DashboardAgentRowMessage({ expanded, - isWorking, isInterrupted, lastAssistantMessage }: DashboardAgentRowMessageProps): React.JSX.Element | null { - const visibleMessage = isWorking ? '' : lastAssistantMessage // Why: message slot is always reserved in collapsed view so the row height // stays fixed as assistant text arrives or clears. - if (!isInterrupted && !visibleMessage) { + if (!isInterrupted && !lastAssistantMessage) { return expanded ? null : (
) @@ -40,9 +37,9 @@ export function DashboardAgentRowMessage({ )} ) : null} - {visibleMessage ? ( + {lastAssistantMessage ? ( ) : null} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-original-pane-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-original-pane-actions.ts index 5c7e5d01200..86077e1c598 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-original-pane-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-original-pane-actions.ts @@ -2,7 +2,7 @@ import { useCallback, useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' import { toast } from 'sonner' import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' -import { jumpToWorktreeFromSidebar } from '@/lib/worktree-jump-navigation' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { useAppStore } from '@/store' import type { AgentStatusState } from '../../../../shared/agent-status-types' import type { AiVaultSession } from '../../../../shared/ai-vault-types' @@ -62,7 +62,7 @@ export function useAiVaultOriginalPaneActions(): { return } - if (!jumpToWorktreeFromSidebar(target.worktreeId)) { + if (!activateAndRevealWorktree(target.worktreeId)) { toast.error( translate( 'auto.components.right.sidebar.AiVaultPanel.worktreeUnavailable', @@ -80,7 +80,7 @@ export function useAiVaultOriginalPaneActions(): { }, []) const jumpToWorktree = useCallback((worktreeId: string): void => { - if (!jumpToWorktreeFromSidebar(worktreeId)) { + if (!activateAndRevealWorktree(worktreeId)) { toast.error( translate( 'auto.components.right.sidebar.AiVaultPanel.worktreeUnavailable', diff --git a/src/renderer/src/components/settings/AgentDashboardExperimentalSetting.tsx b/src/renderer/src/components/settings/AgentDashboardExperimentalSetting.tsx new file mode 100644 index 00000000000..c22f5a9050a --- /dev/null +++ b/src/renderer/src/components/settings/AgentDashboardExperimentalSetting.tsx @@ -0,0 +1,105 @@ +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { translate } from '@/i18n/i18n' +import { Label } from '../ui/label' +import { SearchableSetting } from './SearchableSetting' +import { SettingsSegmentedControl, SettingsSwitch } from './SettingsFormControls' +import { getExperimentalSearchEntry } from './experimental-search' + +type AgentDashboardExperimentalSettingProps = { + settings: GlobalSettings + updateSettings: (updates: Partial) => void +} + +export function AgentDashboardExperimentalSetting({ + settings, + updateSettings +}: AgentDashboardExperimentalSettingProps): React.JSX.Element { + const enabled = settings.experimentalAgentDashboardPopout === true + const mode = settings.experimentalAgentDashboardMode ?? 'in-window' + + return ( + +
+
+ +

+ {translate( + 'auto.components.settings.ExperimentalPane.agentDashboard.copy', + 'Adds an Agent Dashboard entry to the left sidebar. Monitor agents that need you, are working, or are done, with optional idle agents.' + )} +

+
+ updateSettings({ experimentalAgentDashboardPopout: !enabled })} + /> +
+ {enabled ? ( +
+
+
+ +

+ {translate( + 'auto.components.settings.ExperimentalPane.agentDashboard.modeCopy', + 'Show the dashboard as an in-window board beside the sidebar or a separate pop-out window.' + )} +

+
+ updateSettings({ experimentalAgentDashboardMode: next })} + ariaLabel={translate( + 'auto.components.settings.ExperimentalPane.agentDashboard.modeAriaLabel', + 'Agent Dashboard open mode' + )} + size="sm" + options={[ + { + value: 'in-window', + label: translate( + 'auto.components.settings.ExperimentalPane.agentDashboard.modeInWindow', + 'In-window' + ) + }, + { + value: 'popout', + label: translate( + 'auto.components.settings.ExperimentalPane.agentDashboard.modePopout', + 'Pop-out' + ) + } + ]} + /> +
+
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/ExperimentalPane.test.tsx b/src/renderer/src/components/settings/ExperimentalPane.test.tsx index 1a03cfbabed..5985a352f20 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.test.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.test.tsx @@ -132,7 +132,7 @@ describe('ExperimentalPane', () => { ) }) - it('exposes the Agents sidebar visibility switch for discoverability', () => { + it('renders the Agents sidebar switch in Experimental without stale Appearance copy', () => { const settings = getDefaultSettings('/tmp') const markup = renderToStaticMarkup( @@ -140,13 +140,45 @@ describe('ExperimentalPane', () => { expect(settings.experimentalAgentDashboardPopout).toBeUndefined() expect(markup).toContain('Show Agents Button') - expect(markup).toContain('Appearance') - expect(markup).toContain('Window & Sidebar') + expect(markup).toContain('Controls whether the Agents tab appears in the left sidebar') + expect(markup).not.toContain('Window & Sidebar') expect(getExperimentalPaneSearchEntries().map((entry) => entry.title)).toContain( 'Show Agents Button' ) }) + it('renders the agent dashboard as an off-by-default searchable experiment', () => { + const settings = getDefaultSettings('/tmp') + const markup = renderToStaticMarkup( + + ) + + expect(settings.experimentalAgentDashboardPopout).toBeUndefined() + expect(markup).toContain('Agent Dashboard') + expect(markup).toContain('Monitor agents that need you, are working, or are done') + expect(getExperimentalPaneSearchEntries().map((entry) => entry.title)).toContain( + 'Agent Dashboard' + ) + }) + + it('enables the agent dashboard through its experimental switch', async () => { + const updateSettings = vi.fn() + const { root, container } = await renderExperimentalPane({ updateSettings }) + const switchButton = container.querySelector( + '#experimental-agent-dashboard button[role="switch"]' + ) + if (!switchButton) { + throw new Error('Agent Dashboard switch was not rendered') + } + + await act(async () => { + switchButton.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(updateSettings).toHaveBeenCalledWith({ experimentalAgentDashboardPopout: true }) + root.unmount() + }) + it('keeps idle-agent visibility out of global settings', () => { const markup = renderToStaticMarkup( ) : null} + {showAgentDashboard ? ( + + ) : null} + {showPet ? ( { expect(agentsList.getAttribute('data-query')).toBe('') }) - it('closes the dashboard drawer when the Agents sidebar is disabled', async () => { - setSidebarState({ ...getDefaultSettings(tmpdir()), showAgentsSidebar: false }) + it('closes the dashboard drawer when the dashboard experiment is disabled', async () => { + setSidebarState({ + ...getDefaultSettings(tmpdir()), + showAgentsSidebar: true, + experimentalAgentDashboardPopout: false + }) const setAgentDashboardDrawerOpen = vi.fn() mocks.state = { ...mocks.state, diff --git a/src/renderer/src/components/sidebar/SidebarAgentsList.tsx b/src/renderer/src/components/sidebar/SidebarAgentsList.tsx index 056df3c0cf1..80e3604e40d 100644 --- a/src/renderer/src/components/sidebar/SidebarAgentsList.tsx +++ b/src/renderer/src/components/sidebar/SidebarAgentsList.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react' +import React, { useCallback, useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { useAppStore } from '@/store' import { ActivityScopeFilterChips } from '@/components/activity/activity-scope-filter-controls' @@ -76,7 +76,15 @@ export default function SidebarAgentsList({ setSelectedPaneKey }) - const canJumpToWorkspace = hasActivityThreadWorkspace + const canJumpToWorkspace = useCallback( + (thread: Parameters[0]) => + hasActivityThreadWorkspace(thread, { + worktreesByRepo: storeData.worktreesByRepo, + detectedWorktreesByRepo: storeData.detectedWorktreesByRepo, + folderWorkspaces: storeData.folderWorkspaces + }), + [storeData.worktreesByRepo, storeData.detectedWorktreesByRepo, storeData.folderWorkspaces] + ) return ( <> diff --git a/src/renderer/src/components/sidebar/SidebarHeader.test.tsx b/src/renderer/src/components/sidebar/SidebarHeader.test.tsx index a55ad9083c3..bba1cba2729 100644 --- a/src/renderer/src/components/sidebar/SidebarHeader.test.tsx +++ b/src/renderer/src/components/sidebar/SidebarHeader.test.tsx @@ -9,7 +9,6 @@ import SidebarHeader from './SidebarHeader' const mocks = vi.hoisted(() => ({ openWorkspaceCreationComposerWithTourHandoff: vi.fn(), - unreadCount: { value: 0 }, popoverContentProps: { current: null as Record | null }, toast: vi.fn() })) @@ -66,10 +65,6 @@ vi.mock('../contextual-tours/workspace-creation-tour-handoff', () => ({ openWorkspaceCreationComposerWithTourHandoff: mocks.openWorkspaceCreationComposerWithTourHandoff })) -vi.mock('@/components/activity/useActivityUnreadCount', () => ({ - useActivityUnreadCount: (enabled: boolean) => (enabled ? mocks.unreadCount.value : 0) -})) - vi.mock('sonner', () => ({ toast: mocks.toast })) // Deterministic popover: expose the open flag instead of relying on radix portals. @@ -122,7 +117,6 @@ beforeEach(() => { // Hydrated settings: the Agents tab is hidden until settings load. settings: { showAgentsSidebar: true } } - mocks.unreadCount.value = 0 container = document.createElement('div') document.body.append(container) root = createRoot(container) @@ -282,18 +276,6 @@ describe('SidebarHeader', () => { expect(mocks.openWorkspaceCreationComposerWithTourHandoff).toHaveBeenCalledTimes(1) }) - it('shows the unread count badge on the Agents tab', () => { - mocks.unreadCount.value = 3 - act(() => { - root.render() - }) - - const agentTab = container.querySelector( - 'button[data-sidebar-section-title="agents"]' - ) - expect(agentTab?.textContent).toContain('3') - }) - it('keeps the intro closed and unstamped before settings hydrate', () => { mockState.settings = undefined act(() => { diff --git a/src/renderer/src/components/sidebar/SidebarHeader.tsx b/src/renderer/src/components/sidebar/SidebarHeader.tsx index 366b1745d5e..fb44a5e83d5 100644 --- a/src/renderer/src/components/sidebar/SidebarHeader.tsx +++ b/src/renderer/src/components/sidebar/SidebarHeader.tsx @@ -4,8 +4,7 @@ import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import { SidebarViewToggle } from './sidebar-view-toggle' import { SidebarHeaderActions } from './sidebar-header-actions' -import { shouldShowAgentDashboardSidebarButton } from './agent-dashboard-sidebar-visibility' -import { useActivityUnreadCount } from '@/components/activity/useActivityUnreadCount' +import { shouldShowAgentsSidebar } from './agents-sidebar-visibility' import { Popover, PopoverAnchor, PopoverArrow, PopoverContent } from '@/components/ui/popover' import { Button } from '@/components/ui/button' import { Sparkles } from 'lucide-react' @@ -29,9 +28,7 @@ const SidebarHeader = React.memo(function SidebarHeader({ const sidebarBody = useAppStore((s) => s.sidebarBody ?? 'workspaces') // Why the derived boolean, not s.settings: the settings object gets a new identity on // every write, which would re-render this memoized header subtree each time. - const showAgentsSidebarFromStore = useAppStore((s) => - shouldShowAgentDashboardSidebarButton(s.settings) - ) + const showAgentsSidebarFromStore = useAppStore((s) => shouldShowAgentsSidebar(s.settings)) const showAgentsSidebar = showAgentsSidebarProp ?? showAgentsSidebarFromStore const groupBy = useAppStore((s) => s.groupBy) const setSidebarBody = useAppStore((s) => s.setSidebarBody) @@ -44,7 +41,6 @@ const SidebarHeader = React.memo(function SidebarHeader({ // null default would flash the popover open (and stamp it shown) every launch. const settingsHydrated = useAppStore((s) => s.settings != null) const agentsViewActive = showAgentsSidebar && sidebarBody === 'agents' - const agentsUnreadCount = useActivityUnreadCount(showAgentsSidebar, 'sidebar-badge') const introOpen = settingsHydrated && showAgentsSidebar && !agentsSidebarIntroShown const acknowledgeIntro = React.useCallback(() => { void updateSettings?.({ agentsSidebarIntroShown: true }) @@ -106,7 +102,6 @@ const SidebarHeader = React.memo(function SidebarHeader({ value: 'agents' as const, label: translate('dashboard.sidebar.label', 'Agents'), sectionTitle: 'agents' as const, - badgeCount: agentsUnreadCount, renderWrapper: (button: React.ReactNode) => ( {button} ) @@ -119,22 +114,42 @@ const SidebarHeader = React.memo(function SidebarHeader({ {/* Why: prevent startup terminal/editor auto-focus from dismissing the intro popover. */} event.preventDefault()} onFocusOutside={(event) => event.preventDefault()} > - + + + + + +
+
- -

+ +

{migratedFromExperimental ? translate('agentsSidebarIntro.migrated.title', 'Agents are easier to find') : translate('agentsSidebarIntro.new.title', 'Meet your Agents tab')} @@ -152,7 +167,7 @@ const SidebarHeader = React.memo(function SidebarHeader({ )}

-
+
{!migratedFromExperimental ? ( ) diff --git a/src/renderer/src/components/ui/popover.tsx b/src/renderer/src/components/ui/popover.tsx index ee04a763723..ffd2c221e85 100644 --- a/src/renderer/src/components/ui/popover.tsx +++ b/src/renderer/src/components/ui/popover.tsx @@ -191,12 +191,14 @@ function PopoverContent({ function PopoverArrow({ className, + style, ...props }: React.ComponentProps) { return ( ) diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index bbb02216306..89b80d0232e 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14853,7 +14853,8 @@ "title": "Conoce tu pestaña de Agentes", "description": "Ve en qué están trabajando tus agentes, qué está terminado y dónde necesitas intervenir.", "dismiss": "Quizás más tarde", - "action": "Probar Agentes" + "action": "Probar Agentes", + "hiddenToast": "La pestaña Agentes está oculta. Vuelve a activarla en Configuración → Experimental." } } } diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 3ad10876d7e..7490f9f8b90 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -14853,7 +14853,8 @@ "title": "Agent タブのご紹介", "description": "Agent が何に取り組んでいるか、何が完了したか、どこで対応が必要かを確認できます。", "dismiss": "後で", - "action": "Agent を試す" + "action": "Agent を試す", + "hiddenToast": "Agent タブを非表示にしました。設定 → 実験的機能で再度有効にできます。" } } } diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 6f6c95de76c..a9767985cf1 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -14970,7 +14970,8 @@ "title": "에이전트 탭을 만나보세요", "description": "에이전트가 무엇을 작업 중인지, 무엇이 완료되었는지, 어디에 개입이 필요한지 확인하세요.", "dismiss": "나중에", - "action": "에이전트 사용해 보기" + "action": "에이전트 사용해 보기", + "hiddenToast": "에이전트 탭이 숨겨졌습니다. 설정 → 실험 기능에서 다시 활성화하세요." } } } diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index c39e3794bfe..60944b56f33 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -14970,7 +14970,8 @@ "title": "认识你的智能体标签页", "description": "查看智能体正在做什么、哪些已完成,以及哪些需要你介入。", "dismiss": "以后再说", - "action": "试用智能体" + "action": "试用智能体", + "hiddenToast": "智能体标签页已隐藏。可在设置 → 实验性功能中重新启用。" } } } diff --git a/src/renderer/src/lib/worktree-jump-navigation.test.ts b/src/renderer/src/lib/worktree-jump-navigation.test.ts index f7c741981b0..653f3c65454 100644 --- a/src/renderer/src/lib/worktree-jump-navigation.test.ts +++ b/src/renderer/src/lib/worktree-jump-navigation.test.ts @@ -41,7 +41,8 @@ describe('worktree jump navigation', () => { hideWorkspacesFromOtherDevices: false, alwaysShowDefaultBranchWorkspace: true, visibleWorkspaceHostIds: null, - workspaceHostScope: 'all' + workspaceHostScope: 'all', + revealWorktreeInSidebar: vi.fn() }) }) @@ -66,6 +67,16 @@ describe('worktree jump navigation', () => { expect(mocks.warning).not.toHaveBeenCalled() }) + it('reveals without warning when activation wakes a target hidden only by Hide sleeping', () => { + const state = mocks.getState() + mocks.worktreePassesSidebarFilters.mockReturnValueOnce(false).mockReturnValueOnce(true) + + expect(jumpToWorktreeFromSidebar('wt-sleeping')).toBe(true) + + expect(state.revealWorktreeInSidebar).toHaveBeenCalledWith('wt-sleeping', {}) + expect(mocks.warning).not.toHaveBeenCalled() + }) + it('reveals instead of warning when the target is only inside a collapsed group', () => { // Absent from the rendered list (collapse elision) but not excluded by filters. mocks.getVisibleWorktreeShortcutTargets.mockReturnValue([]) diff --git a/src/renderer/src/lib/worktree-jump-navigation.ts b/src/renderer/src/lib/worktree-jump-navigation.ts index 55e60df303b..56de1f47911 100644 --- a/src/renderer/src/lib/worktree-jump-navigation.ts +++ b/src/renderer/src/lib/worktree-jump-navigation.ts @@ -44,13 +44,13 @@ export function jumpToWorktreeFromSidebar( const state = useAppStore.getState() // Folder workspaces aren't in the worktree filter pipeline; only git worktrees can be filter-hidden. - const hiddenByFilters = + const hiddenBeforeActivation = parseWorkspaceKey(worktreeId)?.type !== 'folder' && wasHiddenBySidebarFilters(worktreeId, options?.executionHostId) // Why the workspace dispatcher: it owns the folder-vs-worktree split and the folder path-status gate. const activated = activateAndRevealWorkspace(worktreeId, { - ...(hiddenByFilters ? { revealInSidebar: false, clearSidebarFilters: false } : {}), + ...(hiddenBeforeActivation ? { revealInSidebar: false, clearSidebarFilters: false } : {}), ...(options?.executionHostId ? { executionHostId: options.executionHostId } : {}) }) if (activated === false) { @@ -60,7 +60,20 @@ export function jumpToWorktreeFromSidebar( // The worktree list is the Spaces/Projects sidebar body; jump actions should always expose it. state.setSidebarBody?.('workspaces') - if (hiddenByFilters) { + const hiddenAfterActivation = + hiddenBeforeActivation && wasHiddenBySidebarFilters(worktreeId, options?.executionHostId) + if (hiddenBeforeActivation && !hiddenAfterActivation) { + // Activation can seed a terminal, making a workspace excluded only by Hide sleeping visible. + // Queue the reveal after that state transition instead of reporting a filter conflict. + useAppStore + .getState() + .revealWorktreeInSidebar( + worktreeId, + options?.executionHostId ? { executionHostId: options.executionHostId } : {} + ) + } + + if (hiddenAfterActivation) { toast.warning( translate( 'auto.lib.worktreeJumpNavigation.filteredNotice', diff --git a/src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts b/src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts index 945c4028fe0..92bc91610e6 100644 --- a/src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts +++ b/src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts @@ -20,11 +20,17 @@ describe('acknowledgedAgentsByPaneKey cleanup on teardown', () => { .getState() .setAgentStatus('tab-1:0', { state: 'working', prompt: 'p', agentType: 'claude' }) store.getState().acknowledgeAgents(['tab-1:0']) + store.setState({ + activityClearedAtByPaneKey: { 'tab-1:0': 100 }, + manuallyUnreadTurnsByPaneKey: { 'tab-1:0': 200 } + }) expect(store.getState().acknowledgedAgentsByPaneKey['tab-1:0']).toBeGreaterThan(0) store.getState().removeAgentStatus('tab-1:0') expect(store.getState().acknowledgedAgentsByPaneKey['tab-1:0']).toBeUndefined() + expect(store.getState().activityClearedAtByPaneKey['tab-1:0']).toBeUndefined() + expect(store.getState().manuallyUnreadTurnsByPaneKey['tab-1:0']).toBeUndefined() }) it('removeAgentStatusByTabPrefix drops every ack entry whose paneKey starts with the tab prefix', () => { @@ -40,6 +46,10 @@ describe('acknowledgedAgentsByPaneKey cleanup on teardown', () => { .getState() .setAgentStatus('tab-10:0', { state: 'working', prompt: 'p', agentType: 'claude' }) store.getState().acknowledgeAgents(['tab-1:0', 'tab-1:1', 'tab-10:0']) + store.setState({ + activityClearedAtByPaneKey: { 'tab-1:0': 100, 'tab-10:0': 300 }, + manuallyUnreadTurnsByPaneKey: { 'tab-1:1': 200, 'tab-10:0': 400 } + }) store.getState().removeAgentStatusByTabPrefix('tab-1') @@ -49,6 +59,8 @@ describe('acknowledgedAgentsByPaneKey cleanup on teardown', () => { // Why: the ":" delimiter on the prefix guards against false-prefix matches // across tab ids that share a leading substring (tab-1 vs tab-10). expect(ack['tab-10:0']).toBeGreaterThan(0) + expect(store.getState().activityClearedAtByPaneKey).toEqual({ 'tab-10:0': 300 }) + expect(store.getState().manuallyUnreadTurnsByPaneKey).toEqual({ 'tab-10:0': 400 }) }) it('dropAgentStatus drops the ack entry even when the pane had no live entry', () => { diff --git a/src/renderer/src/store/slices/agent-status-cleanup-actions.ts b/src/renderer/src/store/slices/agent-status-cleanup-actions.ts index 352a86526df..36229a25270 100644 --- a/src/renderer/src/store/slices/agent-status-cleanup-actions.ts +++ b/src/renderer/src/store/slices/agent-status-cleanup-actions.ts @@ -2,6 +2,7 @@ import type { AgentStatusSlice } from './agent-status-slice-contract' import type { AgentStatusRuntime } from './agent-status-runtime' import { collectWorktreeIdsForConnection } from './agent-status-connection-worktree-scope' import { pruneMigrationUnsupportedEntries } from './agent-status-migration-unsupported-entries' +import { removePaneKeys, removePaneKeysByTabPrefix } from './agent-status-pane-keyed-records' /** Actions for removing transient rows and migration-era cache entries. */ export function createAgentStatusCleanupActions( @@ -53,6 +54,9 @@ export function createAgentStatusCleanupActions( if ( !(paneKey in current.agentStatusByPaneKey) && !(paneKey in current.agentLaunchConfigByPaneKey) && + !(paneKey in current.acknowledgedAgentsByPaneKey) && + !(paneKey in current.activityClearedAtByPaneKey) && + !(paneKey in current.manuallyUnreadTurnsByPaneKey) && !Object.values(current.migrationUnsupportedByPtyId).some( (entry) => entry.paneKey === paneKey ) @@ -76,12 +80,10 @@ export function createAgentStatusCleanupActions( s.migrationUnsupportedByPtyId, (entry) => entry.paneKey === paneKey ) - // Ack entries belong to the pane lifecycle; never let a reused key inherit one. - let nextAck = s.acknowledgedAgentsByPaneKey - if (paneKey in nextAck) { - nextAck = { ...nextAck } - delete nextAck[paneKey] - } + const paneKeys = new Set([paneKey]) + const nextAck = removePaneKeys(s.acknowledgedAgentsByPaneKey, paneKeys) + const nextClearedAt = removePaneKeys(s.activityClearedAtByPaneKey, paneKeys) + const nextManualUnread = removePaneKeys(s.manuallyUnreadTurnsByPaneKey, paneKeys) return { agentStatusByPaneKey: next, agentLaunchConfigByPaneKey: nextLaunchConfigs, @@ -89,6 +91,12 @@ export function createAgentStatusCleanupActions( ...(nextAck !== s.acknowledgedAgentsByPaneKey ? { acknowledgedAgentsByPaneKey: nextAck } : {}), + ...(nextClearedAt !== s.activityClearedAtByPaneKey + ? { activityClearedAtByPaneKey: nextClearedAt } + : {}), + ...(nextManualUnread !== s.manuallyUnreadTurnsByPaneKey + ? { manuallyUnreadTurnsByPaneKey: nextManualUnread } + : {}), agentStatusEpoch: s.agentStatusEpoch + 1, sortEpoch: s.sortEpoch + 1 } @@ -108,7 +116,17 @@ export function createAgentStatusCleanupActions( const hasMigrationUnsupported = Object.values(current.migrationUnsupportedByPtyId).some( (entry) => entry.paneKey?.startsWith(prefix) ) - if (toRemove.length === 0 && launchConfigKeys.length === 0 && !hasMigrationUnsupported) { + const hasPaneActivityState = [ + current.acknowledgedAgentsByPaneKey, + current.activityClearedAtByPaneKey, + current.manuallyUnreadTurnsByPaneKey + ].some((record) => Object.keys(record).some((key) => key.startsWith(prefix))) + if ( + toRemove.length === 0 && + launchConfigKeys.length === 0 && + !hasMigrationUnsupported && + !hasPaneActivityState + ) { return } set((s) => { @@ -124,14 +142,12 @@ export function createAgentStatusCleanupActions( s.migrationUnsupportedByPtyId, (entry) => entry.paneKey?.startsWith(prefix) ?? false ) - let nextAck = s.acknowledgedAgentsByPaneKey - const ackKeys = Object.keys(nextAck).filter((key) => key.startsWith(prefix)) - if (ackKeys.length > 0) { - nextAck = { ...nextAck } - for (const key of ackKeys) { - delete nextAck[key] - } - } + const nextAck = removePaneKeysByTabPrefix(s.acknowledgedAgentsByPaneKey, tabIdPrefix) + const nextClearedAt = removePaneKeysByTabPrefix(s.activityClearedAtByPaneKey, tabIdPrefix) + const nextManualUnread = removePaneKeysByTabPrefix( + s.manuallyUnreadTurnsByPaneKey, + tabIdPrefix + ) return { agentStatusByPaneKey: next, agentLaunchConfigByPaneKey: nextLaunchConfigs, @@ -139,6 +155,12 @@ export function createAgentStatusCleanupActions( ...(nextAck !== s.acknowledgedAgentsByPaneKey ? { acknowledgedAgentsByPaneKey: nextAck } : {}), + ...(nextClearedAt !== s.activityClearedAtByPaneKey + ? { activityClearedAtByPaneKey: nextClearedAt } + : {}), + ...(nextManualUnread !== s.manuallyUnreadTurnsByPaneKey + ? { manuallyUnreadTurnsByPaneKey: nextManualUnread } + : {}), agentStatusEpoch: s.agentStatusEpoch + 1, sortEpoch: s.sortEpoch + 1 } diff --git a/src/renderer/src/store/slices/agent-status-drop-reducer.ts b/src/renderer/src/store/slices/agent-status-drop-reducer.ts index d9619f61965..ca48b195238 100644 --- a/src/renderer/src/store/slices/agent-status-drop-reducer.ts +++ b/src/renderer/src/store/slices/agent-status-drop-reducer.ts @@ -3,7 +3,8 @@ import type { DropAgentStatusByTabPrefixOptions } from './agent-status-contract' import { pruneMigrationUnsupportedEntries } from './agent-status-migration-unsupported-entries' import { boundRecentlyClosedAgentStatusTabIds, - boundRecentlyRetiredAgentStatusPaneKeys + boundRecentlyRetiredAgentStatusPaneKeys, + removePaneKeysByTabPrefix } from './agent-status-pane-keyed-records' import { findCompletedOrphanPaneKeysForTabClose } from './agent-status-pane-key-tab-binding' @@ -40,24 +41,6 @@ export type AgentStatusTabPrefixDropState = Pick< | 'tabsByWorktree' > -function removePaneKeyRecords( - record: Record, - prefix: string, - extraKeys: ReadonlySet -): Record { - const matchingKeys = Object.keys(record).filter( - (key) => key.startsWith(prefix) || extraKeys.has(key) - ) - if (matchingKeys.length === 0) { - return record - } - const next = { ...record } - for (const key of matchingKeys) { - delete next[key] - } - return next -} - /** Pure form of the dropAgentStatusByTabPrefix reducer: the paired snapshot * apply folds the same sweep into a patch it assembles itself, so the two * paths cannot drift. `retiredAliasPaneKeys` comes from the caller because @@ -108,10 +91,14 @@ export function buildAgentStatusTabPrefixDropPatch( ) const nextClearedAt = opts?.preserveActivityClearedState ? s.activityClearedAtByPaneKey - : removePaneKeyRecords(s.activityClearedAtByPaneKey, prefix, completedOrphanKeySet) + : removePaneKeysByTabPrefix(s.activityClearedAtByPaneKey, tabIdPrefix, completedOrphanKeySet) const nextManualUnread = opts?.preserveActivityClearedState ? s.manuallyUnreadTurnsByPaneKey - : removePaneKeyRecords(s.manuallyUnreadTurnsByPaneKey, prefix, completedOrphanKeySet) + : removePaneKeysByTabPrefix( + s.manuallyUnreadTurnsByPaneKey, + tabIdPrefix, + completedOrphanKeySet + ) if ( liveKeys.length === 0 && diff --git a/src/renderer/src/store/slices/agent-status-pane-keyed-records.ts b/src/renderer/src/store/slices/agent-status-pane-keyed-records.ts index 1e28710c564..af5cc4e83c7 100644 --- a/src/renderer/src/store/slices/agent-status-pane-keyed-records.ts +++ b/src/renderer/src/store/slices/agent-status-pane-keyed-records.ts @@ -74,3 +74,15 @@ export function removePaneKeys( } return next } + +export function removePaneKeysByTabPrefix( + record: Record, + tabPrefix: string, + extraPaneKeys: ReadonlySet = new Set() +): Record { + const prefix = `${tabPrefix}:` + const matchingKeys = Object.keys(record).filter( + (key) => key.startsWith(prefix) || extraPaneKeys.has(key) + ) + return removePaneKeys(record, new Set(matchingKeys)) +} diff --git a/src/renderer/src/store/slices/agent-status-provider-session-actions.ts b/src/renderer/src/store/slices/agent-status-provider-session-actions.ts index b8a88b9bca6..5c41985e73f 100644 --- a/src/renderer/src/store/slices/agent-status-provider-session-actions.ts +++ b/src/renderer/src/store/slices/agent-status-provider-session-actions.ts @@ -134,6 +134,7 @@ export function createAgentStatusProviderSessionActions( if (nextRetained !== s.retainedAgentsByPaneKey) { delete nextRetained[paneKey] } + const retiredPaneKeys = new Set([paneKey]) // Why: on identity mismatch the sleeping record drops its launch config, so clear the stale // registry entry too, else a later return to the old identity reuses stale args/env. let nextLaunchConfigs = s.agentLaunchConfigByPaneKey @@ -159,12 +160,14 @@ export function createAgentStatusProviderSessionActions( agentLaunchConfigByPaneKey: nextLaunchConfigs, acknowledgedAgentsByPaneKey: removePaneKeys( s.acknowledgedAgentsByPaneKey, - new Set([paneKey]) + retiredPaneKeys ), - unreadAgentCompletionPanes: removePaneKeys( - s.unreadAgentCompletionPanes, - new Set([paneKey]) + activityClearedAtByPaneKey: removePaneKeys(s.activityClearedAtByPaneKey, retiredPaneKeys), + manuallyUnreadTurnsByPaneKey: removePaneKeys( + s.manuallyUnreadTurnsByPaneKey, + retiredPaneKeys ), + unreadAgentCompletionPanes: removePaneKeys(s.unreadAgentCompletionPanes, retiredPaneKeys), agentStatusEpoch: removedLiveStatus ? s.agentStatusEpoch + 1 : s.agentStatusEpoch, sortEpoch: removedLiveStatus ? s.sortEpoch + 1 : s.sortEpoch } diff --git a/src/renderer/src/store/slices/agent-status-worktree-drop-actions.ts b/src/renderer/src/store/slices/agent-status-worktree-drop-actions.ts index 4bafe7dc381..b7d88675f6b 100644 --- a/src/renderer/src/store/slices/agent-status-worktree-drop-actions.ts +++ b/src/renderer/src/store/slices/agent-status-worktree-drop-actions.ts @@ -8,6 +8,7 @@ import { retainedAgentEntryFromLive, shouldReplaceRetainedWithLive } from './agent-status-pane-key-tab-binding' +import { removePaneKeys } from './agent-status-pane-keyed-records' export function createAgentStatusWorktreeDropActions( runtime: AgentStatusRuntime @@ -72,21 +73,23 @@ export function createAgentStatusWorktreeDropActions( } } const retainedEvidenceKeys = new Set(retainedEvidence.keys()) - // Keep acknowledgement for completion evidence so a slept card does not turn bold again. - let nextAck = s.acknowledgedAgentsByPaneKey - const ackKeys = Object.keys(nextAck).filter( - (key) => - !retainedEvidenceKeys.has(key) && - (paneKeyMatchesAnyTabPrefix(key, tabPrefixes) || - liveKeySet.has(key) || - retainedKeySet.has(key)) + // Completion evidence keeps its read/clear state; every fully retired pane drops all three maps. + const activityStateKeys = new Set( + [ + ...Object.keys(s.acknowledgedAgentsByPaneKey), + ...Object.keys(s.activityClearedAtByPaneKey), + ...Object.keys(s.manuallyUnreadTurnsByPaneKey) + ].filter( + (key) => + !retainedEvidenceKeys.has(key) && + (paneKeyMatchesAnyTabPrefix(key, tabPrefixes) || + liveKeySet.has(key) || + retainedKeySet.has(key)) + ) ) - if (ackKeys.length > 0) { - nextAck = { ...nextAck } - for (const key of ackKeys) { - delete nextAck[key] - } - } + const nextAck = removePaneKeys(s.acknowledgedAgentsByPaneKey, activityStateKeys) + const nextClearedAt = removePaneKeys(s.activityClearedAtByPaneKey, activityStateKeys) + const nextManualUnread = removePaneKeys(s.manuallyUnreadTurnsByPaneKey, activityStateKeys) if ( liveKeys.length === 0 && launchConfigKeys.length === 0 && @@ -94,9 +97,18 @@ export function createAgentStatusWorktreeDropActions( retainedEvidence.size === 0 && !migrationUnsupported.changed ) { - return nextAck !== s.acknowledgedAgentsByPaneKey - ? { acknowledgedAgentsByPaneKey: nextAck } - : s + const cleanupPatch = { + ...(nextAck !== s.acknowledgedAgentsByPaneKey + ? { acknowledgedAgentsByPaneKey: nextAck } + : {}), + ...(nextClearedAt !== s.activityClearedAtByPaneKey + ? { activityClearedAtByPaneKey: nextClearedAt } + : {}), + ...(nextManualUnread !== s.manuallyUnreadTurnsByPaneKey + ? { manuallyUnreadTurnsByPaneKey: nextManualUnread } + : {}) + } + return Object.keys(cleanupPatch).length > 0 ? cleanupPatch : s } hadLive = liveKeys.length > 0 const nextLive = @@ -144,6 +156,12 @@ export function createAgentStatusWorktreeDropActions( ...(nextAck !== s.acknowledgedAgentsByPaneKey ? { acknowledgedAgentsByPaneKey: nextAck } : {}), + ...(nextClearedAt !== s.activityClearedAtByPaneKey + ? { activityClearedAtByPaneKey: nextClearedAt } + : {}), + ...(nextManualUnread !== s.manuallyUnreadTurnsByPaneKey + ? { manuallyUnreadTurnsByPaneKey: nextManualUnread } + : {}), agentStatusEpoch: hadLive || migrationUnsupported.changed ? s.agentStatusEpoch + 1 : s.agentStatusEpoch, sortEpoch: hadLive || migrationUnsupported.changed ? s.sortEpoch + 1 : s.sortEpoch diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts index 249f30717a5..a01624e92be 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts @@ -16,7 +16,7 @@ import { } from '../../../../../shared/execution-host' import { persistedUIValuesEqual } from '../../../../../shared/persisted-ui-equality' // Pure predicate over GlobalSettings; safe to share with the store layer. -import { shouldShowAgentDashboardSidebarButton } from '@/components/sidebar/agent-dashboard-sidebar-visibility' +import { shouldShowAgentsSidebar } from '@/components/sidebar/agents-sidebar-visibility' import { DEFAULT_STATUS_BAR_ITEMS } from '../../../../../shared/constants' import type { UISlice } from './ui-slice-contract' @@ -175,14 +175,14 @@ export function sanitizeWorkspaceCleanupDismissals( export function sanitizeHydratedActiveView( value: PersistedUIState['activeView'], - settings: Parameters[0] + settings: Parameters[0] ): TopLevelView { // Why: older data (pre-activeView) or a view a different build doesn't have falls back to terminal rather than rendering nothing. if (!isTopLevelView(value)) { return 'terminal' } // Why: activity is hidden when its entry points are, so gate only it (mobile/automations stay functional when hidden). - if (value === 'activity' && !shouldShowAgentDashboardSidebarButton(settings)) { + if (value === 'activity' && !shouldShowAgentsSidebar(settings)) { return 'terminal' } return value diff --git a/src/renderer/src/store/slices/ui/ui-slice-settings-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-settings-actions.ts index cc56e7aa265..c693c900c4a 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-settings-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-settings-actions.ts @@ -1,7 +1,7 @@ import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' import { isSettingsNavigationTarget } from '../../../lib/settings-navigation-types' // Pure predicate over GlobalSettings; safe to share with the store layer. -import { shouldShowAgentDashboardSidebarButton } from '@/components/sidebar/agent-dashboard-sidebar-visibility' +import { shouldShowAgentsSidebar } from '@/components/sidebar/agents-sidebar-visibility' export function createUiSettingsActions(set: UISliceSet, get: UISliceGet): Partial { return { @@ -20,7 +20,7 @@ export function createUiSettingsActions(set: UISliceSet, get: UISliceGet): Parti // Agents graduated from experimentalActivity; match openActivityPage's gate. const previousView = state.previousViewBeforeSettings === 'activity' && - !shouldShowAgentDashboardSidebarButton(state.settings) + !shouldShowAgentsSidebar(state.settings) ? 'terminal' : state.previousViewBeforeSettings return { activeView: previousView } diff --git a/src/renderer/src/store/slices/ui/ui-slice-view-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-view-actions.ts index e878aee1e95..d61efbb598d 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-view-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-view-actions.ts @@ -1,14 +1,14 @@ import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' import { rewindHistoryIndexPastView } from '../worktree-nav-history' // Pure predicate over GlobalSettings; safe to share with the store layer. -import { shouldShowAgentDashboardSidebarButton } from '@/components/sidebar/agent-dashboard-sidebar-visibility' +import { shouldShowAgentsSidebar } from '@/components/sidebar/agents-sidebar-visibility' export function createUiViewActions(set: UISliceSet, get: UISliceGet): Partial { return { openActivityPage: () => { // Agents graduated from experimentalActivity; gate on the same visibility // rule as the sidebar entry points so the view is reachable iff shown. - if (!shouldShowAgentDashboardSidebarButton(get().settings)) { + if (!shouldShowAgentsSidebar(get().settings)) { return } set((state) => ({ diff --git a/src/shared/agents-sidebar-visibility.ts b/src/shared/agents-sidebar-visibility.ts index 94db13e4b41..c05f8e106ed 100644 --- a/src/shared/agents-sidebar-visibility.ts +++ b/src/shared/agents-sidebar-visibility.ts @@ -1,7 +1,5 @@ export type AgentsSidebarVisibilitySettings = { showAgentsSidebar?: boolean - experimentalActivity?: boolean - experimentalAgentDashboardPopout?: boolean } export function resolveAgentsSidebarVisible( @@ -10,14 +8,5 @@ export function resolveAgentsSidebarVisible( if (!settings) { return true } - if (typeof settings.showAgentsSidebar === 'boolean') { - return settings.showAgentsSidebar - } - if (settings.experimentalActivity === true) { - return true - } - if (typeof settings.experimentalAgentDashboardPopout === 'boolean') { - return settings.experimentalAgentDashboardPopout - } - return true + return settings.showAgentsSidebar !== false } diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index 83b1ff9c1f4..f650e4f4a77 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -427,9 +427,9 @@ export type GlobalSettings = { experimentalActivity: boolean /** Experimental: pop-out Kanban dashboard for monitoring and opening agent terminals across worktrees. */ experimentalAgentDashboardPopout?: boolean - /** Whether the Agents dashboard tab is shown in the left sidebar. Defaults on. */ + /** Experimental: whether the Agents tab is shown in the left sidebar. Defaults on. */ showAgentsSidebar?: boolean - /** Set after the graduated Agents tab introduction has been acknowledged. */ + /** Set after the experimental Agents tab introduction has been acknowledged. */ agentsSidebarIntroShown?: boolean /** True when the profile previously opted into the legacy experimental Agents view. */ agentsSidebarMigratedFromExperimental?: boolean