diff --git a/src/main/agent-hooks/server-status-listener-fanout.test.ts b/src/main/agent-hooks/server-status-listener-fanout.test.ts index 18e7239a91d..af4b40b99b2 100644 --- a/src/main/agent-hooks/server-status-listener-fanout.test.ts +++ b/src/main/agent-hooks/server-status-listener-fanout.test.ts @@ -264,6 +264,49 @@ describe('AgentHookServer listener replay', () => { }) }) + it('evicts when the renderer identity was stamped after receipt but pins the same turn', () => { + // Runtime-sync and recovery entries stamp updatedAt with Date.now()/capturedAt, which is + // at or after main's receivedAt; the eviction must still land for those rows. + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'done', prompt: 'run', agentType: 'claude' } + }, + 'conn-1' + ) + const entry = server.getStatusSnapshot()[0]! + expect( + server.dropPersistedStatusEntry({ + paneKey: entry.paneKey, + receivedAt: entry.receivedAt + 5_000, + stateStartedAt: entry.stateStartedAt + }) + ).toBe(true) + + // A different turn never matches, whatever the receivedAt relationship. + const other = new AgentHookServer() + other.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'done', prompt: 'run', agentType: 'claude' } + }, + 'conn-1' + ) + const otherEntry = other.getStatusSnapshot()[0]! + expect( + other.dropPersistedStatusEntry({ + paneKey: otherEntry.paneKey, + receivedAt: otherEntry.receivedAt + 5_000, + stateStartedAt: otherEntry.stateStartedAt + 1 + }) + ).toBe(false) + }) + it('notifies pane-status-clear listener when pane teardown evicts a cached status', () => { const server = new AgentHookServer() const listener = vi.fn() diff --git a/src/main/agent-hooks/server/server-cleanup.ts b/src/main/agent-hooks/server/server-cleanup.ts index 42773d3a266..aaf7019d126 100644 --- a/src/main/agent-hooks/server/server-cleanup.ts +++ b/src/main/agent-hooks/server/server-cleanup.ts @@ -42,8 +42,14 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen const existing = this.state.lastStatusByPaneKey.get(resolvedPaneKey) as | EnrichedAgentHookEventPayload | undefined - // Why: renderer-enriched fields can diverge from the main cache; timing fields pin the exact event instance. - if (!existing || existing.receivedAt !== identity.receivedAt || existing.stateStartedAt !== identity.stateStartedAt) { + // Why: stateStartedAt pins the turn; the renderer's updatedAt is stamped at or after this + // receivedAt (runtime-sync and recovery paths use Date.now()/capturedAt), so a strictly + // newer cached event is the only replacement worth protecting. + if ( + !existing || + existing.stateStartedAt !== identity.stateStartedAt || + existing.receivedAt > identity.receivedAt + ) { return false } this.dropStatusEntry(resolvedPaneKey) 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 new file mode 100644 index 00000000000..7858b6a8f75 --- /dev/null +++ b/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts @@ -0,0 +1,72 @@ +import { homedir } from 'node:os' +import { describe, expect, it } from 'vitest' +import { getDefaultPersistedState } from '../../../shared/constants' +import { normalizeLoadedGlobalSettings } from './normalize-loaded-global-settings' +import { prepareLoadedTerminalSettings } from './prepare-loaded-terminal-settings' +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. +function normalizeLegacyProfile(overrides: Partial): PersistedState['settings'] { + const defaults = getDefaultPersistedState(homedir()) + const settings: Partial = { ...defaults.settings } + delete settings.showAgentsSidebar + delete settings.experimentalActivity + delete settings.experimentalAgentDashboardPopout + Object.assign(settings, overrides) + const parsed: PersistedState = { ...defaults, settings: settings as GlobalSettings } + const noop = (): void => {} + const terminal = prepareLoadedTerminalSettings(parsed, noop) + const profile = prepareLoadedProfileSettings(parsed, defaults, noop) + return normalizeLoadedGlobalSettings(parsed, terminal, profile) +} + +describe('showAgentsSidebar graduation migration', () => { + it('keeps the sidebar for Agents-view opt-ins regardless of the popout experiment', () => { + const normalized = normalizeLegacyProfile({ + experimentalActivity: true, + experimentalAgentDashboardPopout: false + }) + expect(normalized.showAgentsSidebar).toBe(true) + expect(normalized.agentsSidebarMigratedFromExperimental).toBe(true) + }) + + it('graduates either legacy opt-in to the sidebar', () => { + expect(normalizeLegacyProfile({ experimentalActivity: true }).showAgentsSidebar).toBe(true) + expect( + normalizeLegacyProfile({ experimentalAgentDashboardPopout: true }).showAgentsSidebar + ).toBe(true) + }) + + it('defaults profiles with no legacy signal to the sidebar', () => { + const normalized = normalizeLegacyProfile({}) + expect(normalized.showAgentsSidebar).toBe(true) + expect(normalized.agentsSidebarMigratedFromExperimental).toBe(false) + }) + + it('keeps an explicit popout opt-out hidden when the Agents view was never used', () => { + expect( + normalizeLegacyProfile({ experimentalAgentDashboardPopout: false }).showAgentsSidebar + ).toBe(false) + expect( + normalizeLegacyProfile({ + experimentalActivity: false, + experimentalAgentDashboardPopout: false + }).showAgentsSidebar + ).toBe(false) + }) + + it('preserves a stored showAgentsSidebar choice over legacy flags', () => { + expect( + normalizeLegacyProfile({ showAgentsSidebar: false, experimentalActivity: true }) + .showAgentsSidebar + ).toBe(false) + expect( + normalizeLegacyProfile({ + showAgentsSidebar: true, + experimentalAgentDashboardPopout: false + }).showAgentsSidebar + ).toBe(true) + }) +}) 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 2e40d1adaf7..228183db0a2 100644 --- a/src/main/persistence/loading-store/normalize-loaded-global-settings.ts +++ b/src/main/persistence/loading-store/normalize-loaded-global-settings.ts @@ -85,11 +85,13 @@ export function normalizeLoadedGlobalSettings( ...migratedTerminalTuiScrollSensitivity.settings, experimentalActivity: migratedExperimentalActivity, experimentalActivityDefaultedOffForAllUsers: true, - // The dashboard graduated from Experimental; keep explicit legacy opt-outs, - // while defaulting every profile to the new left-sidebar tab. + // 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. showAgentsSidebar: parsed.settings?.showAgentsSidebar ?? - parsed.settings?.experimentalAgentDashboardPopout !== false, + (parsed.settings?.experimentalActivity === true || + parsed.settings?.experimentalAgentDashboardPopout !== false), // Preserve the legacy opt-in before the experimental setting is normalized away. This // drives the migration-specific introduction copy without changing runtime behavior. agentsSidebarMigratedFromExperimental: diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index 7bfc55a77d1..b9325ea9d42 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -65,7 +65,6 @@ export default function ActivityPrototypePage(): React.JSX.Element { const { storeData, allThreads, - scopedThreads, selectedPaneKeyIsLive, effectiveSelectedPaneKey, visibleThreads, @@ -244,33 +243,34 @@ export default function ActivityPrototypePage(): React.JSX.Element { return () => window.removeEventListener('keydown', focusActivityFilter, { capture: true }) }, [activePortalTargetEl, inactivePortalTargetEl]) - // Why useMemo: rows are React.memo'd on these handlers; a fresh closure per render - // would defeat the bail-out and re-render every mounted row on unrelated updates. - const { - hasUnreadThreads, - markThreadRead, - markThreadUnread, - selectThread, - jumpToWorkspace, - markAllThreadsRead - } = useMemo( - () => - createActivityThreadActions({ - allThreads: scopedThreads, - acknowledgeAgents: storeData.acknowledgeAgents, - unacknowledgeAgents: storeData.unacknowledgeAgents, - setSelectedPaneKey - }), - [scopedThreads, storeData.acknowledgeAgents, storeData.unacknowledgeAgents] - ) + // Why a ref: rows are React.memo'd on these handlers; recreating them whenever the + // thread array identity changes (every status ping) would re-render every mounted row. + const visibleThreadsRef = useRef(visibleThreads) + useEffect(() => { + visibleThreadsRef.current = visibleThreads + }, [visibleThreads]) + const { markThreadRead, markThreadUnread, selectThread, jumpToWorkspace, markAllThreadsRead } = + useMemo( + () => + createActivityThreadActions({ + getVisibleThreads: () => visibleThreadsRef.current, + acknowledgeAgents: storeData.acknowledgeAgents, + unacknowledgeAgents: storeData.unacknowledgeAgents, + setSelectedPaneKey + }), + [storeData.acknowledgeAgents, storeData.unacknowledgeAgents] + ) + // Why visibleThreads: bulk actions and their enablement must match what the list + // shows — clearing/acking rows hidden by search, unread, or child filters would be silent. + const hasUnreadThreads = useMemo(() => visibleThreads.some((t) => t.unread), [visibleThreads]) const hasCompletedThreads = useMemo( - () => scopedThreads.some(isClearableActivityThread), - [scopedThreads] + () => visibleThreads.some(isClearableActivityThread), + [visibleThreads] ) const handleClearCompleted = useCallback(() => { - clearCompletedActivity(scopedThreads) - }, [scopedThreads]) + clearCompletedActivity(visibleThreadsRef.current) + }, []) const canJumpToWorkspace = hasActivityThreadWorkspace diff --git a/src/renderer/src/components/activity/activity-clear-completed.test.ts b/src/renderer/src/components/activity/activity-clear-completed.test.ts index c4847d582e3..f4b2e6c4cdd 100644 --- a/src/renderer/src/components/activity/activity-clear-completed.test.ts +++ b/src/renderer/src/components/activity/activity-clear-completed.test.ts @@ -55,6 +55,7 @@ vi.mock('sonner', () => ({ toast: toastSpy })) import { clearCompletedActivity, + flushPendingClearCompletedEvictions, isClearableActivityThread, planClearCompletedActivity } from './activity-clear-completed' @@ -137,6 +138,8 @@ describe('clearCompletedActivity', () => { }) afterEach(() => { + // Drain any eviction left pending by a test that never closed its toast. + flushPendingClearCompletedEvictions() vi.clearAllMocks() vi.unstubAllGlobals() }) @@ -270,4 +273,34 @@ describe('clearCompletedActivity', () => { expect(toastSpy).not.toHaveBeenCalled() expect(mockStore.applyActivityClearedAt).not.toHaveBeenCalled() }) + + it('pagehide flush evicts a clear whose undo toast is still open', () => { + clearCompletedActivity([doneThread]) + const drop = ( + window as unknown as { + api: { agentStatus: { dropPersisted: ReturnType } } + } + ).api.agentStatus.dropPersisted + expect(drop).not.toHaveBeenCalled() + + // Quit/reload path: the toast's close callbacks never fire. + flushPendingClearCompletedEvictions() + expect(drop).toHaveBeenCalledTimes(1) + + // The flushed eviction is consumed; later toast close must not double-drop. + lastToastOptions().onAutoClose() + expect(drop).toHaveBeenCalledTimes(1) + }) + + it('pagehide flush skips a clear that was undone', () => { + clearCompletedActivity([doneThread]) + lastToastOptions().action.onClick() + flushPendingClearCompletedEvictions() + const drop = ( + window as unknown as { + api: { agentStatus: { dropPersisted: ReturnType } } + } + ).api.agentStatus.dropPersisted + expect(drop).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/activity/activity-clear-completed.ts b/src/renderer/src/components/activity/activity-clear-completed.ts index 9a499a49d57..c6d425e1d2a 100644 --- a/src/renderer/src/components/activity/activity-clear-completed.ts +++ b/src/renderer/src/components/activity/activity-clear-completed.ts @@ -61,6 +61,19 @@ export function planClearCompletedActivity( return { cutoffPatch, restorePatch, retainedSnapshots, cacheIdentities, clearedThreadCount } } +// Deferred evictions whose undo toast is still open; flushed on pagehide because the toast's +// close callbacks never fire on quit/reload, which would let cleared rows replay next launch. +const pendingDiskEvictions = new Set<() => void>() +export function flushPendingClearCompletedEvictions(): void { + // Set iteration tolerates the self-delete each evict() performs. + for (const evict of pendingDiskEvictions) { + evict() + } +} +if (typeof window !== 'undefined') { + window.addEventListener('pagehide', flushPendingClearCompletedEvictions) +} + /** * Clear completed/interrupted activity threads with an undo window. * @@ -93,6 +106,7 @@ export function clearCompletedActivity(threads: readonly AgentPaneThread[]): boo let undone = false let dropped = false const dropRetainedFromDiskCache = (): void => { + pendingDiskEvictions.delete(dropRetainedFromDiskCache) if (undone || dropped) { return } @@ -101,6 +115,7 @@ export function clearCompletedActivity(threads: readonly AgentPaneThread[]): boo window.api?.agentStatus?.dropPersisted?.(identity) } } + pendingDiskEvictions.add(dropRetainedFromDiskCache) toast( plan.clearedThreadCount === 1 ? translate('auto.components.activity.clearCompleted.clearedOne', 'Cleared 1 completed agent') @@ -114,6 +129,7 @@ export function clearCompletedActivity(threads: readonly AgentPaneThread[]): boo label: translate('auto.components.activity.clearCompleted.undo', 'Undo'), onClick: () => { undone = true + pendingDiskEvictions.delete(dropRetainedFromDiskCache) const current = useAppStore.getState() const retainedByPaneKey = new Map( plan.retainedSnapshots.map((retained) => [retained.entry.paneKey, retained]) diff --git a/src/renderer/src/components/activity/activity-event-builder-sources.ts b/src/renderer/src/components/activity/activity-event-builder-sources.ts index 375c25e780b..1049ed95acf 100644 --- a/src/renderer/src/components/activity/activity-event-builder-sources.ts +++ b/src/renderer/src/components/activity/activity-event-builder-sources.ts @@ -58,6 +58,7 @@ export function appendUnsupportedAndRetainedEvents(context: { agentAlive: false, acknowledgedAt: args.acknowledgedAgentsByPaneKey[entry.paneKey] ?? 0, clearedAt: args.activityClearedAtByPaneKey?.[entry.paneKey] ?? 0, + migrationUnsupportedPtyId: unsupported.ptyId, liveState: 'blocked' }, cache, diff --git a/src/renderer/src/components/activity/activity-event-builder.host-ownership.test.ts b/src/renderer/src/components/activity/activity-event-builder.host-ownership.test.ts index 0a3dc04c247..51beb6cf1f9 100644 --- a/src/renderer/src/components/activity/activity-event-builder.host-ownership.test.ts +++ b/src/renderer/src/components/activity/activity-event-builder.host-ownership.test.ts @@ -142,6 +142,38 @@ describe('activity event host ownership', () => { expect(result.events[0]?.worktree.displayName).toBe('Docs folder') }) + it('carries migrationUnsupportedPtyId on events built for un-migratable panes', () => { + const worktree = makeWorktree() + const repo = makeRepo() + const tab = makeTab() + + const result = buildActivityEvents({ + agentStatusByPaneKey: {}, + retainedAgentsByPaneKey: {}, + migrationUnsupportedByPtyId: { + 'pty-1': { + ptyId: 'pty-1', + paneKey: PANE_KEY, + tabId: tab.id, + reason: 'legacy-numeric-pane-key', + source: 'local', + updatedAt: 1_000 + } + }, + tabsByWorktree: { [worktree.id]: [tab] }, + worktreeMap: new Map([[worktree.id, worktree]]), + repoMap: new Map([[repo.id, repo]]), + resolveWorktree: () => worktree, + acknowledgedAgentsByPaneKey: {}, + now: 3_000 + }) + + expect(result.events.length).toBeGreaterThan(0) + for (const event of result.events) { + expect(event.migrationUnsupportedPtyId).toBe('pty-1') + } + }) + it('uses the retained terminal handle to preserve runtime host ownership after teardown', () => { const localWorktree = makeWorktree() const runtimeWorktree = { diff --git a/src/renderer/src/components/activity/activity-prototype-page-exports.ts b/src/renderer/src/components/activity/activity-prototype-page-exports.ts index 795923f9efe..9d2801ffe5d 100644 --- a/src/renderer/src/components/activity/activity-prototype-page-exports.ts +++ b/src/renderer/src/components/activity/activity-prototype-page-exports.ts @@ -16,4 +16,4 @@ export { } from './activity-filter-focus-shortcut' export { ActivityThreadOptionsMenu, ThreadAgentStateIndicator } from './activity-thread-controls' export { useActivityTerminalPortalStatus } from './activity-terminal-portal-status' -export { isChildAgentEntry, isChildAgentThread } from './activity-thread-child-agent' +export { collectChildAgentPaneKeys } from './activity-thread-child-agent' diff --git a/src/renderer/src/components/activity/activity-scope-filter-controls.tsx b/src/renderer/src/components/activity/activity-scope-filter-controls.tsx index 5a2c38d30da..b5f06f4a61b 100644 --- a/src/renderer/src/components/activity/activity-scope-filter-controls.tsx +++ b/src/renderer/src/components/activity/activity-scope-filter-controls.tsx @@ -83,7 +83,7 @@ function ScopeFilterChip({ onClear: () => void }): React.JSX.Element { return ( - + {label} -
-
-

Now — identity chrome

-

- ORCA, the branch, “20 hours ago”, and Codex all speak before the task. Last activity is - hidden in compact mode, so this card has nothing useful on it. -

- -
-
-

Proposed — title first, meta at the bottom

-

- Task, then last activity. State, agent, and time sit on one footer row — time pinned - right. -

- -
-
-

- Primary: task title, then last activity.
- Footer: state, agent icon, workspace, and time — time at the bottom right. -

- - - diff --git a/src/renderer/src/components/activity/activity-thread-row.tsx b/src/renderer/src/components/activity/activity-thread-row.tsx index d6bb10f9c53..93d4d5ec18f 100644 --- a/src/renderer/src/components/activity/activity-thread-row.tsx +++ b/src/renderer/src/components/activity/activity-thread-row.tsx @@ -167,7 +167,7 @@ export const ActivityThreadRow = React.memo(function ActivityThreadRow({ onMarkRead(thread) }} onMouseDown={(event) => event.stopPropagation()} - className="flex size-5 shrink-0 cursor-pointer items-center justify-center rounded hover:bg-accent/80 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + className="flex size-3.5 shrink-0 cursor-pointer items-center justify-center rounded hover:bg-accent/80 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" aria-label={translate( 'auto.components.activity.ActivityPrototypePage.markThreadRead', 'Mark thread as read' 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 70584b52500..65a54dbc8c1 100644 --- a/src/renderer/src/components/activity/use-agent-pane-threads.ts +++ b/src/renderer/src/components/activity/use-agent-pane-threads.ts @@ -6,7 +6,7 @@ import type { AppState } from '@/store/types' import { getSettingsFocusedExecutionHostId } from '../../../../shared/execution-host' import { buildActivityEvents, createActivityEventBuildCache } from './activity-event-builder' import { buildAgentPaneThreads, createAgentPaneThreadReuseCache } from './activity-thread-builder' -import { isChildAgentThread } from './activity-thread-child-agent' +import { collectChildAgentPaneKeys } from './activity-thread-child-agent' import { filterThreadsByActivityScope, resolveActivityScopeRepoIds } from './activity-scope-filter' import { activityThreadMatchesSearchQuery, @@ -53,10 +53,10 @@ export function useAgentPaneThreads(args: { }): { storeData: AgentPaneThreadsStoreData allThreads: AgentPaneThread[] - /** Threads inside the persisted host/project scope; bulk actions must not affect rows outside it. */ - scopedThreads: AgentPaneThread[] selectedPaneKeyIsLive: boolean effectiveSelectedPaneKey: string | null + /** Threads shown after every active filter; bulk actions (mark all read, clear + * completed) operate on exactly this set so they never touch hidden rows. */ visibleThreads: AgentPaneThread[] visibleThreadGroups: ActivityThreadGroup[] /** Threads excluded by the persisted host/project scope — the chips row shows this so scope filtering is never silent. */ @@ -141,11 +141,7 @@ export function useAgentPaneThreads(args: { // Why scope runs before the per-view filters: its hidden count must mean "hidden by // the persisted host/project scope alone", not folded into unread/search misses. - const { - threads: scopeVisibleThreads, - matchingThreads: scopedThreads, - hiddenCount: scopeHiddenThreadCount - } = useMemo( + const { threads: scopeVisibleThreads, hiddenCount: scopeHiddenThreadCount } = useMemo( () => filterThreadsByActivityScope({ threads: allThreads, @@ -166,6 +162,11 @@ export function useAgentPaneThreads(args: { ] ) + // Why over allThreads (not the scoped list): child classification asks whether the + // parent pane still exists at all, and a scope filter hiding the parent must not + // reclassify its workers as orphans. + const childAgentPaneKeys = useMemo(() => collectChildAgentPaneKeys(allThreads), [allThreads]) + // Why deferred: filtering hundreds of threads is interruptible background work; the input // echoes the keystroke at full priority while the list catches up on the deferred value. const deferredQuery = useDeferredValue(query) @@ -185,7 +186,7 @@ export function useAgentPaneThreads(args: { // Why: child agents (e.g. dispatched orchestration workers) are hidden by default to keep top-level agent views focused on root tasks. if ( !showChildAgents && - isChildAgentThread(thread) && + childAgentPaneKeys.has(thread.paneKey) && thread.paneKey !== effectiveSelectedPaneKey ) { return false @@ -195,7 +196,14 @@ export function useAgentPaneThreads(args: { } return activityThreadMatchesSearchQuery({ thread, searchQuery: normalizedQuery }) }) - }, [scopeVisibleThreads, readFilter, deferredQuery, effectiveSelectedPaneKey, showChildAgents]) + }, [ + scopeVisibleThreads, + readFilter, + deferredQuery, + effectiveSelectedPaneKey, + showChildAgents, + childAgentPaneKeys + ]) const visibleThreadGroups = useMemo( () => buildActivityThreadGroups(visibleThreads, groupBy), @@ -205,7 +213,6 @@ export function useAgentPaneThreads(args: { return { storeData, allThreads, - scopedThreads, selectedPaneKeyIsLive, effectiveSelectedPaneKey, visibleThreads, diff --git a/src/renderer/src/components/sidebar/Sidebar.test.tsx b/src/renderer/src/components/sidebar/Sidebar.test.tsx index 2e9574f14c2..fffec754f0c 100644 --- a/src/renderer/src/components/sidebar/Sidebar.test.tsx +++ b/src/renderer/src/components/sidebar/Sidebar.test.tsx @@ -3,7 +3,7 @@ import type { CSSProperties, ReactNode } from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { tmpdir } from 'node:os' -import { cleanup, render, waitFor } from '@testing-library/react' +import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../../../shared/constants' import type { GlobalSettings } from '../../../../shared/global-settings-types' @@ -39,7 +39,24 @@ vi.mock('@/components/ui/tooltip', () => ({ })) vi.mock('./SidebarHeader', () => ({ - default: () =>
+ default: ({ + agentToolbar, + agentSearchRow + }: { + agentToolbar?: ReactNode + agentSearchRow?: ReactNode + }) => ( +
+ {agentToolbar} + {agentSearchRow} +
+ ) +})) + +vi.mock('./SidebarAgentsList', () => ({ + default: ({ query }: { query: string }) => ( +
+ ) })) vi.mock('./SidebarNav', () => ({ @@ -223,6 +240,31 @@ describe('Sidebar', () => { expect(fetchAllWorktrees).not.toHaveBeenCalled() }) + it('clears the agents search query when the search row closes', async () => { + setSidebarState(getDefaultSettings(tmpdir())) + mocks.state = { ...mocks.state, sidebarBody: 'agents' } + const view = render(sidebarElement()) + const agentsList = await view.findByTestId('sidebar-agents-list') + + const searchToggle = view.getAllByRole('button', { name: 'Search' })[0] + fireEvent.click(searchToggle) + const searchInput = view.getByPlaceholderText('Filter...') + fireEvent.change(searchInput, { target: { value: 'deploy' } }) + expect(agentsList.getAttribute('data-query')).toBe('deploy') + + // Escape hides the row; a lingering query would silently keep filtering the list. + fireEvent.keyDown(searchInput, { key: 'Escape' }) + expect(view.queryByPlaceholderText('Filter...')).toBeNull() + expect(agentsList.getAttribute('data-query')).toBe('') + + fireEvent.click(searchToggle) + fireEvent.change(view.getByPlaceholderText('Filter...'), { target: { value: 'again' } }) + expect(agentsList.getAttribute('data-query')).toBe('again') + fireEvent.click(searchToggle) + expect(view.queryByPlaceholderText('Filter...')).toBeNull() + expect(agentsList.getAttribute('data-query')).toBe('') + }) + it('closes the dashboard drawer when the Agents sidebar is disabled', async () => { setSidebarState({ ...getDefaultSettings(tmpdir()), showAgentsSidebar: false }) const setAgentDashboardDrawerOpen = vi.fn() diff --git a/src/renderer/src/components/sidebar/SidebarAgentsList.tsx b/src/renderer/src/components/sidebar/SidebarAgentsList.tsx index 204eac2c302..4561f7cf648 100644 --- a/src/renderer/src/components/sidebar/SidebarAgentsList.tsx +++ b/src/renderer/src/components/sidebar/SidebarAgentsList.tsx @@ -52,7 +52,6 @@ export default function SidebarAgentsList({ const { storeData, - scopedThreads, selectedPaneKeyIsLive, effectiveSelectedPaneKey, visibleThreads, @@ -66,33 +65,34 @@ export default function SidebarAgentsList({ } }, [selectedPaneKeyIsLive]) - // Why useMemo: rows are React.memo'd on these handlers; a fresh closure per render - // would defeat the bail-out and re-render every mounted row on unrelated updates. - const { - hasUnreadThreads, - markThreadRead, - markThreadUnread, - selectThread, - jumpToWorkspace, - markAllThreadsRead - } = useMemo( - () => - createActivityThreadActions({ - allThreads: scopedThreads, - acknowledgeAgents: storeData.acknowledgeAgents, - unacknowledgeAgents: storeData.unacknowledgeAgents, - setSelectedPaneKey - }), - [scopedThreads, storeData.acknowledgeAgents, storeData.unacknowledgeAgents] - ) + // Why a ref: rows are React.memo'd on these handlers; recreating them whenever the + // thread array identity changes (every status ping) would re-render every mounted row. + const visibleThreadsRef = useRef(visibleThreads) + useEffect(() => { + visibleThreadsRef.current = visibleThreads + }, [visibleThreads]) + const { markThreadRead, markThreadUnread, selectThread, jumpToWorkspace, markAllThreadsRead } = + useMemo( + () => + createActivityThreadActions({ + getVisibleThreads: () => visibleThreadsRef.current, + acknowledgeAgents: storeData.acknowledgeAgents, + unacknowledgeAgents: storeData.unacknowledgeAgents, + setSelectedPaneKey + }), + [storeData.acknowledgeAgents, storeData.unacknowledgeAgents] + ) + // Why visibleThreads: bulk actions and their enablement must match what the list + // shows — clearing/acking rows hidden by search, unread, or child filters would be silent. + const hasUnreadThreads = useMemo(() => visibleThreads.some((t) => t.unread), [visibleThreads]) const hasCompletedThreads = useMemo( - () => scopedThreads.some(isClearableActivityThread), - [scopedThreads] + () => visibleThreads.some(isClearableActivityThread), + [visibleThreads] ) const handleClearCompleted = useCallback(() => { - clearCompletedActivity(scopedThreads) - }, [scopedThreads]) + clearCompletedActivity(visibleThreadsRef.current) + }, []) const canJumpToWorkspace = hasActivityThreadWorkspace diff --git a/src/renderer/src/components/sidebar/SidebarHeader.test.tsx b/src/renderer/src/components/sidebar/SidebarHeader.test.tsx index 93603f99fc4..b33cc992b08 100644 --- a/src/renderer/src/components/sidebar/SidebarHeader.test.tsx +++ b/src/renderer/src/components/sidebar/SidebarHeader.test.tsx @@ -8,7 +8,8 @@ import SidebarHeader from './SidebarHeader' ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true const mocks = vi.hoisted(() => ({ - openWorkspaceCreationComposerWithTourHandoff: vi.fn() + openWorkspaceCreationComposerWithTourHandoff: vi.fn(), + unreadCount: { value: 0 } })) type MockState = { @@ -18,10 +19,14 @@ type MockState = { sidebarWidth: number setSidebarBody: (body: 'workspaces' | 'agents') => void openModal: (modal: string, data?: unknown) => void + updateSettings: (patch: Record) => void + openActivityPage: () => void activeContextualTourId: string | null settings?: { showAgentsSidebar?: boolean experimentalAgentDashboardPopout?: boolean + agentsSidebarIntroShown?: boolean + agentsSidebarMigratedFromExperimental?: boolean } } @@ -60,6 +65,19 @@ vi.mock('../contextual-tours/workspace-creation-tour-handoff', () => ({ openWorkspaceCreationComposerWithTourHandoff: mocks.openWorkspaceCreationComposerWithTourHandoff })) +vi.mock('@/components/activity/useActivityUnreadCount', () => ({ + useActivityUnreadCount: (enabled: boolean) => (enabled ? mocks.unreadCount.value : 0) +})) + +// Deterministic popover: expose the open flag instead of relying on radix portals. +vi.mock('@/components/ui/popover', () => ({ + Popover: ({ children, open }: { children: React.ReactNode; open?: boolean }) => ( +
{children}
+ ), + PopoverAnchor: ({ children }: { children: React.ReactNode }) => <>{children}, + PopoverContent: ({ children }: { children: React.ReactNode }) => <>{children} +})) + let container: HTMLDivElement let root: Root @@ -91,8 +109,13 @@ beforeEach(() => { sidebarWidth: 280, setSidebarBody: vi.fn(), openModal: vi.fn(), - activeContextualTourId: null + updateSettings: vi.fn(), + openActivityPage: vi.fn(), + activeContextualTourId: null, + // 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) @@ -252,6 +275,96 @@ 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(() => { + root.render() + }) + + expect(container.querySelector('[data-intro-open]')).toBeNull() + + const projectsTab = container.querySelector( + 'button[data-sidebar-section-title="projects"]' + ) + act(() => { + projectsTab?.click() + }) + expect(mockState.updateSettings).not.toHaveBeenCalled() + }) + + it('does not reset a persisted agents body before settings hydrate', () => { + mockState.settings = undefined + mockState.sidebarBody = 'agents' + act(() => { + root.render() + }) + + expect(mockState.setSidebarBody).not.toHaveBeenCalled() + }) + + it('opens the intro once hydrated and stamps it only while it is on screen', () => { + act(() => { + root.render() + }) + + expect(container.querySelector('[data-intro-open]')).toBeTruthy() + + const agentTab = container.querySelector( + 'button[data-sidebar-section-title="agents"]' + ) + act(() => { + agentTab?.click() + }) + expect(mockState.updateSettings).toHaveBeenCalledWith({ agentsSidebarIntroShown: true }) + }) + + it('never re-stamps the intro after it was acknowledged', () => { + mockState.settings = { showAgentsSidebar: true, agentsSidebarIntroShown: true } + act(() => { + root.render() + }) + + expect(container.querySelector('[data-intro-open]')).toBeNull() + + const agentTab = container.querySelector( + 'button[data-sidebar-section-title="agents"]' + ) + act(() => { + agentTab?.click() + }) + expect(mockState.updateSettings).not.toHaveBeenCalled() + }) + + it('opens the full Agents view from the expand button in agents mode', () => { + mockState.settings = { showAgentsSidebar: true, agentsSidebarIntroShown: true } + mockState.sidebarBody = 'agents' + act(() => { + root.render() + }) + + const expandButton = container.querySelector( + '[aria-label="Open full Agents view"]' + ) + expect(expandButton).toBeTruthy() + + act(() => { + expandButton?.click() + }) + expect(mockState.openActivityPage).toHaveBeenCalledTimes(1) + }) + it('switches to compact actions only below the wide-layout breakpoint', () => { mockState.sidebarWidth = 234 act(() => { diff --git a/src/renderer/src/components/sidebar/SidebarHeader.tsx b/src/renderer/src/components/sidebar/SidebarHeader.tsx index 6e6de09a335..b7dfdff2597 100644 --- a/src/renderer/src/components/sidebar/SidebarHeader.tsx +++ b/src/renderer/src/components/sidebar/SidebarHeader.tsx @@ -1,12 +1,15 @@ import React, { useEffect } from 'react' +import { Maximize2 } from 'lucide-react' import { useTranslation } from 'react-i18next' 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 { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' type SidebarHeaderProps = { onWorkspaceBoardMenuOpenChange: (open: boolean) => void @@ -37,8 +40,13 @@ const SidebarHeader = React.memo(function SidebarHeader({ const migratedFromExperimental = useAppStore( (s) => s.settings?.agentsSidebarMigratedFromExperimental === true ) + // Why: settings are null until hydration; deriving intro visibility from the + // null default would flash the popover open (and stamp it shown) every launch. + const settingsHydrated = useAppStore((s) => s.settings != null) + const openActivityPage = useAppStore((s) => s.openActivityPage) const agentsViewActive = showAgentsSidebar && sidebarBody === 'agents' - const introOpen = showAgentsSidebar && !agentsSidebarIntroShown + const agentsUnreadCount = useActivityUnreadCount(showAgentsSidebar, 'sidebar-badge') + const introOpen = settingsHydrated && showAgentsSidebar && !agentsSidebarIntroShown const acknowledgeIntro = React.useCallback(() => { void updateSettings?.({ agentsSidebarIntroShown: true }) }, [updateSettings]) @@ -48,10 +56,11 @@ const SidebarHeader = React.memo(function SidebarHeader({ const workspaceTabLabel = groupBy === 'none' ? spacesLabel : projectsLabel useEffect(() => { - if (!showAgentsSidebar && sidebarBody === 'agents') { + // Wait for hydration: settings null must not clobber a persisted 'agents' body. + if (settingsHydrated && !showAgentsSidebar && sidebarBody === 'agents') { setSidebarBody?.('workspaces') } - }, [setSidebarBody, showAgentsSidebar, sidebarBody]) + }, [setSidebarBody, settingsHydrated, showAgentsSidebar, sidebarBody]) return ( <> @@ -70,7 +79,10 @@ const SidebarHeader = React.memo(function SidebarHeader({ ariaLabel={translate('auto.components.sidebar.SidebarHeader.views', 'Sidebar view')} value={agentsViewActive ? 'agents' : 'workspaces'} onSelect={(value) => { - acknowledgeIntro() + // Only stamp the intro as seen when it is actually on screen. + if (introOpen) { + acknowledgeIntro() + } setSidebarBody?.(value as 'workspaces' | 'agents') }} options={[ @@ -85,7 +97,8 @@ const SidebarHeader = React.memo(function SidebarHeader({ { value: 'agents' as const, label: translate('dashboard.sidebar.label', 'Agents'), - sectionTitle: 'agents' as const + sectionTitle: 'agents' as const, + badgeCount: agentsUnreadCount } ] : []) @@ -134,7 +147,34 @@ const SidebarHeader = React.memo(function SidebarHeader({
- {agentsViewActive ?
{agentToolbar}
: null} + {agentsViewActive ? ( +
+ + + + + + {translate( + 'auto.components.sidebar.SidebarHeader.openFullAgentsView', + 'Open full Agents view' + )} + + + {agentToolbar} +
+ ) : null} {!agentsViewActive ? ( ) : null} diff --git a/src/renderer/src/components/sidebar/agent-dashboard-sidebar-visibility.test.ts b/src/renderer/src/components/sidebar/agent-dashboard-sidebar-visibility.test.ts new file mode 100644 index 00000000000..4ac87944dea --- /dev/null +++ b/src/renderer/src/components/sidebar/agent-dashboard-sidebar-visibility.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { shouldShowAgentDashboardSidebarButton } from './agent-dashboard-sidebar-visibility' + +describe('shouldShowAgentDashboardSidebarButton', () => { + it('hides while settings are not yet hydrated', () => { + expect(shouldShowAgentDashboardSidebarButton(null)).toBe(false) + expect(shouldShowAgentDashboardSidebarButton(undefined)).toBe(false) + }) + + it('honors the explicit setting', () => { + expect(shouldShowAgentDashboardSidebarButton({ showAgentsSidebar: true })).toBe(true) + expect(shouldShowAgentDashboardSidebarButton({ showAgentsSidebar: false })).toBe(false) + expect( + shouldShowAgentDashboardSidebarButton({ + showAgentsSidebar: false, + experimentalAgentDashboardPopout: true + }) + ).toBe(false) + }) + + it('falls back to the legacy popout flag, then defaults on', () => { + expect(shouldShowAgentDashboardSidebarButton({ experimentalAgentDashboardPopout: true })).toBe( + true + ) + expect(shouldShowAgentDashboardSidebarButton({ experimentalAgentDashboardPopout: false })).toBe( + false + ) + expect(shouldShowAgentDashboardSidebarButton({})).toBe(true) + }) +}) diff --git a/src/renderer/src/components/sidebar/agent-dashboard-sidebar-visibility.ts b/src/renderer/src/components/sidebar/agent-dashboard-sidebar-visibility.ts index 41e0735a96e..2f42fdc4925 100644 --- a/src/renderer/src/components/sidebar/agent-dashboard-sidebar-visibility.ts +++ b/src/renderer/src/components/sidebar/agent-dashboard-sidebar-visibility.ts @@ -6,5 +6,9 @@ export function shouldShowAgentDashboardSidebarButton( | null | undefined ): boolean { - return settings?.showAgentsSidebar ?? settings?.experimentalAgentDashboardPopout ?? true + // Null means settings not yet hydrated; hide so opted-out profiles don't flash Agents UI at startup. + if (!settings) { + return false + } + return settings.showAgentsSidebar ?? settings.experimentalAgentDashboardPopout ?? true } diff --git a/src/renderer/src/components/sidebar/index.tsx b/src/renderer/src/components/sidebar/index.tsx index 66bf3b03857..66845be84d2 100644 --- a/src/renderer/src/components/sidebar/index.tsx +++ b/src/renderer/src/components/sidebar/index.tsx @@ -65,6 +65,11 @@ function Sidebar({ const [agentGroupBy, setAgentGroupBy] = React.useState('status') const [agentQuery, setAgentQuery] = React.useState('') const [agentSearchOpen, setAgentSearchOpen] = React.useState(false) + // Why clear on close: the hidden input's query would keep filtering the list with no visible indicator. + const closeAgentSearch = React.useCallback(() => { + setAgentSearchOpen(false) + setAgentQuery('') + }, []) const [agentOptionsTarget, setAgentOptionsTarget] = React.useState(null) const agentsScrollTopRef = React.useRef(0) const fetchAllWorktrees = useAppStore((s) => s.fetchAllWorktrees) @@ -161,7 +166,13 @@ function Sidebar({ 'Search' )} aria-pressed={agentSearchOpen} - onClick={() => setAgentSearchOpen((open) => !open)} + onClick={() => { + if (agentSearchOpen) { + closeAgentSearch() + } else { + setAgentSearchOpen(true) + } + }} > @@ -212,7 +223,7 @@ function Sidebar({ onChange={(event) => setAgentQuery(event.target.value)} onKeyDown={(event) => { if (event.key === 'Escape') { - setAgentSearchOpen(false) + closeAgentSearch() } }} placeholder={translate( diff --git a/src/renderer/src/components/sidebar/sidebar-view-toggle.test.tsx b/src/renderer/src/components/sidebar/sidebar-view-toggle.test.tsx index 0f0eb90b326..973b51cfed2 100644 --- a/src/renderer/src/components/sidebar/sidebar-view-toggle.test.tsx +++ b/src/renderer/src/components/sidebar/sidebar-view-toggle.test.tsx @@ -55,4 +55,25 @@ describe('SidebarViewToggle', () => { expect(visibleLabel?.className).toContain('whitespace-nowrap') expect(visibleLabel?.className.includes('truncate')).toBe(false) }) + + it('renders the unread badge only for a positive badgeCount', () => { + act(() => { + root.render( + undefined} + options={[ + { value: 'workspaces', label: 'Projects', sectionTitle: 'projects', badgeCount: 0 }, + { value: 'agents', label: 'Agents', sectionTitle: 'agents', badgeCount: 5 } + ]} + /> + ) + }) + + const agentsTab = container.querySelector('[data-sidebar-section-title="agents"]') + expect(agentsTab?.textContent).toContain('5') + const projectsTab = container.querySelector('[data-sidebar-section-title="projects"]') + expect(projectsTab?.textContent).toBe('ProjectsProjects') + }) }) diff --git a/src/renderer/src/components/sidebar/sidebar-view-toggle.tsx b/src/renderer/src/components/sidebar/sidebar-view-toggle.tsx index 91341131347..2f9fa3c6361 100644 --- a/src/renderer/src/components/sidebar/sidebar-view-toggle.tsx +++ b/src/renderer/src/components/sidebar/sidebar-view-toggle.tsx @@ -7,6 +7,8 @@ type SidebarViewToggleOption = { /** Every label this slot can ever show; reserves width so switching never resizes the tab. */ widthLabels?: readonly string[] sectionTitle?: string + /** Unread count rendered after the label; hidden at 0. */ + badgeCount?: number } type SidebarViewToggleProps = { @@ -60,7 +62,14 @@ export function SidebarViewToggle({ {widthLabel} ))} - {option.label} + + {option.label} + {option.badgeCount ? ( + + {option.badgeCount} + + ) : null} + ) })} diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 18e01106495..bb22f5f6086 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -265,6 +265,41 @@ export function setVisibleWorktreeShortcutTargets( * recomputes the order the sidebar *would* render from the same row pipeline, * so a closed sidebar numbers workspaces the same way an open one does (#9497). */ +export function buildVisibleWorktreeOptionsFromState( + state: ReturnType, + repoMap: Map +): VisibleWorktreeOptions { + return { + filterRepoIds: state.filterRepoIds, + showSleepingWorkspaces: state.showSleepingWorkspaces, + tabsByWorktree: state.tabsByWorktree, + ptyIdsByTabId: state.ptyIdsByTabId, + browserTabsByWorktree: state.browserTabsByWorktree, + worktreeIdsWithLiveAgent: getWorktreeIdsWithLiveAgent( + state.agentStatusByPaneKey, + state.tabsByWorktree, + Date.now() + ), + hideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces: state.hideAutomationGeneratedWorkspaces, + hideCliCreatedWorkspaces: state.hideCliCreatedWorkspaces, + hideDetachedHeadWorkspaces: state.hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices: state.hideWorkspacesFromOtherDevices, + pairedDeviceIdsByEnvironment: state.hideWorkspacesFromOtherDevices + ? getPairedDeviceIdsByEnvironment( + state.runtimeEnvironments, + state.runtimeStatusByEnvironmentId + ) + : EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, + alwaysShowDefaultBranchWorkspace: state.alwaysShowDefaultBranchWorkspace, + repoMap, + workspaceHostScope: state.workspaceHostScope, + visibleWorkspaceHostIds: state.visibleWorkspaceHostIds, + defaultHostId: getSettingsFocusedExecutionHostId(state.settings), + worktreeLineageById: state.worktreeLineageById + } +} + export function getVisibleWorktreeIds(): string[] { // Prefer the published IDs that mirror the rendered sidebar order. if (_publishedVisibleIds) { @@ -299,35 +334,11 @@ export function getVisibleWorktreeIds(): string[] { sortedIds = sorted.map((w) => w.id) } - const visibleIds = computeVisibleWorktreeIds(state.worktreesByRepo, sortedIds, { - filterRepoIds: state.filterRepoIds, - showSleepingWorkspaces: state.showSleepingWorkspaces, - tabsByWorktree: state.tabsByWorktree, - ptyIdsByTabId: state.ptyIdsByTabId, - browserTabsByWorktree: state.browserTabsByWorktree, - worktreeIdsWithLiveAgent: getWorktreeIdsWithLiveAgent( - state.agentStatusByPaneKey, - state.tabsByWorktree, - Date.now() - ), - hideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace, - hideAutomationGeneratedWorkspaces: state.hideAutomationGeneratedWorkspaces, - hideCliCreatedWorkspaces: state.hideCliCreatedWorkspaces, - hideDetachedHeadWorkspaces: state.hideDetachedHeadWorkspaces, - hideWorkspacesFromOtherDevices: state.hideWorkspacesFromOtherDevices, - pairedDeviceIdsByEnvironment: state.hideWorkspacesFromOtherDevices - ? getPairedDeviceIdsByEnvironment( - state.runtimeEnvironments, - state.runtimeStatusByEnvironmentId - ) - : EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, - alwaysShowDefaultBranchWorkspace: state.alwaysShowDefaultBranchWorkspace, - repoMap, - workspaceHostScope: state.workspaceHostScope, - visibleWorkspaceHostIds: state.visibleWorkspaceHostIds, - defaultHostId: getSettingsFocusedExecutionHostId(state.settings), - worktreeLineageById: state.worktreeLineageById - }) + const visibleIds = computeVisibleWorktreeIds( + state.worktreesByRepo, + sortedIds, + buildVisibleWorktreeOptionsFromState(state, repoMap) + ) const visibleIdRank = new Map(visibleIds.map((id, index) => [id, index])) const visibleHostIds = getVisibleWorkspaceHostIdSet(state) diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.stable-message.test.tsx b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.stable-message.test.tsx new file mode 100644 index 00000000000..c9510cfef4f --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.stable-message.test.tsx @@ -0,0 +1,112 @@ +/** @vitest-environment happy-dom */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' +import { TooltipProvider } from '@/components/ui/tooltip' +import { CompactAgentRow } from './worktree-card-compact-agent-row' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +vi.mock('@/components/dashboard/use-agent-row-conversation-name', () => ({ + useAgentRowConversationName: () => null +})) + +vi.mock('./CacheTimer', () => ({ + default: () => null, + usePromptCacheCountdownForPane: () => null +})) + +function makeAgent({ + stateStartedAt, + lastAssistantMessage, + state = 'working' +}: { + stateStartedAt: number + lastAssistantMessage?: string + state?: string +}): DashboardAgentRowData { + return { + paneKey: 'tab-1:leaf-1', + tab: { id: 'tab-1' }, + agentType: 'claude', + state, + startedAt: 500, + entry: { + prompt: 'do the task', + state, + stateStartedAt, + lastAssistantMessage, + paneKey: 'tab-1:leaf-1', + updatedAt: stateStartedAt + } + } as unknown as DashboardAgentRowData +} + +let root: Root | undefined + +afterEach(() => { + act(() => root?.unmount()) + document.body.replaceChildren() +}) + +function renderRow(agent: DashboardAgentRowData): HTMLElement { + const container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => { + root!.render( + + {}} /> + + ) + }) + return container +} + +function rerenderRow(agent: DashboardAgentRowData): void { + act(() => { + root!.render( + + {}} /> + + ) + }) +} + +describe('CompactAgentRow stable assistant message', () => { + it('holds the last assistant line when a same-turn ping omits it', () => { + const container = renderRow( + makeAgent({ stateStartedAt: 1000, lastAssistantMessage: 'First reply' }) + ) + expect(container.textContent).toContain('First reply') + + rerenderRow(makeAgent({ stateStartedAt: 1000 })) + expect(container.textContent).toContain('First reply') + }) + + it('drops the held line when a new turn starts', () => { + const container = renderRow( + makeAgent({ stateStartedAt: 1000, lastAssistantMessage: 'First reply' }) + ) + rerenderRow(makeAgent({ stateStartedAt: 3000 })) + expect(container.textContent).not.toContain('First reply') + }) + + it('never holds across pings for entries without a turn identity (stateStartedAt 0)', () => { + const container = renderRow(makeAgent({ stateStartedAt: 0, lastAssistantMessage: 'Turn one' })) + expect(container.textContent).toContain('Turn one') + + rerenderRow(makeAgent({ stateStartedAt: 0 })) + expect(container.textContent).not.toContain('Turn one') + }) + + it('drops the held line when the agent leaves working', () => { + const container = renderRow( + makeAgent({ stateStartedAt: 1000, lastAssistantMessage: 'First reply' }) + ) + rerenderRow(makeAgent({ stateStartedAt: 1000, state: 'done' })) + rerenderRow(makeAgent({ stateStartedAt: 1000, state: 'working' })) + expect(container.textContent).not.toContain('First reply') + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx index d2fdfac89d5..ece174d4cd6 100644 --- a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useRef } from 'react' +import React, { useCallback, useState } from 'react' import { ChevronRight } from 'lucide-react' import { AgentStateDot, agentStateLabel } from '@/components/AgentStateDot' import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' @@ -13,22 +13,7 @@ import { agentNoUpdateLabel } from '@/lib/agent-row-decay-state' import { useAgentRowConversationName } from '@/components/dashboard/use-agent-row-conversation-name' import { lastEnteredDoneAt } from '@/components/dashboard/agent-finished-timestamp' import CacheTimer, { usePromptCacheCountdownForPane } from './CacheTimer' - -function formatShortTimeAgo(ts: number, now: number): string { - const delta = now - ts - if (delta < 60_000) { - return 'now' - } - const minutes = Math.floor(delta / 60_000) - if (minutes < 60) { - return `${minutes}m` - } - const hours = Math.floor(minutes / 60) - if (hours < 24) { - return `${hours}h` - } - return `${Math.floor(hours / 24)}d` -} +import { formatShortTimeAgo } from '@/lib/short-time-ago' function getCompactAgentPrimary( agent: DashboardAgentRowData, @@ -134,21 +119,22 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({ const primary = getCompactAgentPrimary(agent, conversationName) const isLineageChild = agent.lineage?.depth === 1 // Keep a live row's last assistant line stable while status/tool payloads - // briefly omit the hook-only field between updates. - const lastAssistantMessageRef = useRef<{ turn: number; message: string } | null>(null) - const turn = agent.entry.stateStartedAt ?? 0 + // briefly omit the hook-only field between updates. State (not a ref) so a + // discarded concurrent render can't pin an uncommitted message; a zero + // stateStartedAt has no per-turn identity, so those rows never cache. + const [heldMessage, setHeldMessage] = useState<{ turn: number; message: string } | null>(null) + const turn = agent.entry.stateStartedAt const currentMessage = agent.entry.lastAssistantMessage?.trim() ?? '' - if (agent.state === 'working' && currentMessage) { - lastAssistantMessageRef.current = { turn, message: currentMessage } - } else if (agent.state !== 'working') { - lastAssistantMessageRef.current = null - } else if (lastAssistantMessageRef.current?.turn !== turn) { - lastAssistantMessageRef.current = null + const turnHoldable = agent.state === 'working' && turn > 0 + if (turnHoldable && currentMessage) { + if (heldMessage?.turn !== turn || heldMessage.message !== currentMessage) { + setHeldMessage({ turn, message: currentMessage }) + } + } else if (heldMessage !== null && (!turnHoldable || heldMessage.turn !== turn)) { + setHeldMessage(null) } const stableMessage = - agent.state === 'working' && !currentMessage - ? lastAssistantMessageRef.current?.message - : undefined + turnHoldable && !currentMessage && heldMessage?.turn === turn ? heldMessage.message : undefined const secondary = getCompactAgentSecondary(agent, now, stableMessage) // Why: sidebar truncation must preserve the passive-vs-active distinction. const leadingText = dotState === 'monitoring' ? secondary : primary diff --git a/src/renderer/src/components/sidebar/worktree-filter-visibility.ts b/src/renderer/src/components/sidebar/worktree-filter-visibility.ts new file mode 100644 index 00000000000..1d34f4d714e --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-filter-visibility.ts @@ -0,0 +1,21 @@ +import { useAppStore } from '@/store' +import { getRepoMapFromState } from '@/store/selectors' +import { + buildVisibleWorktreeOptionsFromState, + computeVisibleWorktreeIds +} from './visible-worktrees' + +/** + * Filter-only visibility for one worktree id: runs the sidebar filter pipeline + * without collapse elision or rendered order, so a target inside a collapsed + * group is not misreported as hidden by filters. + */ +export function worktreePassesSidebarFilters(worktreeId: string): boolean { + const state = useAppStore.getState() + const repoMap = getRepoMapFromState(state) + return computeVisibleWorktreeIds( + state.worktreesByRepo, + [], + buildVisibleWorktreeOptionsFromState(state, repoMap) + ).includes(worktreeId) +} diff --git a/src/renderer/src/components/terminal-pane/stale-agent-row.ts b/src/renderer/src/components/terminal-pane/stale-agent-row.ts index c08f176691e..b781f76940d 100644 --- a/src/renderer/src/components/terminal-pane/stale-agent-row.ts +++ b/src/renderer/src/components/terminal-pane/stale-agent-row.ts @@ -7,7 +7,7 @@ export function dismissStaleAgentRowByKey(paneKey: string): void { const store = useAppStore.getState() const liveExisted = paneKey in store.agentStatusByPaneKey const retainedExisted = paneKey in store.retainedAgentsByPaneKey - store.dropAgentStatus(paneKey) + store.dropAgentStatus(paneKey, { paneRemoved: true }) store.dismissRetainedAgent(paneKey) if (liveExisted || retainedExisted) { toast.info( diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts index 83f2e565c92..886c19fdf0d 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts @@ -47,7 +47,7 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr const leafId = manager.getLeafId(paneId) if (leafId) { useAppStore.getState().setCacheTimerStartedAt(makePaneKey(tabId, leafId), null) - useAppStore.getState().dropAgentStatus(makePaneKey(tabId, leafId)) + useAppStore.getState().dropAgentStatus(makePaneKey(tabId, leafId), { paneRemoved: true }) } setTerminalErrorsByPaneId((current) => clearPaneTerminalError(current, paneId)) if (leafId) { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 39f7155a8c5..10cff2e0fe4 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -5247,7 +5247,8 @@ "ca6f729da2": "New workspace ({{value0}})", "a30e34eb5c": "Close workspace board", "views": "Sidebar view", - "moreActions": "More workspace actions" + "moreActions": "More workspace actions", + "openFullAgentsView": "Open full Agents view" }, "SidebarNav": { "80611a8b10": "Search", @@ -17274,5 +17275,19 @@ "unlinkedPr": { "status": "PR #{{number}} unlinked" } + }, + "agentsSidebarIntro": { + "migrated": { + "title": "Agents are easier to find", + "description": "Your Agents view is now a dedicated sidebar tab. Your activity and filters are preserved.", + "dismiss": "Got it", + "action": "Open Agents" + }, + "new": { + "title": "Meet your Agents tab", + "description": "See what your agents are working on, what is done, and where you need to step in.", + "dismiss": "Maybe later", + "action": "Try Agents" + } } } diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index c1dc87fc51d..0a2af0c8fe7 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4410,7 +4410,10 @@ "5c9c7c16aa": "Agregar un proyecto para crear espacios de trabajo", "ca6f729da2": "Nuevo espacio de trabajo ({{value0}})", "a30e34eb5c": "Cerrar tablero del espacio de trabajo", - "25a95899c9": "Agregar proyecto" + "25a95899c9": "Agregar proyecto", + "openFullAgentsView": "Abrir vista completa de Agentes", + "spaces": "Espacios", + "views": "Vista de la barra lateral" }, "SidebarNav": { "80611a8b10": "Buscar", @@ -14144,7 +14147,7 @@ "b29191b3e0": "worktree", "8c3b621ddf": "Proyecto", "4a3986b200": "Estado", - "770d458144": "Agrupar actividad del agente por", + "770d458144": "Agrupar por", "795cbf26e2": "Filtrar...", "4616ea39fd": "Ir al workspace", "markThreadRead": "Marcar hilo como leído", @@ -14839,5 +14842,19 @@ "unknown": "Restart Orca to try again." } } + }, + "agentsSidebarIntro": { + "migrated": { + "title": "Los agentes son más fáciles de encontrar", + "description": "Tu vista de Agentes ahora es una pestaña dedicada de la barra lateral. Tu actividad y filtros se conservan.", + "dismiss": "Entendido", + "action": "Abrir Agentes" + }, + "new": { + "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" + } } } diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 9904a9493c0..672bc8df225 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4391,7 +4391,10 @@ "5c9c7c16aa": "プロジェクトを追加してワークスペースを作成する", "ca6f729da2": "新規ワークスペース ({{value0}})", "a30e34eb5c": "ワークスペースボードを閉じる", - "25a95899c9": "プロジェクトを追加" + "25a95899c9": "プロジェクトを追加", + "openFullAgentsView": "Agent の全画面ビューを開く", + "spaces": "スペース", + "views": "サイドバービュー" }, "SidebarNav": { "80611a8b10": "検索", @@ -14144,7 +14147,7 @@ "b29191b3e0": "ワークツリー", "8c3b621ddf": "プロジェクト", "4a3986b200": "状態", - "770d458144": "Agent のアクティビティをグループ化する", + "770d458144": "グループ化", "795cbf26e2": "フィルター…", "4616ea39fd": "ワークスペースにジャンプ", "markThreadRead": "スレッドを既読としてマーク", @@ -14839,5 +14842,19 @@ "unknown": "Orca を再起動してから、もう一度お試しください。" } } + }, + "agentsSidebarIntro": { + "migrated": { + "title": "Agent が見つけやすくなりました", + "description": "Agent ビューはサイドバーの専用タブになりました。アクティビティとフィルターはそのまま引き継がれます。", + "dismiss": "OK", + "action": "Agent を開く" + }, + "new": { + "title": "Agent タブのご紹介", + "description": "Agent が何に取り組んでいるか、何が完了したか、どこで対応が必要かを確認できます。", + "dismiss": "後で", + "action": "Agent を試す" + } } } diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 58b0fe7c6cc..13c0e7d026e 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3912,7 +3912,9 @@ "searchLinks": "링크 검색", "deleteSkills": "스킬 삭제…" }, - "SkillShareSelectionControls": { "01c5a15e02": "스킬 공유" }, + "SkillShareSelectionControls": { + "01c5a15e02": "스킬 공유" + }, "SkillRow": { "updatedUnknown": "날짜 없음", "pathCopied": "경로 복사됨", @@ -3922,13 +3924,17 @@ "viewDetails": "세부 정보 보기", "deleteSkill": "삭제…" }, - "SkillsList": { "listLabel": "스킬" }, + "SkillsList": { + "listLabel": "스킬" + }, "sourceStatus": { "missing": "폴더를 찾을 수 없음", "remoteRepo": "원격 리포지토리 — 검색 안 됨", "unavailable": "검색 안 됨" }, - "sources": { "heading": "스킬 폴더" }, + "sources": { + "heading": "스킬 폴더" + }, "sourceKind": { "home": "홈", "workspace": "워크스페이스", @@ -3950,7 +3956,10 @@ "linkOne": "링크 {{count}}개", "linkOther": "링크 {{count}}개" }, - "filter": { "allAgents": "모든 에이전트", "sharedAgent": "공유됨 (.agents)" }, + "filter": { + "allAgents": "모든 에이전트", + "sharedAgent": "공유됨 (.agents)" + }, "SkillsSelectionHeader": { "exit": "선택 나가기", "exitTooltip": "선택 나가기 · Esc", @@ -3959,7 +3968,11 @@ "clear": "지우기", "deleteTitle": "삭제할 스킬 선택" }, - "SkillDetailDialog": { "agents": "에이전트", "updated": "업데이트됨", "copy": "복사" }, + "SkillDetailDialog": { + "agents": "에이전트", + "updated": "업데이트됨", + "copy": "복사" + }, "SkillFreshnessNudge": { "titleOne": "설치된 Orca 스킬이 오래되었습니다", "titleMany": "설치된 Orca 스킬 {{value0}}개가 오래되었습니다", @@ -4383,7 +4396,10 @@ "5c9c7c16aa": "워크스페이스를 만들려면 프로젝트를 추가하세요.", "ca6f729da2": "새 워크스페이스({{value0}})", "a30e34eb5c": "워크스페이스 보드 닫기", - "25a95899c9": "프로젝트 추가" + "25a95899c9": "프로젝트 추가", + "openFullAgentsView": "에이전트 전체 보기 열기", + "spaces": "스페이스", + "views": "사이드바 보기" }, "SidebarNav": { "80611a8b10": "검색", @@ -14187,7 +14203,7 @@ "b29191b3e0": "워크트리", "8c3b621ddf": "프로젝트", "4a3986b200": "상태", - "770d458144": "agent 활동 그룹화 기준", + "770d458144": "그룹화 기준", "795cbf26e2": "필터...", "4616ea39fd": "워크스페이스로 이동", "markThreadRead": "스레드를 읽은 것으로 표시", @@ -14943,5 +14959,19 @@ "unknown": "Restart Orca to try again." } } + }, + "agentsSidebarIntro": { + "migrated": { + "title": "에이전트를 더 쉽게 찾을 수 있습니다", + "description": "에이전트 보기가 이제 사이드바 전용 탭이 되었습니다. 활동과 필터는 그대로 유지됩니다.", + "dismiss": "확인", + "action": "에이전트 열기" + }, + "new": { + "title": "에이전트 탭을 만나보세요", + "description": "에이전트가 무엇을 작업 중인지, 무엇이 완료되었는지, 어디에 개입이 필요한지 확인하세요.", + "dismiss": "나중에", + "action": "에이전트 사용해 보기" + } } } diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index c4ca9bc34db..ace5abf3b05 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3922,7 +3922,9 @@ "searchLinks": "搜索链接", "deleteSkills": "删除技能…" }, - "SkillShareSelectionControls": { "01c5a15e02": "共享技能" }, + "SkillShareSelectionControls": { + "01c5a15e02": "共享技能" + }, "SkillRow": { "updatedUnknown": "无日期", "pathCopied": "路径已复制", @@ -3932,13 +3934,17 @@ "viewDetails": "查看详情", "deleteSkill": "删除…" }, - "SkillsList": { "listLabel": "技能" }, + "SkillsList": { + "listLabel": "技能" + }, "sourceStatus": { "missing": "未找到文件夹", "remoteRepo": "远程仓库 — 未扫描", "unavailable": "未扫描" }, - "sources": { "heading": "技能文件夹" }, + "sources": { + "heading": "技能文件夹" + }, "sourceKind": { "home": "主目录", "workspace": "工作区", @@ -3968,7 +3974,10 @@ "deleteLinkOne": "{{count}} 个链接", "deleteLinkOther": "{{count}} 个链接" }, - "filter": { "allAgents": "所有 Agent", "sharedAgent": "共享 (.agents)" }, + "filter": { + "allAgents": "所有 Agent", + "sharedAgent": "共享 (.agents)" + }, "SkillsSelectionHeader": { "exit": "退出选择", "exitTooltip": "退出选择 · Esc", @@ -3977,7 +3986,11 @@ "clear": "清除", "deleteTitle": "选择要删除的技能" }, - "SkillDetailDialog": { "agents": "Agent", "updated": "已更新", "copy": "复制" }, + "SkillDetailDialog": { + "agents": "Agent", + "updated": "已更新", + "copy": "复制" + }, "SkillFreshnessNudge": { "titleOne": "已安装的 Orca 技能已过期", "titleMany": "{{value0}} 个已安装的 Orca 技能已过期", @@ -4426,7 +4439,10 @@ "5c9c7c16aa": "添加项目以创建工作区", "ca6f729da2": "新工作区 ({{value0}})", "a30e34eb5c": "关闭工作区板", - "25a95899c9": "添加项目" + "25a95899c9": "添加项目", + "openFullAgentsView": "打开智能体完整视图", + "spaces": "空间", + "views": "侧边栏视图" }, "SidebarNav": { "80611a8b10": "搜索", @@ -14187,7 +14203,7 @@ "b29191b3e0": "工作树", "8c3b621ddf": "项目", "4a3986b200": "状态", - "770d458144": "对智能体活动进行分组", + "770d458144": "分组方式", "795cbf26e2": "筛选...", "4616ea39fd": "跳转到工作区", "markThreadRead": "将话题标记为已读", @@ -14943,5 +14959,19 @@ "unknown": "请重启 Orca 以重试。" } } + }, + "agentsSidebarIntro": { + "migrated": { + "title": "智能体更容易找到了", + "description": "智能体视图现在是侧边栏的专用标签页。你的活动和筛选条件都会保留。", + "dismiss": "知道了", + "action": "打开智能体" + }, + "new": { + "title": "认识你的智能体标签页", + "description": "查看智能体正在做什么、哪些已完成,以及哪些需要你介入。", + "dismiss": "以后再说", + "action": "试用智能体" + } } } diff --git a/src/renderer/src/lib/short-time-ago.ts b/src/renderer/src/lib/short-time-ago.ts new file mode 100644 index 00000000000..731f9cbc136 --- /dev/null +++ b/src/renderer/src/lib/short-time-ago.ts @@ -0,0 +1,16 @@ +/** Compact "now / 5m / 3h / 2d" age label shared by agent rows and activity threads. */ +export function formatShortTimeAgo(ts: number, now = Date.now()): string { + const delta = now - ts + if (delta < 60_000) { + return 'now' + } + const minutes = Math.floor(delta / 60_000) + if (minutes < 60) { + return `${minutes}m` + } + const hours = Math.floor(minutes / 60) + if (hours < 24) { + return `${hours}h` + } + return `${Math.floor(hours / 24)}d` +} diff --git a/src/renderer/src/lib/worktree-jump-navigation.test.ts b/src/renderer/src/lib/worktree-jump-navigation.test.ts index e615d394f02..08092a11a99 100644 --- a/src/renderer/src/lib/worktree-jump-navigation.test.ts +++ b/src/renderer/src/lib/worktree-jump-navigation.test.ts @@ -3,17 +3,23 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ getState: vi.fn(), activateAndRevealWorktree: vi.fn(), + activateAndRevealFolderWorkspace: vi.fn(), getVisibleWorktreeShortcutTargets: vi.fn(), + worktreePassesSidebarFilters: vi.fn(), warning: vi.fn() })) vi.mock('@/store', () => ({ useAppStore: { getState: mocks.getState } })) vi.mock('@/lib/worktree-activation', () => ({ - activateAndRevealWorktree: mocks.activateAndRevealWorktree + activateAndRevealWorktree: mocks.activateAndRevealWorktree, + activateAndRevealFolderWorkspace: mocks.activateAndRevealFolderWorkspace })) vi.mock('@/components/sidebar/visible-worktrees', () => ({ getVisibleWorktreeShortcutTargets: mocks.getVisibleWorktreeShortcutTargets })) +vi.mock('@/components/sidebar/worktree-filter-visibility', () => ({ + worktreePassesSidebarFilters: mocks.worktreePassesSidebarFilters +})) vi.mock('sonner', () => ({ toast: { warning: mocks.warning } })) import { jumpToWorktreeFromSidebar } from './worktree-jump-navigation' @@ -22,7 +28,9 @@ describe('worktree jump navigation', () => { beforeEach(() => { vi.clearAllMocks() mocks.activateAndRevealWorktree.mockReturnValue({ primaryTabId: null }) + mocks.activateAndRevealFolderWorkspace.mockReturnValue({ primaryTabId: null }) mocks.getVisibleWorktreeShortcutTargets.mockReturnValue([]) + mocks.worktreePassesSidebarFilters.mockReturnValue(false) mocks.getState.mockReturnValue({ sidebarBody: 'agents', setSidebarBody: vi.fn(), @@ -60,4 +68,35 @@ describe('worktree jump navigation', () => { 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([]) + mocks.worktreePassesSidebarFilters.mockReturnValue(true) + + expect(jumpToWorktreeFromSidebar('wt-collapsed')).toBe(true) + + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-collapsed', {}) + expect(mocks.warning).not.toHaveBeenCalled() + }) + + it('routes folder workspaces through the path-status-gated folder activation', () => { + const state = mocks.getState() + + expect(jumpToWorktreeFromSidebar('folder:folder-1', { executionHostId: 'local' })).toBe(true) + + expect(mocks.activateAndRevealFolderWorkspace).toHaveBeenCalledWith('folder-1', { + executionHostId: 'local' + }) + expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() + expect(state.setSidebarBody).toHaveBeenCalledWith('workspaces') + }) + + it('propagates a blocked folder-workspace activation as failure', () => { + const state = mocks.getState() + mocks.activateAndRevealFolderWorkspace.mockReturnValue(false) + + expect(jumpToWorktreeFromSidebar('folder:folder-1')).toBe(false) + expect(state.setSidebarBody).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/lib/worktree-jump-navigation.ts b/src/renderer/src/lib/worktree-jump-navigation.ts index e8ff00ff83e..ceeec3e0190 100644 --- a/src/renderer/src/lib/worktree-jump-navigation.ts +++ b/src/renderer/src/lib/worktree-jump-navigation.ts @@ -1,9 +1,14 @@ import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' -import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { + activateAndRevealFolderWorkspace, + activateAndRevealWorktree +} from '@/lib/worktree-activation' import { getVisibleWorktreeShortcutTargets } from '@/components/sidebar/visible-worktrees' +import { worktreePassesSidebarFilters } from '@/components/sidebar/worktree-filter-visibility' import { sidebarHasActiveFilters } from '@/components/sidebar/sidebar-filter-actions' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' import { normalizeExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' function wasHiddenBySidebarFilters(worktreeId: string, executionHostId?: ExecutionHostId): boolean { @@ -13,7 +18,7 @@ function wasHiddenBySidebarFilters(worktreeId: string, executionHostId?: Executi return false } - return !getVisibleWorktreeShortcutTargets().some((target) => { + const inRenderedTargets = getVisibleWorktreeShortcutTargets().some((target) => { if (target.id !== worktreeId) { return false } @@ -24,6 +29,12 @@ function wasHiddenBySidebarFilters(worktreeId: string, executionHostId?: Executi normalizeExecutionHostId(target.executionHostId) === normalizeExecutionHostId(executionHostId) ) }) + if (inRenderedTargets) { + return false + } + // Absent from the rendered list can mean a collapsed group, not a filter: + // collapsed-but-unfiltered targets should be revealed, not toasted. + return !worktreePassesSidebarFilters(worktreeId) } /** Navigate from a worktree reference in either sidebar back to the workspace surface. */ @@ -32,6 +43,21 @@ export function jumpToWorktreeFromSidebar( options?: { executionHostId?: ExecutionHostId } ): boolean { const state = useAppStore.getState() + + // Folder workspaces must go through the folder branch so its path-status gate runs. + const workspaceScope = parseWorkspaceKey(worktreeId) + if (workspaceScope?.type === 'folder') { + const activated = activateAndRevealFolderWorkspace( + workspaceScope.folderWorkspaceId, + options?.executionHostId ? { executionHostId: options.executionHostId } : undefined + ) + if (activated === false) { + return false + } + state.setSidebarBody?.('workspaces') + return true + } + const hiddenByFilters = wasHiddenBySidebarFilters(worktreeId, options?.executionHostId) const activated = activateAndRevealWorktree(worktreeId, { diff --git a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts index 8ab5263dc2f..f0eb938f00e 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts @@ -183,7 +183,11 @@ export function buildRetractedMirroredTabSweepPatch( // so it must see the post-removal tab list, not the one the snapshot replaced. tabsByWorktree: nextTabsByWorktree } - const sweep = buildRetiredTerminalTabStateSweepPatch(sweepState, retractedTabIds, worktreeId) + // Why: a retraction can be a reconnect re-key, not pane death (ssh-execution-boundary); keeping + // cutoffs means a republished pane cannot replay activity the user cleared on this client. + const sweep = buildRetiredTerminalTabStateSweepPatch(sweepState, retractedTabIds, worktreeId, { + preserveActivityClearedState: true + }) if (!sweep?.agentStatusByPaneKey || !batchContext) { return sweep ?? null } diff --git a/src/renderer/src/store/slices/activity-cleared-at.test.ts b/src/renderer/src/store/slices/activity-cleared-at.test.ts index 565e51e736d..3a59ee3a11b 100644 --- a/src/renderer/src/store/slices/activity-cleared-at.test.ts +++ b/src/renderer/src/store/slices/activity-cleared-at.test.ts @@ -1,8 +1,12 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { RetainedAgentEntry } from './agent-status' import { createTestStore } from './store-test-helpers' +import { + sanitizeAcknowledgedAgentsByPaneKey, + sanitizeActivityClearedAtByPaneKey +} from './ui/ui-slice-hydration-sanitizers' function makeRetained(paneKey: string, worktreeId = 'wt-1'): RetainedAgentEntry { const entry: AgentStatusEntry = { @@ -88,3 +92,78 @@ describe('dismissRetainedAgents', () => { expect(store.getState().retainedAgentsByPaneKey).toBe(before) }) }) + +describe('dropAgentStatus cleared-at/manual-unread lifecycle', () => { + // Why: setAgentStatus schedules a real 30-minute freshness setTimeout. + afterEach(() => { + vi.useRealTimers() + }) + + function seedLiveWithClearState(store: ReturnType): void { + vi.useFakeTimers() + store.getState().setAgentStatus('tab-a:1', { state: 'done', prompt: 'p', agentType: 'claude' }) + store.getState().applyActivityClearedAt({ 'tab-a:1': 5_000 }) + store.getState().unacknowledgeAgents(['tab-a:1']) + expect(store.getState().activityClearedAtByPaneKey['tab-a:1']).toBe(5_000) + expect(store.getState().manuallyUnreadTurnsByPaneKey['tab-a:1']).toBeGreaterThan(0) + } + + it('row dismissal keeps the cutoff and manual-unread stamp for a still-live pane', () => { + const store = createTestStore() + seedLiveWithClearState(store) + store.getState().dropAgentStatus('tab-a:1') + expect(store.getState().agentStatusByPaneKey['tab-a:1']).toBeUndefined() + // The pane may republish its full stateHistory; without the cutoff every + // cleared event would flood back as unread (the Clear-completed undo bug). + expect(store.getState().activityClearedAtByPaneKey['tab-a:1']).toBe(5_000) + expect(store.getState().manuallyUnreadTurnsByPaneKey['tab-a:1']).toBeGreaterThan(0) + }) + + it('paneRemoved drop clears the cutoff and manual-unread stamp with the pane', () => { + const store = createTestStore() + seedLiveWithClearState(store) + store.getState().dropAgentStatus('tab-a:1', { paneRemoved: true }) + expect(store.getState().activityClearedAtByPaneKey['tab-a:1']).toBeUndefined() + expect(store.getState().manuallyUnreadTurnsByPaneKey['tab-a:1']).toBeUndefined() + }) +}) + +describe('dropAgentStatusByTabPrefix preserveActivityClearedState', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('keeps cutoffs and manual-unread stamps for a mirrored-tab retraction sweep', () => { + vi.useFakeTimers() + const store = createTestStore() + store.getState().setAgentStatus('tab-a:1', { state: 'done', prompt: 'p', agentType: 'claude' }) + store.getState().applyActivityClearedAt({ 'tab-a:1': 5_000 }) + store.getState().unacknowledgeAgents(['tab-a:1']) + + store.getState().dropAgentStatusByTabPrefix('tab-a', { preserveActivityClearedState: true }) + + expect(store.getState().agentStatusByPaneKey['tab-a:1']).toBeUndefined() + // Loss of contact is not pane death: the host republishes the same panes on reconnect, + // and the preserved cutoff keeps cleared activity from replaying. + expect(store.getState().activityClearedAtByPaneKey['tab-a:1']).toBe(5_000) + expect(store.getState().manuallyUnreadTurnsByPaneKey['tab-a:1']).toBeGreaterThan(0) + + store.getState().dropAgentStatusByTabPrefix('tab-a') + expect(store.getState().activityClearedAtByPaneKey['tab-a:1']).toBeUndefined() + expect(store.getState().manuallyUnreadTurnsByPaneKey['tab-a:1']).toBeUndefined() + }) +}) + +describe('sanitizeActivityClearedAtByPaneKey hydration TTL', () => { + it('keeps cutoffs past the 7-day ack TTL so they outlive the persisted entries they guard', () => { + const eightDaysAgo = Date.now() - 8 * 24 * 60 * 60 * 1000 + const record = { 'tab-a:1': eightDaysAgo } + // Main prunes persisted entries at 7d from receivedAt; a same-aged cutoff must survive + // hydration or the entry it shadows replays as unread on restart. + expect(sanitizeAcknowledgedAgentsByPaneKey(record)).toEqual({}) + expect(sanitizeActivityClearedAtByPaneKey(record)).toEqual(record) + + const fifteenDaysAgo = Date.now() - 15 * 24 * 60 * 60 * 1000 + expect(sanitizeActivityClearedAtByPaneKey({ 'tab-a:1': fifteenDaysAgo })).toEqual({}) + }) +}) diff --git a/src/renderer/src/store/slices/agent-status-contract.ts b/src/renderer/src/store/slices/agent-status-contract.ts index 65b91e4074b..64dcb7f6919 100644 --- a/src/renderer/src/store/slices/agent-status-contract.ts +++ b/src/renderer/src/store/slices/agent-status-contract.ts @@ -42,8 +42,18 @@ export type DropHibernatedAgentPaneOptions = { retainedCompletionEvidence?: readonly RetainedAgentEntry[] } +export type DropAgentStatusOptions = { + /** The pane itself is gone (pane close, stale-row teardown). Row-only dismissals leave the + * cleared-at cutoff and manual-unread stamp in place so a still-live pane's next hook event + * cannot resurrect activity the user already cleared. */ + paneRemoved?: boolean +} + export type DropAgentStatusByTabPrefixOptions = { worktreeId?: string + /** Keep cleared-at cutoffs and manual-unread stamps: a mirrored-tab retraction is loss of + * contact, not pane death, and the host republishes the same panes on reconnect. */ + preserveActivityClearedState?: boolean } export type AgentLaunchConfigRegistrationMetadata = { diff --git a/src/renderer/src/store/slices/agent-status-drop-actions.ts b/src/renderer/src/store/slices/agent-status-drop-actions.ts index 17ad12af6e1..cff59fdc329 100644 --- a/src/renderer/src/store/slices/agent-status-drop-actions.ts +++ b/src/renderer/src/store/slices/agent-status-drop-actions.ts @@ -1,6 +1,7 @@ import type { RetainedAgentEntry, DropAgentStatusByTabPrefixOptions, + DropAgentStatusOptions, DropHibernatedAgentPaneOptions } from './agent-status-contract' import type { AgentStatusSlice } from './agent-status-slice-contract' @@ -33,7 +34,7 @@ export function createAgentStatusDropActions( > { const { set, freshness } = runtime return { - dropAgentStatus: (paneKey) => { + dropAgentStatus: (paneKey, opts?: DropAgentStatusOptions) => { let liveExisted = false set((s) => { const hasLive = paneKey in s.agentStatusByPaneKey @@ -44,8 +45,14 @@ export function createAgentStatusDropActions( (entry) => entry.paneKey === paneKey ) const nextAck = removeAcknowledgement(s.acknowledgedAgentsByPaneKey, paneKey) - const nextClearedAt = removeAcknowledgement(s.activityClearedAtByPaneKey, paneKey) - const nextManualUnread = removeAcknowledgement(s.manuallyUnreadTurnsByPaneKey, paneKey) + // Row dismissal keeps cutoff/manual-unread: the pane may still be live, and its next + // hook event would replay every cleared stateHistory event as unread without them. + const nextClearedAt = opts?.paneRemoved + ? removeAcknowledgement(s.activityClearedAtByPaneKey, paneKey) + : s.activityClearedAtByPaneKey + const nextManualUnread = opts?.paneRemoved + ? removeAcknowledgement(s.manuallyUnreadTurnsByPaneKey, paneKey) + : s.manuallyUnreadTurnsByPaneKey const hasLaunchConfig = paneKey in s.agentLaunchConfigByPaneKey const nextLaunchConfigs = hasLaunchConfig ? { ...s.agentLaunchConfigByPaneKey } 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 0291b03b6e3..d9619f61965 100644 --- a/src/renderer/src/store/slices/agent-status-drop-reducer.ts +++ b/src/renderer/src/store/slices/agent-status-drop-reducer.ts @@ -106,16 +106,12 @@ export function buildAgentStatusTabPrefixDropPatch( s.recentlyRetiredAgentStatusPaneKeys, retiredAliasPaneKeys ) - const nextClearedAt = removePaneKeyRecords( - s.activityClearedAtByPaneKey, - prefix, - completedOrphanKeySet - ) - const nextManualUnread = removePaneKeyRecords( - s.manuallyUnreadTurnsByPaneKey, - prefix, - completedOrphanKeySet - ) + const nextClearedAt = opts?.preserveActivityClearedState + ? s.activityClearedAtByPaneKey + : removePaneKeyRecords(s.activityClearedAtByPaneKey, prefix, completedOrphanKeySet) + const nextManualUnread = opts?.preserveActivityClearedState + ? s.manuallyUnreadTurnsByPaneKey + : removePaneKeyRecords(s.manuallyUnreadTurnsByPaneKey, prefix, completedOrphanKeySet) if ( liveKeys.length === 0 && diff --git a/src/renderer/src/store/slices/agent-status-slice-contract.ts b/src/renderer/src/store/slices/agent-status-slice-contract.ts index 70a50e1d98b..9762207091b 100644 --- a/src/renderer/src/store/slices/agent-status-slice-contract.ts +++ b/src/renderer/src/store/slices/agent-status-slice-contract.ts @@ -14,6 +14,7 @@ import type { AgentProviderSessionMetadata, DropAgentStatusByTabPrefixOptions, DropAgentStatusByWorktreeOptions, + DropAgentStatusOptions, DropHibernatedAgentPaneOptions, RetainedAgentEntry, AllAgentSessionCaptureMode @@ -133,7 +134,7 @@ export type AgentStatusSlice = { clearTransientAgentStatuses: (connectionId: string, clearedAt: number) => void /** Remove a single entry AND suppress re-retention on its next disappearance (user-initiated teardown: X button, pane close). */ - dropAgentStatus: (paneKey: string) => void + dropAgentStatus: (paneKey: string, opts?: DropAgentStatusOptions) => void /** Remove all entries under a tab AND suppress re-retention for each (tab close — no rows may reappear). */ dropAgentStatusByTabPrefix: ( diff --git a/src/renderer/src/store/slices/retired-terminal-tab-state-sweep.ts b/src/renderer/src/store/slices/retired-terminal-tab-state-sweep.ts index cfdefcf87e0..9985a0f7406 100644 --- a/src/renderer/src/store/slices/retired-terminal-tab-state-sweep.ts +++ b/src/renderer/src/store/slices/retired-terminal-tab-state-sweep.ts @@ -58,7 +58,8 @@ export function sweepRetiredTerminalTabState( export function buildRetiredTerminalTabStateSweepPatch( state: RetiredTerminalTabSweepState, tabIds: readonly string[], - worktreeId?: string | null + worktreeId?: string | null, + opts?: { preserveActivityClearedState?: boolean } ): Partial | null { if (tabIds.length === 0) { return null @@ -73,7 +74,10 @@ export function buildRetiredTerminalTabStateSweepPatch( swept, tabId, retireAgentPaneAuthorityAliasesByOwnerTab(tabId), - worktreeId ? { worktreeId } : undefined + { + ...(worktreeId ? { worktreeId } : {}), + ...(opts?.preserveActivityClearedState ? { preserveActivityClearedState: true } : {}) + } ) const foreground = buildPaneForegroundAgentTabPrefixClearPatch( swept.paneForegroundAgentByPaneKey, diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts index 897aca74c2f..8168ccf7751 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts @@ -51,7 +51,7 @@ import { normalizeHydratedVisibleWorkspaceHostIds, preserveStringArrayIdentity, sanitizeAcknowledgedAgentsByPaneKey, - sanitizePaneKeyTimestampRecord, + sanitizeActivityClearedAtByPaneKey, sanitizeHydratedActiveView, sanitizePersistedRepoIds, sanitizeShowDotfilesByWorktree, @@ -267,7 +267,9 @@ export function createUiHydrationActions(set: UISliceSet, _get: UISliceGet): Par acknowledgedAgentsByPaneKey: sanitizeAcknowledgedAgentsByPaneKey( ui.acknowledgedAgentsByPaneKey ), - activityClearedAtByPaneKey: sanitizePaneKeyTimestampRecord(ui.activityClearedAtByPaneKey), + activityClearedAtByPaneKey: sanitizeActivityClearedAtByPaneKey( + ui.activityClearedAtByPaneKey + ), workspaceCleanupDismissals: sanitizeWorkspaceCleanupDismissals( ui.workspaceCleanup?.dismissals ), @@ -282,7 +284,7 @@ export function createUiHydrationActions(set: UISliceSet, _get: UISliceGet): Par // Why: restore only on startup; on 'sync' broadcasts it would clobber the window's current per-window view. activeView: source === 'startup' - ? sanitizeHydratedActiveView(ui.activeView, s.settings?.experimentalActivity === true) + ? sanitizeHydratedActiveView(ui.activeView, s.settings) : s.activeView, persistedUIReady: true } 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 20fdd03a964..249f30717a5 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 @@ -15,6 +15,8 @@ import { normalizeExecutionHostScope } 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 { DEFAULT_STATUS_BAR_ITEMS } from '../../../../../shared/constants' import type { UISlice } from './ui-slice-contract' @@ -104,11 +106,14 @@ export function sanitizePersistedSidebarWidth( return Math.min(maxWidth, Math.max(MIN_SIDEBAR_WIDTH, width)) } -export function sanitizePaneKeyTimestampRecord(value: unknown): Record { +export function sanitizePaneKeyTimestampRecord( + value: unknown, + maxAgeMs: number = HYDRATE_MAX_AGE_MS +): Record { if (value === null || typeof value !== 'object' || Array.isArray(value)) { return {} } - const cutoff = Date.now() - HYDRATE_MAX_AGE_MS + const cutoff = Date.now() - maxAgeMs const out: Record = {} for (const [key, ackAt] of Object.entries(value as Record)) { if (!isSafePersistedRecordKey(key)) { @@ -124,6 +129,14 @@ export function sanitizePaneKeyTimestampRecord(value: unknown): Record { + return sanitizePaneKeyTimestampRecord(value, 2 * HYDRATE_MAX_AGE_MS) +} + export function sanitizeWorkspaceCleanupDismissals( value: unknown ): Record { @@ -162,14 +175,14 @@ export function sanitizeWorkspaceCleanupDismissals( export function sanitizeHydratedActiveView( value: PersistedUIState['activeView'], - experimentalActivityEnabled: boolean + 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 setting is off, so gate only it (mobile/automations stay functional when hidden). - if (value === 'activity' && !experimentalActivityEnabled) { + // Why: activity is hidden when its entry points are, so gate only it (mobile/automations stay functional when hidden). + if (value === 'activity' && !shouldShowAgentDashboardSidebarButton(settings)) { return 'terminal' } return value 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 432572b799f..e878aee1e95 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,10 +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' export function createUiViewActions(set: UISliceSet, get: UISliceGet): Partial { return { openActivityPage: () => { - if (get().settings?.experimentalActivity !== true) { + // 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)) { return } set((state) => ({