diff --git a/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts b/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts index 5bcaca1fd0e..4e3beb3a771 100644 --- a/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts +++ b/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts @@ -48,6 +48,9 @@ describe('client UI RPC pairing-local field seams', () => { workspaceHostOrder: ['runtime:web-11111111-2222-3333-4444-555555555555', 'local'], agentsVisibleHostIds: ['runtime:web-11111111-2222-3333-4444-555555555555'], agentsFilterRepoIds: ['repo-a'], + agentsHideWorkspacesFromOtherDevices: true, + agentsHideAutomationGeneratedWorkspaces: true, + agentsHideCliCreatedWorkspaces: true, agentsShowChildAgents: true, agentsCompactMode: false, agentsShowSearch: false, diff --git a/src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx b/src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx index 6ba2b7906a4..c5cedcb2443 100644 --- a/src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx +++ b/src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx @@ -48,7 +48,13 @@ describe('ActivityThreadOptionsMenu', () => { let root: Root beforeEach(() => { - useAppStore.setState({ agentsVisibleHostIds: null, agentsFilterRepoIds: [] }) + useAppStore.setState({ + agentsVisibleHostIds: null, + agentsFilterRepoIds: [], + agentsHideWorkspacesFromOtherDevices: false, + agentsHideAutomationGeneratedWorkspaces: false, + agentsHideCliCreatedWorkspaces: false + }) container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) @@ -68,10 +74,80 @@ describe('ActivityThreadOptionsMenu', () => { }) const trigger = container.querySelector( - 'button[aria-label="Thread list options, filters active"]' + 'button[aria-label="Thread list options (1 filter active)"]' ) expect(trigger).not.toBeNull() - expect(trigger?.querySelector('[data-scope-filter-dot]')).not.toBeNull() + expect(trigger?.querySelector('[data-options-filter-count]')?.textContent).toBe('1') + }) + + it('counts every active activity filter in the badge', async () => { + useAppStore.setState({ + agentsVisibleHostIds: ['local'], + agentsFilterRepoIds: ['repo-1'], + agentsHideWorkspacesFromOtherDevices: true, + agentsHideAutomationGeneratedWorkspaces: true, + agentsHideCliCreatedWorkspaces: true + }) + + await act(async () => { + root.render( + + + + ) + }) + + const trigger = container.querySelector( + 'button[aria-label="Thread list options (6 filters active)"]' + ) + expect(trigger?.querySelector('[data-options-filter-count]')?.textContent).toBe('6') + }) + + it('persists the workspace-origin toggles separately from the workspace-nav filters', async () => { + const originalState = useAppStore.getState() + const persist = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('api', { ui: { set: persist } }) + useAppStore.setState({ + runtimeEnvironmentCatalogHydrated: true, + runtimeEnvironments: [], + agentsHideCliCreatedWorkspaces: false, + hideCliCreatedWorkspaces: false + }) + try { + await act(async () => root.render()) + const trigger = container.querySelector( + 'button[aria-label="Thread list options"]' + ) + await act(async () => { + trigger?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' })) + }) + const labels = Array.from( + document.querySelectorAll('[role="menuitemcheckbox"]') + ).map((item) => item.textContent) + expect(labels).toContain('Hide automation-created') + // Without paired runtimes there is no other-client provenance to filter on. + expect(labels).not.toContain('Hide other-client agents') + + const cliItem = Array.from( + document.querySelectorAll('[role="menuitemcheckbox"]') + ).find((item) => item.textContent === 'Hide CLI-created') + await act(async () => { + cliItem?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' })) + }) + + expect(useAppStore.getState().agentsHideCliCreatedWorkspaces).toBe(true) + expect(useAppStore.getState().hideCliCreatedWorkspaces).toBe(false) + expect(persist).toHaveBeenCalledWith({ agentsHideCliCreatedWorkspaces: true }) + } finally { + act(() => useAppStore.setState(originalState)) + vi.unstubAllGlobals() + } }) it('opens without recursively updating composed Radix trigger refs', async () => { @@ -108,7 +184,7 @@ describe('ActivityThreadOptionsMenu', () => { try { await act(async () => root.render()) const trigger = container.querySelector( - 'button[aria-label="Thread list options, filters active"]' + 'button[aria-label="Thread list options (1 filter active)"]' ) expect(trigger).not.toBeNull() await act(async () => { @@ -128,7 +204,7 @@ describe('ActivityThreadOptionsMenu', () => { expect(useAppStore.getState().filterRepoIds).toEqual(['workspace-nav-filter']) expect(persist).toHaveBeenCalledWith({ agentsVisibleHostIds: null }) expect(persist).toHaveBeenCalledWith({ agentsFilterRepoIds: [] }) - expect(container.querySelector('[data-scope-filter-dot]')).toBeNull() + expect(container.querySelector('[data-options-filter-count]')).toBeNull() } finally { act(() => useAppStore.setState(originalState)) vi.unstubAllGlobals() 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 e90ec1f3322..5671567937f 100644 --- a/src/renderer/src/components/activity/activity-scope-filter-controls.tsx +++ b/src/renderer/src/components/activity/activity-scope-filter-controls.tsx @@ -1,6 +1,6 @@ import React from 'react' import { useAppStore } from '@/store' -import { DropdownMenuItem } from '@/components/ui/dropdown-menu' +import { DropdownMenuCheckboxItem, DropdownMenuItem } from '@/components/ui/dropdown-menu' import { translate } from '@/i18n/i18n' import SidebarRepositoryFilterSection from '@/components/sidebar/SidebarRepositoryFilterSection' import { SidebarHostScopeMenuSection } from '@/components/sidebar/SidebarHostScopeMenuSection' @@ -11,45 +11,73 @@ import { import { useSidebarHostScopeOptions } from '@/components/sidebar/use-sidebar-host-scope-options' /** - * Whether {@link ActivityScopeFilterMenuItems} renders anything. - * Why exported: the parent owns the Filters label and separator, so it has to - * know whether the section would be empty. + * Workspace-origin, host, and project scope items for the Agents activity surfaces. State is the + * persisted agents-view scope (agentsHide*, agentsVisibleHostIds, agentsFilterRepoIds), + * deliberately separate from the workspace-nav filters. The parent owns the Filters label and + * separator. */ -export function useActivityScopeFilterMenuItemsVisible(): boolean { - const repos = useAppStore((s) => s.repos) - const agentsVisibleHostIds = useAppStore((s) => s.agentsVisibleHostIds) - const agentsFilterRepoIds = useAppStore((s) => s.agentsFilterRepoIds) - const { hostOptions } = useSidebarHostScopeOptions() - return ( - agentsVisibleHostIds !== null || - agentsFilterRepoIds.length > 0 || - shouldShowHostScopeControls(hostOptions) || - repos.length > 1 - ) -} - -/** - * Host/project scope items for the Agents activity surfaces. State is the - * persisted agents-view scope (agentsVisibleHostIds / agentsFilterRepoIds), - * deliberately separate from the workspace-nav filters. The parent owns the - * Filters label and separator. - */ -export function ActivityScopeFilterMenuItems(): React.JSX.Element | null { +export function ActivityScopeFilterMenuItems(): React.JSX.Element { const agentsVisibleHostIds = useAppStore((s) => s.agentsVisibleHostIds) const setAgentsVisibleHostIds = useAppStore((s) => s.setAgentsVisibleHostIds) const agentsFilterRepoIds = useAppStore((s) => s.agentsFilterRepoIds) const setAgentsFilterRepoIds = useAppStore((s) => s.setAgentsFilterRepoIds) + const hideAutomationGenerated = useAppStore((s) => s.agentsHideAutomationGeneratedWorkspaces) + const setHideAutomationGenerated = useAppStore( + (s) => s.setAgentsHideAutomationGeneratedWorkspaces + ) + const hideCliCreated = useAppStore((s) => s.agentsHideCliCreatedWorkspaces) + const setHideCliCreated = useAppStore((s) => s.setAgentsHideCliCreatedWorkspaces) + const hideFromOtherDevices = useAppStore((s) => s.agentsHideWorkspacesFromOtherDevices) + const setHideFromOtherDevices = useAppStore((s) => s.setAgentsHideWorkspacesFromOtherDevices) + // Same gate as the workspace menu: other-client provenance only exists with paired runtimes. + const showOtherClientFilter = useAppStore( + (s) => + !s.runtimeEnvironmentCatalogHydrated || + s.runtimeEnvironments.length > 0 || + s.agentsHideWorkspacesFromOtherDevices + ) const { hostOptions } = useSidebarHostScopeOptions() const showHostScopeControls = shouldShowHostScopeControls(hostOptions) const hasScopeFilter = agentsVisibleHostIds !== null || agentsFilterRepoIds.length > 0 - const visible = useActivityScopeFilterMenuItemsVisible() - - if (!visible) { - return null - } return ( <> + setHideAutomationGenerated(checked === true)} + onSelect={(event) => event.preventDefault()} + > + {translate( + 'auto.components.activity.ActivityScopeFilterControls.hideAutomationCreated', + 'Hide automation-created' + )} + + setHideCliCreated(checked === true)} + onSelect={(event) => event.preventDefault()} + > + {translate( + 'auto.components.activity.ActivityScopeFilterControls.hideCliCreated', + 'Hide CLI-created' + )} + + {showOtherClientFilter ? ( + setHideFromOtherDevices(checked === true)} + onSelect={(event) => event.preventDefault()} + aria-label={translate( + 'auto.components.activity.ActivityScopeFilterControls.hideOtherClientsAria', + 'Hide agents in workspaces created from other Orca clients on shared remote servers' + )} + > + {translate( + 'auto.components.activity.ActivityScopeFilterControls.hideOtherClients', + 'Hide other-client agents' + )} + + ) : null} {showHostScopeControls ? ( state.agentsVisibleHostIds !== null || state.agentsFilterRepoIds.length > 0 + (state) => + (state.agentsVisibleHostIds !== null ? 1 : 0) + + state.agentsFilterRepoIds.length + + (state.agentsHideWorkspacesFromOtherDevices ? 1 : 0) + + (state.agentsHideAutomationGeneratedWorkspaces ? 1 : 0) + + (state.agentsHideCliCreatedWorkspaces ? 1 : 0) ) } diff --git a/src/renderer/src/components/activity/activity-scope-filter.test.ts b/src/renderer/src/components/activity/activity-scope-filter.test.ts index 45817e50a7b..557c91badf7 100644 --- a/src/renderer/src/components/activity/activity-scope-filter.test.ts +++ b/src/renderer/src/components/activity/activity-scope-filter.test.ts @@ -42,6 +42,10 @@ function makeScope(overrides: Partial = {}): ActivityScopeF visibleHostIds: null, filterRepoIds: [], defaultHostId: LOCAL_EXECUTION_HOST_ID, + hideWorkspacesFromOtherDevices: false, + pairedDeviceIdsByEnvironment: new Map(), + hideAutomationGeneratedWorkspaces: false, + hideCliCreatedWorkspaces: false, ...overrides } } @@ -75,6 +79,67 @@ describe('threadMatchesActivityScope', () => { }) }) +describe('threadMatchesActivityScope workspace-origin toggles', () => { + const cliThread = makeThread({ + worktree: { ...makeWorktree(), cliProvenance: { kind: 'created-by-cli', createdAt: 1 } } + }) + const otherDeviceThread = makeThread({ + worktree: { + ...makeWorktree(), + runtimeOwnerEnvironmentId: 'env-1', + creatorProvenance: { kind: 'paired-device', deviceId: 'phone' } + } + }) + const ownDeviceThread = makeThread({ + worktree: { + ...makeWorktree(), + runtimeOwnerEnvironmentId: 'env-1', + creatorProvenance: { kind: 'paired-device', deviceId: 'this-desktop' } + } + }) + const pairedDeviceIdsByEnvironment = new Map([['env-1', 'this-desktop']]) + + it('hides CLI-created workspaces only when that toggle is on', () => { + expect(threadMatchesActivityScope(cliThread, makeScope())).toBe(true) + expect( + threadMatchesActivityScope(cliThread, makeScope({ hideCliCreatedWorkspaces: true })) + ).toBe(false) + expect( + threadMatchesActivityScope(cliThread, makeScope({ hideAutomationGeneratedWorkspaces: true })) + ).toBe(true) + }) + + it('hides workspaces created from another client of the same runtime', () => { + const scope = makeScope({ hideWorkspacesFromOtherDevices: true, pairedDeviceIdsByEnvironment }) + expect(threadMatchesActivityScope(otherDeviceThread, scope)).toBe(false) + expect(threadMatchesActivityScope(ownDeviceThread, scope)).toBe(true) + }) + + it('never hides provenance-less threads such as floating terminals', () => { + const floating = makeThread({ repo: null }) + const scope = makeScope({ + hideWorkspacesFromOtherDevices: true, + pairedDeviceIdsByEnvironment, + hideAutomationGeneratedWorkspaces: true, + hideCliCreatedWorkspaces: true + }) + expect(threadMatchesActivityScope(floating, scope)).toBe(true) + }) + + it('keeps the exempt pane visible and counts the rest as hidden', () => { + const plain = makeThread({ paneKey: 'pane-plain' }) + const exemptCli = { ...cliThread, paneKey: 'pane-exempt' } + const result = filterThreadsByActivityScope({ + threads: [plain, cliThread, exemptCli], + scope: makeScope({ hideCliCreatedWorkspaces: true }), + exemptPaneKey: 'pane-exempt' + }) + expect(result.threads).toEqual([plain, exemptCli]) + expect(result.matchingThreads).toEqual([plain]) + expect(result.hiddenCount).toBe(1) + }) +}) + describe('filterThreadsByActivityScope', () => { it('returns the input array by identity when the scope is inactive', () => { const threads = [makeThread(), makeThread({ paneKey: 'pane-2', repo: null })] diff --git a/src/renderer/src/components/activity/activity-scope-filter.ts b/src/renderer/src/components/activity/activity-scope-filter.ts index 36dfafa8cf6..b8330ffc866 100644 --- a/src/renderer/src/components/activity/activity-scope-filter.ts +++ b/src/renderer/src/components/activity/activity-scope-filter.ts @@ -1,14 +1,34 @@ import type { ExecutionHostId } from '../../../../shared/execution-host' import { getWorktreeExecutionHostId } from '../../../../shared/execution-host' import type { Repo } from '../../../../shared/repo-types' +import { + isAutomationGeneratedWorkspace, + isCliCreatedWorkspace +} from '@/components/sidebar/visible-worktree-kinds' +import { isWorkspaceFromOtherDevice } from '@/components/sidebar/workspace-creator-visibility' import type { AgentPaneThread } from './activity-thread-types' -/** Host/project scope for the Agents activity surfaces. Persisted and separate +/** Host/project/workspace-origin scope for the Agents activity surfaces. Persisted and separate * from the workspace-nav filters; hosts `null` = all, repoIds empty = all. */ export type ActivityScopeFilter = { visibleHostIds: readonly ExecutionHostId[] | null filterRepoIds: readonly string[] defaultHostId: ExecutionHostId + hideWorkspacesFromOtherDevices: boolean + /** Only read when hideWorkspacesFromOtherDevices is on. */ + pairedDeviceIdsByEnvironment: ReadonlyMap + hideAutomationGeneratedWorkspaces: boolean + hideCliCreatedWorkspaces: boolean +} + +function isActivityScopeActive(scope: ActivityScopeFilter): boolean { + return ( + scope.visibleHostIds !== null || + scope.filterRepoIds.length > 0 || + scope.hideWorkspacesFromOtherDevices || + scope.hideAutomationGeneratedWorkspaces || + scope.hideCliCreatedWorkspaces + ) } /** Repo ids that still exist; stale persisted ids must not count as an active filter. */ @@ -36,7 +56,7 @@ export function filterThreadsByActivityScope(args: { hiddenCount: number } { const { threads, scope, exemptPaneKey } = args - if (!scope.visibleHostIds && scope.filterRepoIds.length === 0) { + if (!isActivityScopeActive(scope)) { return { threads, matchingThreads: threads, hiddenCount: 0 } } const matchingThreads: AgentPaneThread[] = [] @@ -76,5 +96,18 @@ export function threadMatchesActivityScope( return false } } + // Synthetic floating/standalone worktrees carry no provenance, so these never hide them. + if (scope.hideAutomationGeneratedWorkspaces && isAutomationGeneratedWorkspace(thread.worktree)) { + return false + } + if (scope.hideCliCreatedWorkspaces && isCliCreatedWorkspace(thread.worktree)) { + return false + } + if ( + scope.hideWorkspacesFromOtherDevices && + isWorkspaceFromOtherDevice(thread.worktree, scope.pairedDeviceIdsByEnvironment) + ) { + return false + } return true } 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 ce546f03697..0e1cdd6a20e 100644 --- a/src/renderer/src/components/activity/activity-thread-actions.test.ts +++ b/src/renderer/src/components/activity/activity-thread-actions.test.ts @@ -259,6 +259,7 @@ describe('activity thread host routing', () => { expect(acknowledgeAgents).toHaveBeenCalledWith([thread.paneKey]) expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith(thread.worktree.id, { navigationIntent: 'user-open', + showWorkspaceList: true, executionHostId: REMOTE_HOST }) }) diff --git a/src/renderer/src/components/activity/activity-thread-context-menu.test.tsx b/src/renderer/src/components/activity/activity-thread-context-menu.test.tsx new file mode 100644 index 00000000000..46758ccf057 --- /dev/null +++ b/src/renderer/src/components/activity/activity-thread-context-menu.test.tsx @@ -0,0 +1,167 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { + ActivityThreadContextMenu, + getActivityThreadCopyTargets +} from './activity-thread-context-menu' +import type * as ActivityClearCompleted from './activity-clear-completed' +import type { AgentPaneThread } from './activity-thread-types' +import { makeRepo, makeTab, makeWorktree } from './ActivityPrototypePage-test-fixtures' + +const mocks = vi.hoisted(() => ({ clearActivityThread: vi.fn() })) + +vi.mock('./activity-clear-completed', async (importOriginal) => ({ + ...(await importOriginal()), + clearActivityThread: mocks.clearActivityThread +})) + +function makeThread(overrides: Partial = {}): AgentPaneThread { + return { + paneKey: 'tab-1:leaf-1', + paneTitle: 'Fix the flaky test', + worktree: { ...makeWorktree(), branch: 'refs/heads/feat/flaky' }, + repo: makeRepo(), + tab: makeTab(), + agentType: 'claude', + currentAgentState: 'working', + currentAgentEntry: null, + responsePreview: '', + latestTimestamp: 1000, + latestEvent: null, + events: [], + unread: true, + ...overrides + } +} + +const handlers = { + onOpen: vi.fn(), + onJump: vi.fn(), + onMarkRead: vi.fn(), + onMarkUnread: vi.fn() +} + +function openMenu(thread: AgentPaneThread, canJump = true, disableMarkUnread = false): void { + render( + + {(menuOpen) => ( +
+ row +
+ )} +
+ ) + fireEvent.contextMenu(screen.getByTestId('row')) +} + +function menuItem(name: string): HTMLElement { + return screen.getByRole('menuitem', { name }) +} + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +describe('ActivityThreadContextMenu', () => { + it('opens the thread', () => { + const thread = makeThread() + openMenu(thread) + + fireEvent.click(menuItem('Open')) + expect(handlers.onOpen).toHaveBeenCalledWith(thread) + }) + + it('shows one read toggle that marks an unread thread read', () => { + const thread = makeThread() + openMenu(thread) + + expect(screen.queryByRole('menuitem', { name: 'Mark Unread' })).toBeNull() + fireEvent.click(menuItem('Mark Read')) + expect(handlers.onMarkRead).toHaveBeenCalledWith(thread) + }) + + it('shows one read toggle that marks a read thread unread', () => { + const thread = makeThread({ unread: false }) + openMenu(thread) + + expect(screen.queryByRole('menuitem', { name: 'Mark Read' })).toBeNull() + fireEvent.click(menuItem('Mark Unread')) + expect(handlers.onMarkUnread).toHaveBeenCalledWith(thread) + }) + + it('disables Mark Unread for the open thread', () => { + openMenu(makeThread({ unread: false }), true, true) + + expect(menuItem('Mark Unread').hasAttribute('data-disabled')).toBe(true) + }) + + it('lists copy actions flat instead of in a submenu', () => { + openMenu(makeThread()) + + expect(menuItem('Copy Path')).toBeTruthy() + expect(menuItem('Copy Title')).toBeTruthy() + expect(screen.queryByRole('menuitem', { name: 'Copy Branch' })).toBeNull() + }) + + it('tells the row while the menu is open so it can keep its preview closed', () => { + openMenu(makeThread()) + expect(screen.getByTestId('row').hasAttribute('data-menu-open')).toBe(true) + + fireEvent.keyDown(menuItem('Open'), { key: 'Escape' }) + expect(screen.getByTestId('row').hasAttribute('data-menu-open')).toBe(false) + }) + + it('offers Go to Workspace only for threads with a real workspace', () => { + openMenu(makeThread(), false) + + expect(screen.queryByRole('menuitem', { name: 'Go to Workspace' })).toBeNull() + }) + + it('offers Clear from List only for finished threads', () => { + const done = makeThread({ + currentAgentState: null, + paneEntry: { + state: 'done', + prompt: '', + updatedAt: 1000, + stateStartedAt: 1000, + agentType: 'claude', + paneKey: 'tab-1:leaf-1', + stateHistory: [] + } + }) + openMenu(done) + + fireEvent.click(menuItem('Clear from List')) + expect(mocks.clearActivityThread).toHaveBeenCalledWith(done) + cleanup() + + openMenu(makeThread()) + expect(screen.queryByRole('menuitem', { name: 'Clear from List' })).toBeNull() + }) + + it('copies the path, then the title, of a real workspace like the workspace menu', () => { + expect(getActivityThreadCopyTargets(makeThread(), true)).toEqual([ + { key: 'path', label: 'Copy Path', value: '/repo/wt-1' }, + { key: 'title', label: 'Copy Title', value: 'Fix the flaky test' } + ]) + }) + + it('offers only the title for a synthetic terminal without a workspace', () => { + const synthetic = makeThread({ + worktree: { ...makeWorktree(), path: '', branch: 'Floating terminal' } + }) + + expect(getActivityThreadCopyTargets(synthetic, false)).toEqual([ + { key: 'title', label: 'Copy Title', value: 'Fix the flaky test' } + ]) + }) +}) diff --git a/src/renderer/src/components/activity/activity-thread-context-menu.tsx b/src/renderer/src/components/activity/activity-thread-context-menu.tsx new file mode 100644 index 00000000000..2a63c5d627c --- /dev/null +++ b/src/renderer/src/components/activity/activity-thread-context-menu.tsx @@ -0,0 +1,121 @@ +import React from 'react' +import { Bell, BellOff, Copy, ExternalLink, PanelRight, X } from 'lucide-react' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuTrigger +} from '@/components/ui/context-menu' +import { translate } from '@/i18n/i18n' +import { clearActivityThread, isClearableActivityThread } from './activity-clear-completed' +import { activityThreadRowCopy } from './activity-thread-presentation' +import type { AgentPaneThread } from './activity-thread-types' + +type CopyTarget = { key: string; label: string; value: string } + +export function getActivityThreadCopyTargets( + thread: AgentPaneThread, + hasWorkspace: boolean +): CopyTarget[] { + const title: CopyTarget = { + key: 'title', + label: translate('auto.components.activity.ActivityThreadContextMenu.copyTitle', 'Copy Title'), + value: activityThreadRowCopy(thread).taskTitle + } + // Why gated: synthetic floating/standalone worktrees have no path. + if (!hasWorkspace || !thread.worktree.path) { + return [title] + } + const path: CopyTarget = { + key: 'path', + label: translate('auto.components.activity.ActivityThreadContextMenu.copyPath', 'Copy Path'), + value: thread.worktree.path + } + // Same order as the workspace menu: Copy Path, then the name. + return [path, title] +} + +/** Right-click actions for an activity row; mirrors the row's own click and hover actions. */ +export function ActivityThreadContextMenu({ + thread, + canJump, + disableMarkUnread, + onOpen, + onJump, + onMarkRead, + onMarkUnread, + children +}: { + thread: AgentPaneThread + canJump: boolean + disableMarkUnread: boolean + onOpen: (thread: AgentPaneThread) => void + onJump: (thread: AgentPaneThread) => void + onMarkRead: (thread: AgentPaneThread) => void + onMarkUnread: (thread: AgentPaneThread) => void + /** Receives whether the menu is open, so the row can keep its preview out of the way. */ + children: (menuOpen: boolean) => React.ReactElement +}): React.JSX.Element { + const [menuOpen, setMenuOpen] = React.useState(false) + return ( + + {children(menuOpen)} + {/* Why no focus restore: refocusing the row would reopen its hover preview and pin it open. */} + event.preventDefault()}> + + {translate('auto.components.activity.ActivityThreadContextMenu.agentSection', 'Agent')} + + onOpen(thread)}> + + {translate('auto.components.activity.ActivityThreadContextMenu.open', 'Open')} + + {canJump ? ( + onJump(thread)}> + + {translate( + 'auto.components.activity.ActivityThreadContextMenu.goToWorkspace', + 'Go to Workspace' + )} + + ) : null} + + {getActivityThreadCopyTargets(thread, canJump).map((target) => ( + void window.api.ui.writeClipboardText(target.value)} + > + + {target.label} + + ))} + + (thread.unread ? onMarkRead(thread) : onMarkUnread(thread))} + > + {thread.unread ? : } + {thread.unread + ? translate('auto.components.activity.ActivityThreadContextMenu.markRead', 'Mark Read') + : translate( + 'auto.components.activity.ActivityThreadContextMenu.markUnread', + 'Mark Unread' + )} + + {isClearableActivityThread(thread) ? ( + <> + + clearActivityThread(thread)}> + + {translate( + 'auto.components.activity.ActivityThreadContextMenu.clear', + 'Clear from List' + )} + + + ) : null} + + + ) +} diff --git a/src/renderer/src/components/activity/activity-thread-hover-card.test.tsx b/src/renderer/src/components/activity/activity-thread-hover-card.test.tsx index d64f60c11a1..312b9e8c4ab 100644 --- a/src/renderer/src/components/activity/activity-thread-hover-card.test.tsx +++ b/src/renderer/src/components/activity/activity-thread-hover-card.test.tsx @@ -215,6 +215,23 @@ describe('ActivityThreadHoverCard and ActivityThreadRow', () => { expect(content).toContain('Performance investigation notes') }) + it('keeps the preview closed while suppressed by the row menu', async () => { + const renderCard = (suppressed: boolean) => + root.render( + + +
Hover Target
+
+
+ ) + + await act(async () => renderCard(true)) + expect(container.querySelector('[data-testid="hover-card-content"]')).toBeNull() + + await act(async () => renderCard(false)) + expect(container.querySelector('[data-testid="hover-card-content"]')).not.toBeNull() + }) + it('allows clicking row while preventing inner hover interactions from bubbling', async () => { const onSelect = vi.fn() const thread = createTestThread() diff --git a/src/renderer/src/components/activity/activity-thread-hover-card.tsx b/src/renderer/src/components/activity/activity-thread-hover-card.tsx index 9bb4ed83887..064235f175e 100644 --- a/src/renderer/src/components/activity/activity-thread-hover-card.tsx +++ b/src/renderer/src/components/activity/activity-thread-hover-card.tsx @@ -37,6 +37,8 @@ export type ActivityThreadHoverCardProps = { closeDelay?: number onJumpToWorkspace?: (thread: AgentPaneThread) => void canJumpToWorkspace?: boolean + /** Keeps the preview closed, e.g. while the row's right-click menu covers it. */ + suppressed?: boolean } export function ActivityThreadHoverCard({ @@ -45,19 +47,21 @@ export function ActivityThreadHoverCard({ openDelay = 200, closeDelay = 120, onJumpToWorkspace, - canJumpToWorkspace + canJumpToWorkspace, + suppressed = false }: ActivityThreadHoverCardProps): React.JSX.Element { const detailsHoverControl = useWorktreeCardDetailsHoverControl() + const open = detailsHoverControl.hoverOpen && !suppressed return ( {children} - {detailsHoverControl.hoverOpen ? ( + {open ? ( void }): React.JSX.Element { const skipCloseAutoFocusRef = React.useRef(false) - const scopeFilterActive = useActivityScopeFilterActive() - const scopeFilterItemsVisible = useActivityScopeFilterMenuItemsVisible() - const hasFilters = Boolean( - onUnreadOnlyChange || onShowChildAgentsChange || scopeFilterItemsVisible - ) - const optionsLabel = scopeFilterActive - ? translate( - 'auto.components.activity.ActivityPrototypePage.threadListOptionsFiltered', - 'Thread list options, filters active' - ) - : translate('auto.components.activity.ActivityPrototypePage.db8a1878b5', 'Thread list options') + const activeFilterCount = + useActivityScopeFilterCount() + (onUnreadOnlyChange && unreadOnly ? 1 : 0) + const activeFilterLabel = formatActiveFilterLabel(activeFilterCount) + const optionsLabel = + activeFilterCount > 0 + ? translate( + 'auto.components.activity.ActivityPrototypePage.threadListOptionsActive', + 'Thread list options ({{value0}} active)', + { value0: activeFilterLabel } + ) + : translate( + 'auto.components.activity.ActivityPrototypePage.db8a1878b5', + 'Thread list options' + ) return ( @@ -105,23 +111,23 @@ export function ActivityThreadOptionsMenu({ className="relative text-muted-foreground" aria-label={optionsLabel} > - - {scopeFilterActive ? ( - - - {translate( - 'auto.components.activity.ActivityPrototypePage.activityOptions', - 'Activity options' - )} + + {activeFilterCount > 0 + ? translate( + 'auto.components.activity.ActivityPrototypePage.activityOptionsActive', + 'Activity options ({{value0}})', + { value0: activeFilterLabel } + ) + : translate( + 'auto.components.activity.ActivityPrototypePage.activityOptions', + 'Activity options' + )} - {hasFilters ? ( - <> - - {translate( - 'auto.components.activity.ActivityPrototypePage.filtersSection', - 'Filters' - )} - - {onUnreadOnlyChange ? ( - onUnreadOnlyChange(checked === true)} - onSelect={(event) => event.preventDefault()} - > - {translate( - 'auto.components.activity.ActivityPrototypePage.showUnreadOnly', - 'Show unread only' - )} - - ) : null} - {onShowChildAgentsChange ? ( - onShowChildAgentsChange(checked === true)} - onSelect={(event) => event.preventDefault()} - > - {translate( - 'auto.components.activity.ActivityPrototypePage.showChildAgents', - 'Show child agents' - )} - - ) : null} - - - + + {translate('auto.components.activity.ActivityPrototypePage.filtersSection', 'Filters')} + + {onUnreadOnlyChange ? ( + onUnreadOnlyChange(checked === true)} + onSelect={(event) => event.preventDefault()} + > + {translate( + 'auto.components.activity.ActivityPrototypePage.showUnreadOnly', + 'Show unread only' + )} + ) : null} + {onShowChildAgentsChange ? ( + onShowChildAgentsChange(checked === true)} + onSelect={(event) => event.preventDefault()} + > + {translate( + 'auto.components.activity.ActivityPrototypePage.showChildAgents', + 'Show child agents' + )} + + ) : null} + + {translate('auto.components.activity.ActivityPrototypePage.viewSection', 'View')} diff --git a/src/renderer/src/components/activity/activity-thread-row.tsx b/src/renderer/src/components/activity/activity-thread-row.tsx index 207ba0d6021..a418624bfff 100644 --- a/src/renderer/src/components/activity/activity-thread-row.tsx +++ b/src/renderer/src/components/activity/activity-thread-row.tsx @@ -65,7 +65,8 @@ export const ActivityThreadRow = React.memo(function ActivityThreadRow({ canJump, compactMode, disableMarkUnread = false, - showJumpAction = true + showJumpAction = true, + previewSuppressed = false }: { thread: AgentPaneThread selected: boolean @@ -78,6 +79,7 @@ export const ActivityThreadRow = React.memo(function ActivityThreadRow({ compactMode: boolean disableMarkUnread?: boolean showJumpAction?: boolean + previewSuppressed?: boolean }): React.JSX.Element { const { taskTitle, statusLine, statusKind, needsAttention, workspaceLabel } = activityThreadRowCopy(thread) @@ -89,6 +91,7 @@ export const ActivityThreadRow = React.memo(function ActivityThreadRow({ thread={thread} onJumpToWorkspace={onJump} canJumpToWorkspace={canJump} + suppressed={previewSuppressed} >
) } + const canJump = canJumpToWorkspace(item.thread) + const disableMarkUnread = item.thread.paneKey === selectedPaneKey && !allowMarkUnreadWhenSelected return ( -
- -
+ + {(menuOpen) => ( + // Why the menu wraps this wrapper, not the row: the row is already the hover-card + // trigger, and stacking two Radix triggers on one node composes their refs. +
+ +
+ )} +
) } 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 ce1f571cdbc..44fecebb5e6 100644 --- a/src/renderer/src/components/activity/use-agent-pane-threads.ts +++ b/src/renderer/src/components/activity/use-agent-pane-threads.ts @@ -11,6 +11,10 @@ import { buildActivityEvents, createActivityEventBuildCache } from './activity-e import { projectActivityTabs, type ActivityTabProjection } from './activity-tab-projection' import { buildAgentPaneThreads, createAgentPaneThreadReuseCache } from './activity-thread-builder' import { collectChildAgentPaneKeys } from './activity-thread-child-agent' +import { + EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, + getPairedDeviceIdsByEnvironment +} from '@/components/sidebar/workspace-creator-visibility' const EMPTY_PANE_KEYS: ReadonlySet = new Set() import { filterThreadsByActivityScope, resolveActivityScopeRepoIds } from './activity-scope-filter' @@ -77,6 +81,25 @@ export function useAgentPaneThreads(args: { const { query, readFilter, groupBy, selectedPaneKey, showChildAgents = false } = args const agentsVisibleHostIds = useAppStore((s) => s.agentsVisibleHostIds) const agentsFilterRepoIds = useAppStore((s) => s.agentsFilterRepoIds) + const hideWorkspacesFromOtherDevices = useAppStore((s) => s.agentsHideWorkspacesFromOtherDevices) + const hideAutomationGeneratedWorkspaces = useAppStore( + (s) => s.agentsHideAutomationGeneratedWorkspaces + ) + const hideCliCreatedWorkspaces = useAppStore((s) => s.agentsHideCliCreatedWorkspaces) + // Why gated selectors: runtime status churns, and only the other-device filter reads it. + const runtimeEnvironments = useAppStore((s) => + s.agentsHideWorkspacesFromOtherDevices ? s.runtimeEnvironments : null + ) + const runtimeStatusByEnvironmentId = useAppStore((s) => + s.agentsHideWorkspacesFromOtherDevices ? s.runtimeStatusByEnvironmentId : null + ) + const pairedDeviceIdsByEnvironment = useMemo( + () => + runtimeEnvironments && runtimeStatusByEnvironmentId + ? getPairedDeviceIdsByEnvironment(runtimeEnvironments, runtimeStatusByEnvironmentId) + : EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, + [runtimeEnvironments, runtimeStatusByEnvironmentId] + ) // Why project: the unified tab map is rewritten on every tab focus; the projection keeps // its identity (and each tab's) unless a field this pipeline reads actually changed. const tabProjectionRef = useRef<{ @@ -173,7 +196,7 @@ export function useAgentPaneThreads(args: { selectedPaneKey === null || allThreads.some((thread) => thread.paneKey === selectedPaneKey) const effectiveSelectedPaneKey = selectedPaneKeyIsLive ? selectedPaneKey : null - // Why scope runs before the per-view filters: host/project scope must stay separate + // Why scope runs before the per-view filters: host/project/origin scope must stay separate // from unread/search narrowing. const { threads: scopeVisibleThreads } = useMemo( () => @@ -182,7 +205,11 @@ export function useAgentPaneThreads(args: { scope: { visibleHostIds: agentsVisibleHostIds, filterRepoIds: resolveActivityScopeRepoIds(agentsFilterRepoIds, storeData.repoMap), - defaultHostId: storeData.defaultHostId + defaultHostId: storeData.defaultHostId, + hideWorkspacesFromOtherDevices, + pairedDeviceIdsByEnvironment, + hideAutomationGeneratedWorkspaces, + hideCliCreatedWorkspaces }, exemptPaneKey: effectiveSelectedPaneKey }), @@ -192,6 +219,10 @@ export function useAgentPaneThreads(args: { agentsFilterRepoIds, storeData.repoMap, storeData.defaultHostId, + hideWorkspacesFromOtherDevices, + pairedDeviceIdsByEnvironment, + hideAutomationGeneratedWorkspaces, + hideCliCreatedWorkspaces, effectiveSelectedPaneKey ] ) diff --git a/src/renderer/src/components/sidebar/SidebarHeader.test.tsx b/src/renderer/src/components/sidebar/SidebarHeader.test.tsx index 44cd588be5b..d68d35e36fb 100644 --- a/src/renderer/src/components/sidebar/SidebarHeader.test.tsx +++ b/src/renderer/src/components/sidebar/SidebarHeader.test.tsx @@ -270,7 +270,7 @@ describe('SidebarHeader', () => { ) }) - it('drops both project actions in the agents view, which lists activity, not projects', () => { + it('swaps only the options slot in the agents view so no header button shifts', () => { mockState.sidebarBody = 'agents' act(() => { root.render() @@ -279,7 +279,7 @@ describe('SidebarHeader', () => { expect(container.querySelector('[aria-label="Turn off activity view"]')).toBeTruthy() expect(container.querySelector('[aria-label="New workspace"]')).toBeTruthy() expect(container.querySelector('[aria-label="Workspace options"]')).toBeNull() - expect(container.querySelector('[aria-label="Add project"]')).toBeNull() + expect(container.querySelector('[aria-label="Add project"]')).toBeTruthy() }) it('keeps the activity bell and actions on one row at the default sidebar width', () => { diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx index 9f230e94f95..c423a978861 100644 --- a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx @@ -8,6 +8,7 @@ import { } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' +import { OptionsFilterCountBadge } from './options-filter-count-badge' import { useWorkspaceOptionsFilterBadge, WorkspaceOptionsMenuItems @@ -58,16 +59,7 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM data-workspace-board-preserve-open={preserveWorkspaceBoardOpen ? '' : undefined} > - {hasAnyFilter && ( - // Why: this combined options button now owns filtering, so it - // needs the same at-a-glance signal that the old filter button had. - - {activeFilterCount > 9 ? '9+' : activeFilterCount} - - )} + diff --git a/src/renderer/src/components/sidebar/options-filter-count-badge.tsx b/src/renderer/src/components/sidebar/options-filter-count-badge.tsx new file mode 100644 index 00000000000..ad835d5698b --- /dev/null +++ b/src/renderer/src/components/sidebar/options-filter-count-badge.tsx @@ -0,0 +1,23 @@ +import React from 'react' + +/** Pluralized "N filter(s)" used in the options triggers' tooltip and aria label. */ +export function formatActiveFilterLabel(count: number): string { + return `${count} ${count === 1 ? 'filter' : 'filters'}` +} + +/** Active-filter count on a sidebar options trigger (the trigger must be `relative`). Shared so + * the workspace and activity headers signal filtering identically. */ +export function OptionsFilterCountBadge({ count }: { count: number }): React.JSX.Element | null { + if (count === 0) { + return null + } + return ( + + {count > 9 ? '9+' : count} + + ) +} diff --git a/src/renderer/src/components/sidebar/sidebar-header-actions.tsx b/src/renderer/src/components/sidebar/sidebar-header-actions.tsx index 994c26eb88d..d673745d251 100644 --- a/src/renderer/src/components/sidebar/sidebar-header-actions.tsx +++ b/src/renderer/src/components/sidebar/sidebar-header-actions.tsx @@ -88,16 +88,15 @@ export function SidebarHeaderActions({ }): React.JSX.Element { return (
- {/* Why both hidden in the agents view: it lists activity, not projects. */} + {/* Why only options swap: the activity view portals its own options button into this slot + (SidebarHeader), and keeping Add project means no header button shifts between views. */} {agentsViewActive ? null : ( - <> - - - + )} +
) diff --git a/src/renderer/src/components/sidebar/workspace-options-menu-items.tsx b/src/renderer/src/components/sidebar/workspace-options-menu-items.tsx index c4318449bee..5b0ad23e502 100644 --- a/src/renderer/src/components/sidebar/workspace-options-menu-items.tsx +++ b/src/renderer/src/components/sidebar/workspace-options-menu-items.tsx @@ -21,6 +21,7 @@ import { PROJECT_ORDER_OPTIONS, SORT_OPTIONS } from './sidebar-workspace-option- import { WorktreeCardDisplayMenuSection } from './WorktreeCardDisplayMenuSection' import { translate } from '@/i18n/i18n' import { SidebarGroupByToggle } from './SidebarGroupByToggle' +import { formatActiveFilterLabel } from './options-filter-count-badge' export function useWorkspaceOptionsFilterBadge(): { hasAnyFilter: boolean @@ -79,7 +80,7 @@ export function useWorkspaceOptionsFilterBadge(): { return { hasAnyFilter, activeFilterCount, - activeFilterLabel: `${activeFilterCount} ${activeFilterCount === 1 ? 'filter' : 'filters'}` + activeFilterLabel: formatActiveFilterLabel(activeFilterCount) } } diff --git a/src/renderer/src/components/ui/context-menu.tsx b/src/renderer/src/components/ui/context-menu.tsx index 40fc7dd7bea..5fa36fe1ebe 100644 --- a/src/renderer/src/components/ui/context-menu.tsx +++ b/src/renderer/src/components/ui/context-menu.tsx @@ -59,7 +59,7 @@ function ContextMenuSubContent({ { skipRestoreFocusRef.current = true + // Why switch: picking a project row explicitly asks to see it in the workspace list. + useAppStore.getState().setSidebarBody('workspaces') revealSidebarRow(result.rowKey, { behavior: 'smooth', highlight: true }) recordFeatureInteraction('cmd-j') closeModal() diff --git a/src/renderer/src/hooks/ipc-events/workspace-shortcut-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/workspace-shortcut-ipc-bridge.ts index 496f885ebbb..959f00d4a30 100644 --- a/src/renderer/src/hooks/ipc-events/workspace-shortcut-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/workspace-shortcut-ipc-bridge.ts @@ -95,6 +95,8 @@ export function registerWorkspaceShortcutIpcBridge(unsubs: (() => void)[]): void if (target) { activateAndRevealWorkspace(target.id, { navigationIntent: 'user-open', + // Why: the digit addresses a workspace-list row, so show the list it acted on. + showWorkspaceList: true, ...(target.executionHostId ? { executionHostId: target.executionHostId } : {}) }) } diff --git a/src/renderer/src/hooks/useIpcEvents-cmd-j-digit-chord.test.ts b/src/renderer/src/hooks/useIpcEvents-cmd-j-digit-chord.test.ts index ae61b9adcb9..ff78eb065f6 100644 --- a/src/renderer/src/hooks/useIpcEvents-cmd-j-digit-chord.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-cmd-j-digit-chord.test.ts @@ -53,7 +53,8 @@ describe('useIpcEvents digit-chord routing while Cmd+J is open', () => { expect(rowJumps).toEqual([]) // Why assert the activation and not just the silent bus: a premature return would also emit nothing. expect(harness.activateAndRevealWorkspace).toHaveBeenCalledWith('wt-c', { - navigationIntent: 'user-open' + navigationIntent: 'user-open', + showWorkspaceList: true }) }) @@ -70,6 +71,7 @@ describe('useIpcEvents digit-chord routing while Cmd+J is open', () => { expect(harness.activateAndRevealWorkspace).toHaveBeenCalledWith('repo::path', { navigationIntent: 'user-open', + showWorkspaceList: true, executionHostId: 'ssh:box' }) }) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 40c50ff3b5c..4588ebee405 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -16907,7 +16907,6 @@ "showUnreadOnly": "Show unread only", "showChildAgents": "Show child agents", "activityOptions": "Activity options", - "threadListOptionsFiltered": "Thread list options, filters active", "showSearch": "Show search", "interrupted": "Interrupted", "state": { @@ -16923,7 +16922,9 @@ "permission": "Needs attention" }, "filtersSection": "Filters", - "viewSection": "View" + "viewSection": "View", + "threadListOptionsActive": "Thread list options ({{value0}} active)", + "activityOptionsActive": "Activity options ({{value0}})" }, "clearCompleted": { "clearedOne": "Cleared 1 completed agent", @@ -16941,7 +16942,11 @@ }, "ActivityScopeFilterControls": { "hiddenCount": "{{value0}} hidden", - "resetScope": "Show all hosts and projects" + "resetScope": "Show all hosts and projects", + "hideAutomationCreated": "Hide automation-created", + "hideCliCreated": "Hide CLI-created", + "hideOtherClientsAria": "Hide agents in workspaces created from other Orca clients on shared remote servers", + "hideOtherClients": "Hide other-client agents" }, "ActivityThreadHoverCard": { "pathCopied": "Path copied to clipboard", @@ -16952,6 +16957,16 @@ }, "ActivityThreadRow": { "clearNotification": "Clear notification" + }, + "ActivityThreadContextMenu": { + "copyTitle": "Copy Title", + "copyPath": "Copy Path", + "open": "Open", + "markRead": "Mark Read", + "markUnread": "Mark Unread", + "goToWorkspace": "Go to Workspace", + "clear": "Clear from List", + "agentSection": "Agent" } }, "confirmation": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 0d836852f59..6d2694fdfce 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -16672,7 +16672,6 @@ "770d458144": "Agrupar por", "795cbf26e2": "Filtrar...", "4616ea39fd": "Ir al workspace", - "threadListOptionsFiltered": "Opciones de lista de hilos, filtros activos", "showSearch": "Mostrar búsqueda", "markThreadRead": "Marcar hilo como leído", "59b131fbd9": "Marcar hilo como no leído", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 0b7490532bb..6a7ecc60d85 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -16775,7 +16775,6 @@ "770d458144": "Grouper l'activité des agents par", "795cbf26e2": "Filtrer...", "4616ea39fd": "Aller à l'espace de travail", - "threadListOptionsFiltered": "Options de la liste des fils, filtres actifs", "showSearch": "Afficher la recherche", "showUnreadOnly": "Afficher uniquement les fils non lus", "showChildAgents": "Afficher les agents enfants", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index f83c99f8972..10b6de6e546 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -16672,7 +16672,6 @@ "770d458144": "グループ化", "795cbf26e2": "フィルター…", "4616ea39fd": "ワークスペースにジャンプ", - "threadListOptionsFiltered": "スレッドリストのオプション、フィルターが有効", "showSearch": "検索を表示", "markThreadRead": "スレッドを既読としてマーク", "59b131fbd9": "スレッドを未読としてマークする", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 27247862e4b..346317e1b17 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -16672,7 +16672,6 @@ "770d458144": "그룹화 기준", "795cbf26e2": "필터...", "4616ea39fd": "워크스페이스로 이동", - "threadListOptionsFiltered": "스레드 목록 옵션, 필터 활성화됨", "showSearch": "검색 표시", "markThreadRead": "스레드를 읽은 것으로 표시", "59b131fbd9": "스레드를 읽지 않은 것으로 표시", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 63b7188d2a1..31deaef11e1 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -16672,7 +16672,6 @@ "770d458144": "分组方式", "795cbf26e2": "筛选...", "4616ea39fd": "跳转到工作区", - "threadListOptionsFiltered": "线程列表选项,筛选器已启用", "showSearch": "显示搜索", "markThreadRead": "将话题标记为已读", "59b131fbd9": "将话题标记为未读", diff --git a/src/renderer/src/lib/worktree-activation-automation-filter.test.ts b/src/renderer/src/lib/worktree-activation-automation-filter.test.ts index 9e07ee425aa..8ddc5ff4225 100644 --- a/src/renderer/src/lib/worktree-activation-automation-filter.test.ts +++ b/src/renderer/src/lib/worktree-activation-automation-filter.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { Worktree } from '../../../shared/worktree/types' import { useAppStore } from '@/store' -import { activateAndRevealWorktree } from './worktree-activation' +import { activateAndRevealWorkspace, activateAndRevealWorktree } from './worktree-activation' const initialAppStoreState = useAppStore.getState() @@ -44,40 +44,107 @@ function makeAutomationWorktree(): Worktree { } } +function seedAutomationWorktreeState( + worktree: Worktree, + overrides: Partial> = {} +): void { + useAppStore.setState({ + repos: [ + { + id: worktree.repoId, + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0 + } + ], + worktreesByRepo: { [worktree.repoId]: [worktree] }, + activeRepoId: worktree.repoId, + activeView: 'terminal', + activeWorktreeId: worktree.id, + activeTabId: 'tab-1', + activeTabType: 'terminal', + tabsByWorktree: { [worktree.id]: [] }, + ptyIdsByTabId: {}, + everActivatedWorktreeIds: new Set([worktree.id]), + hideAutomationGeneratedWorkspaces: true, + markWorktreeVisited: vi.fn(), + recordWorktreeVisit: vi.fn(), + refreshGitHubForWorktreeIfStale: vi.fn(), + ...overrides + }) +} + describe('activateAndRevealWorktree automation filters', () => { it('clears the automation-generated filter before revealing an automation-created worktree', () => { const worktree = makeAutomationWorktree() const revealWorktreeInSidebar = vi.fn() - - useAppStore.setState({ - repos: [ - { - id: worktree.repoId, - path: '/workspace/repo', - displayName: 'repo', - badgeColor: '#000000', - addedAt: 0 - } - ], - worktreesByRepo: { [worktree.repoId]: [worktree] }, - activeRepoId: worktree.repoId, - activeView: 'terminal', - activeWorktreeId: worktree.id, - activeTabId: 'tab-1', - activeTabType: 'terminal', - tabsByWorktree: { [worktree.id]: [] }, - ptyIdsByTabId: {}, - everActivatedWorktreeIds: new Set([worktree.id]), - hideAutomationGeneratedWorkspaces: true, - markWorktreeVisited: vi.fn(), - recordWorktreeVisit: vi.fn(), - refreshGitHubForWorktreeIfStale: vi.fn(), - revealWorktreeInSidebar - }) + seedAutomationWorktreeState(worktree, { revealWorktreeInSidebar }) activateAndRevealWorktree(worktree.id) expect(useAppStore.getState().hideAutomationGeneratedWorkspaces).toBe(false) expect(revealWorktreeInSidebar).toHaveBeenCalledWith(worktree.id) }) + + it('keeps workspace-list filters while the activity view is showing', () => { + const worktree = makeAutomationWorktree() + seedAutomationWorktreeState(worktree, { + sidebarBody: 'agents', + filterRepoIds: ['other-repo'] + }) + + activateAndRevealWorktree(worktree.id) + + const state = useAppStore.getState() + expect(state.hideAutomationGeneratedWorkspaces).toBe(true) + expect(state.filterRepoIds).toEqual(['other-repo']) + expect(state.sidebarBody).toBe('agents') + expect(state.pendingRevealWorktree).toBeNull() + }) + + it('leaves the activity view to lift filters and reveal when the caller asks for the list', () => { + const worktree = makeAutomationWorktree() + seedAutomationWorktreeState(worktree, { sidebarBody: 'agents' }) + + activateAndRevealWorktree(worktree.id, { showWorkspaceList: true }) + + const state = useAppStore.getState() + expect(state.sidebarBody).toBe('workspaces') + expect(state.hideAutomationGeneratedWorkspaces).toBe(false) + expect(state.pendingRevealWorktree?.worktreeId).toBe(worktree.id) + }) + + it('stays in the activity view when a blocked folder activation fails', () => { + const getFreshFolderWorkspacePathStatus = vi.fn(() => ({ + path: '/gone', + exists: false, + reason: 'missing' as const + })) + useAppStore.setState({ + sidebarBody: 'agents', + folderWorkspaces: [ + { + id: 'folder-1', + projectGroupId: 'group-1', + name: 'gone', + folderPath: '/gone', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 0, + updatedAt: 0 + } + ], + getFreshFolderWorkspacePathStatus + }) + + expect(activateAndRevealWorkspace('folder:folder-1', { showWorkspaceList: true })).toBe(false) + expect(getFreshFolderWorkspacePathStatus).toHaveBeenCalled() + expect(useAppStore.getState().sidebarBody).toBe('agents') + }) }) diff --git a/src/renderer/src/lib/worktree-activation-sidebar-filters.ts b/src/renderer/src/lib/worktree-activation-sidebar-filters.ts new file mode 100644 index 00000000000..06c934bb4bd --- /dev/null +++ b/src/renderer/src/lib/worktree-activation-sidebar-filters.ts @@ -0,0 +1,26 @@ +import type { Worktree } from '../../../shared/worktree/types' +import { useAppStore } from '@/store' +import { isDetachedHeadWorkspace } from '@/components/sidebar/visible-worktrees' +import { revealRepoInProjectFilter } from '@/components/sidebar/project-filter-reveal' + +/** Lifts the workspace-list filters hiding an activated worktree; its reveal needs the card rendered, else it silently no-ops. */ +export function liftSidebarFiltersHidingWorktree(wt: Worktree): void { + const state = useAppStore.getState() + // Not in the activity view: the reveal is skipped there, so lifting would only discard filters. + if (state.sidebarBody === 'agents') { + return + } + revealRepoInProjectFilter(state, wt.repoId) + if ( + state.hideAutomationGeneratedWorkspaces && + wt.automationProvenance?.kind === 'created-by-automation' + ) { + state.setHideAutomationGeneratedWorkspaces(false) + } + if (state.hideCliCreatedWorkspaces && wt.cliProvenance?.kind === 'created-by-cli') { + state.setHideCliCreatedWorkspaces(false) + } + if (state.hideDetachedHeadWorkspaces && isDetachedHeadWorkspace(wt)) { + state.setHideDetachedHeadWorkspaces(false) + } +} diff --git a/src/renderer/src/lib/worktree-activation-surface-selection.ts b/src/renderer/src/lib/worktree-activation-surface-selection.ts index 9b78a23cd09..566ad5c3e69 100644 --- a/src/renderer/src/lib/worktree-activation-surface-selection.ts +++ b/src/renderer/src/lib/worktree-activation-surface-selection.ts @@ -26,6 +26,8 @@ export type WorktreeActivationOptions = WorktreeActivationSurfaceSelection & { sidebarRevealBehavior?: PendingSidebarWorktreeReveal['behavior'] notifyHostRuntime?: boolean revealInSidebar?: boolean + /** Explicit request to see the workspace in the list: leave the activity view once activation commits. */ + showWorkspaceList?: boolean executionHostId?: ExecutionHostId backendStartupTerminalSpawned?: boolean /** Install a preserved fallback startup beside setup/default terminals already seeded. */ diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index f63b1189097..d8351a9f27a 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -18,8 +18,6 @@ import { getFolderWorkspacePathStatusTitle } from './folder-workspace-path-status' import { toast } from 'sonner' -import { isDetachedHeadWorkspace } from '@/components/sidebar/visible-worktrees' -import { revealRepoInProjectFilter } from '@/components/sidebar/project-filter-reveal' import type { ExecutionHostId } from '../../../shared/execution-host' import { findFolderWorkspaceOwner } from './folder-workspace-runtime-owner' import type { WorktreeStartupPayload } from '@/lib/worktree-startup-payload' @@ -33,6 +31,7 @@ import { type WorktreeActivationSurfaceSelection } from './worktree-activation-surface-selection' import { gateAndReseedEmptyWorkspace } from './worktree-activation-gated-empty-reseed' +import { liftSidebarFiltersHidingWorktree } from './worktree-activation-sidebar-filters' /** * Shared activation sequence used by the worktree palette and add-repo/worktree dialogs. @@ -84,6 +83,7 @@ export function activateAndRevealFolderWorkspace( opts?: WorktreeActivationSurfaceSelection & { sidebarRevealBehavior?: PendingSidebarWorktreeReveal['behavior'] revealInSidebar?: boolean + showWorkspaceList?: boolean startup?: WorktreeStartupPayload runtimeEnvironmentId?: string | null executionHostId?: ExecutionHostId @@ -165,6 +165,9 @@ export function activateAndRevealFolderWorkspace( seedUserDefaultSurface ) + if (opts?.showWorkspaceList) { + state.setSidebarBody('workspaces') + } if (opts?.revealInSidebar !== false) { state.revealWorktreeInSidebar( workspaceKey, @@ -286,21 +289,12 @@ export function activateAndRevealWorktree( useAppStore.getState().queueTabInitialCwd(primaryTabId, opts.initialCwd) } - // 5. Lift the sidebar filters hiding the target — reveal needs the card rendered, else it silently no-ops. + // 5. Explicit list requests leave the activity view only now that activation has committed. + if (opts?.showWorkspaceList) { + state.setSidebarBody('workspaces') + } if (opts?.clearSidebarFilters !== false) { - revealRepoInProjectFilter(state, wt.repoId) - if ( - state.hideAutomationGeneratedWorkspaces && - wt.automationProvenance?.kind === 'created-by-automation' - ) { - state.setHideAutomationGeneratedWorkspaces(false) - } - if (state.hideCliCreatedWorkspaces && wt.cliProvenance?.kind === 'created-by-cli') { - state.setHideCliCreatedWorkspaces(false) - } - if (state.hideDetachedHeadWorkspaces && isDetachedHeadWorkspace(wt)) { - state.setHideDetachedHeadWorkspaces(false) - } + liftSidebarFiltersHidingWorktree(wt) } // 6. Reveal in sidebar @@ -340,6 +334,7 @@ export function activateAndRevealWorkspace( opts?: WorktreeActivationSurfaceSelection & { executionHostId?: ExecutionHostId revealInSidebar?: boolean + showWorkspaceList?: boolean /** Worktree-only: folder workspaces are never filter-hidden. */ clearSidebarFilters?: boolean } diff --git a/src/renderer/src/lib/worktree-jump-navigation.test.ts b/src/renderer/src/lib/worktree-jump-navigation.test.ts index af8072ac9c4..18e0e45d114 100644 --- a/src/renderer/src/lib/worktree-jump-navigation.test.ts +++ b/src/renderer/src/lib/worktree-jump-navigation.test.ts @@ -29,8 +29,6 @@ describe('worktree jump navigation', () => { mocks.getVisibleWorktreeShortcutTargets.mockReturnValue([]) mocks.worktreePassesSidebarFilters.mockReturnValue(false) mocks.getState.mockReturnValue({ - sidebarBody: 'agents', - setSidebarBody: vi.fn(), worktreesByRepo: { repo: [] }, showSleepingWorkspaces: true, filterRepoIds: ['other-repo'], @@ -58,18 +56,17 @@ describe('worktree jump navigation', () => { expect(mocks.worktreePassesSidebarFilters).not.toHaveBeenCalled() expect(mocks.warning).not.toHaveBeenCalled() expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('repo::/deleted', { - navigationIntent: 'user-open' + navigationIntent: 'user-open', + showWorkspaceList: true }) }) it('switches the left sidebar to Spaces and warns when filters hide the target', () => { - const state = mocks.getState() - expect(jumpToWorktreeFromSidebar('repo::/target')).toBe(true) - expect(state.setSidebarBody).toHaveBeenCalledWith('workspaces') expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('repo::/target', { navigationIntent: 'user-open', + showWorkspaceList: true, revealInSidebar: false, clearSidebarFilters: false }) @@ -102,7 +99,8 @@ describe('worktree jump navigation', () => { expect(jumpToWorktreeFromSidebar('wt-collapsed')).toBe(true) expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('wt-collapsed', { - navigationIntent: 'user-open' + navigationIntent: 'user-open', + showWorkspaceList: true }) expect(mocks.warning).not.toHaveBeenCalled() }) @@ -127,6 +125,7 @@ describe('worktree jump navigation', () => { expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('repo::/target', { navigationIntent: 'user-open', + showWorkspaceList: true, revealInSidebar: false, clearSidebarFilters: false, executionHostId: 'ssh:beta' @@ -135,24 +134,20 @@ describe('worktree jump navigation', () => { }) it('routes folder workspaces through the workspace dispatcher without a filter check', () => { - const state = mocks.getState() - expect(jumpToWorktreeFromSidebar('folder:folder-1', { executionHostId: 'local' })).toBe(true) expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('folder:folder-1', { navigationIntent: 'user-open', + showWorkspaceList: true, executionHostId: 'local' }) // Folder workspaces never get the filter-hidden treatment. expect(mocks.worktreePassesSidebarFilters).not.toHaveBeenCalled() - expect(state.setSidebarBody).toHaveBeenCalledWith('workspaces') }) it('propagates a blocked folder-workspace activation as failure', () => { - const state = mocks.getState() mocks.activateAndRevealWorkspace.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 38721b3337b..cb3685c94b9 100644 --- a/src/renderer/src/lib/worktree-jump-navigation.ts +++ b/src/renderer/src/lib/worktree-jump-navigation.ts @@ -51,8 +51,6 @@ export function jumpToWorktreeFromSidebar( worktreeId: string, options?: { executionHostId?: ExecutionHostId } ): boolean { - const state = useAppStore.getState() - // Folder workspaces aren't in the worktree filter pipeline; only git worktrees can be filter-hidden. const hiddenBeforeActivation = parseWorkspaceKey(worktreeId)?.type !== 'folder' && @@ -61,6 +59,7 @@ export function jumpToWorktreeFromSidebar( // Why the workspace dispatcher: it owns the folder-vs-worktree split and the folder path-status gate. const activated = activateAndRevealWorkspace(worktreeId, { navigationIntent: 'user-open', + showWorkspaceList: true, ...(hiddenBeforeActivation ? { revealInSidebar: false, clearSidebarFilters: false } : {}), ...(options?.executionHostId ? { executionHostId: options.executionHostId } : {}) }) @@ -68,9 +67,6 @@ export function jumpToWorktreeFromSidebar( return false } - // The worktree list is the Spaces/Projects sidebar body; jump actions should always expose it. - state.setSidebarBody?.('workspaces') - const hiddenAfterActivation = hiddenBeforeActivation && wasHiddenBySidebarFilters(worktreeId, options?.executionHostId) if (hiddenBeforeActivation && !hiddenAfterActivation) { diff --git a/src/renderer/src/store/slices/ui-agent-send-target.test.ts b/src/renderer/src/store/slices/ui-agent-send-target.test.ts index 6d49bb1baa5..071d83ff1cc 100644 --- a/src/renderer/src/store/slices/ui-agent-send-target.test.ts +++ b/src/renderer/src/store/slices/ui-agent-send-target.test.ts @@ -197,6 +197,24 @@ describe('createUISlice agent send target mode', () => { }) }) + it('switches out of the activity view because send targets render on workspace cards', () => { + const store = createAgentSendStore() + seedAgentSendState(store) + store.getState().setSidebarBody('agents') + + store.getState().openAgentSendPopoverTargetMode({ + id: 'send-1', + worktreeId, + source: 'diff-notes', + prompt: 'Review this', + label: 'All unsent notes', + launchSource: 'notes_send' + }) + + expect(store.getState().sidebarBody).toBe('workspaces') + expect(store.getState().pendingRevealWorktree).toMatchObject({ worktreeId }) + }) + it('disables sidebar target rows that need permission', async () => { const store = createAgentSendStore() seedAgentSendState(store) diff --git a/src/renderer/src/store/slices/ui-hydration-view-layout.test.ts b/src/renderer/src/store/slices/ui-hydration-view-layout.test.ts index c6219d1be5c..fd01ee2d2a7 100644 --- a/src/renderer/src/store/slices/ui-hydration-view-layout.test.ts +++ b/src/renderer/src/store/slices/ui-hydration-view-layout.test.ts @@ -50,17 +50,28 @@ beforeEach(() => { }) describe('sidebar reveal actions', () => { - it('switch the sidebar body back to Spaces so the worktree list can consume the reveal', () => { + it('skip reveals while the activity view is showing instead of switching bodies', () => { const store = createUIStore() store.getState().setSidebarBody('agents') store.getState().revealWorktreeInSidebar('wt-1', { highlight: true }) + store.getState().revealSidebarRow('repo:r1') + + expect(store.getState().sidebarBody).toBe('agents') + expect(store.getState().pendingRevealWorktree).toBeNull() + expect(store.getState().pendingRevealSidebarRow).toBeNull() + }) + + it('reveal after an explicit switch to the workspace list', () => { + const store = createUIStore() + store.getState().setSidebarBody('agents') + + store.getState().setSidebarBody('workspaces') + store.getState().revealWorktreeInSidebar('wt-1', { highlight: true }) + store.getState().revealSidebarRow('repo:r1') + expect(store.getState().sidebarBody).toBe('workspaces') expect(store.getState().pendingRevealWorktree?.worktreeId).toBe('wt-1') - - store.getState().setSidebarBody('agents') - store.getState().revealSidebarRow('repo:r1') - expect(store.getState().sidebarBody).toBe('workspaces') expect(store.getState().pendingRevealSidebarRow?.rowKey).toBe('repo:r1') }) }) diff --git a/src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts b/src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts index 815ae36b523..8a9e627788a 100644 --- a/src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts +++ b/src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts @@ -557,6 +557,9 @@ describe('createUISlice hydratePersistedUI', () => { expect(store.getState().agentsVisibleHostIds).toBeNull() expect(store.getState().agentsFilterRepoIds).toEqual([]) + expect(store.getState().agentsHideWorkspacesFromOtherDevices).toBe(false) + expect(store.getState().agentsHideAutomationGeneratedWorkspaces).toBe(false) + expect(store.getState().agentsHideCliCreatedWorkspaces).toBe(false) expect(store.getState().agentsShowChildAgents).toBe(false) expect(store.getState().agentsCompactMode).toBe(true) expect(store.getState().agentsShowSearch).toBe(true) @@ -564,6 +567,24 @@ describe('createUISlice hydratePersistedUI', () => { expect(store.getState().agentsGroupBy).toBe('status') }) + it('restores the agents workspace-origin filters independently of the workspace-nav ones', () => { + const store = createUIStore() + + store.getState().hydratePersistedUI( + makePersistedUI({ + agentsHideWorkspacesFromOtherDevices: true, + agentsHideAutomationGeneratedWorkspaces: true, + agentsHideCliCreatedWorkspaces: true, + hideCliCreatedWorkspaces: false + }) + ) + + expect(store.getState().agentsHideWorkspacesFromOtherDevices).toBe(true) + expect(store.getState().agentsHideAutomationGeneratedWorkspaces).toBe(true) + expect(store.getState().agentsHideCliCreatedWorkspaces).toBe(true) + expect(store.getState().hideCliCreatedWorkspaces).toBe(false) + }) + it('restores a hidden agents search field', () => { const store = createUIStore() diff --git a/src/renderer/src/store/slices/ui/sidebar-body-writers.test.ts b/src/renderer/src/store/slices/ui/sidebar-body-writers.test.ts new file mode 100644 index 00000000000..1935eea6f8c --- /dev/null +++ b/src/renderer/src/store/slices/ui/sidebar-body-writers.test.ts @@ -0,0 +1,65 @@ +import { readFileSync, readdirSync } from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * Leaving the activity view is a user decision: the bell toggles the body, and only explicit + * "show me this in the workspace list" requests switch it (activation only via showWorkspaceList). + * Reveals must never write sidebarBody, or every incidental activation kicks the user out. + */ +const RENDERER_ROOT = path.resolve(import.meta.dirname, '../../..') + +const SLICE_DEFINITION_FILES = [ + 'store/slices/ui/ui-slice-contract-preferences.ts', + 'store/slices/ui/ui-slice-preference-actions.ts' +] + +const BODY_WRITER_FILES = [ + // The bell toggle. + 'components/sidebar/SidebarHeader.tsx', + // Explicit workspace-list requests. + 'components/sidebar/use-workspace-reveal-body-redirect.ts', + 'components/use-worktree-jump-palette-selection-actions.ts', + 'lib/worktree-activation.ts', + 'store/slices/ui/ui-slice-agent-actions.ts' +] + +function collectSourceFiles(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + if (entry.name !== 'node_modules') { + collectSourceFiles(full, out) + } + continue + } + if (/\.tsx?$/.test(entry.name) && !/\.(test|spec)\.tsx?$/.test(entry.name)) { + out.push(full) + } + } + return out +} + +function filesMatching(pattern: RegExp): string[] { + return ( + collectSourceFiles(RENDERER_ROOT) + .map((file) => ({ file, source: readFileSync(file, 'utf8') })) + // Test harnesses stub the actions without being callers. + .filter(({ source }) => pattern.test(source) && !source.includes("from 'vitest'")) + .map(({ file }) => file) + .map((file) => path.relative(RENDERER_ROOT, file).split(path.sep).join('/')) + .sort() + ) +} + +describe('sidebarBody writers', () => { + it('only the slice defines sidebarBody state writes', () => { + expect(filesMatching(/\bsidebarBody\s*:/)).toEqual(SLICE_DEFINITION_FILES) + }) + + it('only the bell toggle and explicit workspace-list requests call setSidebarBody', () => { + expect(filesMatching(/\bsetSidebarBody\b/)).toEqual( + [...SLICE_DEFINITION_FILES, ...BODY_WRITER_FILES].sort() + ) + }) +}) diff --git a/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts index 7af82053993..6c0ba2a5b78 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts @@ -76,6 +76,8 @@ export function createUiAgentActions( targets.some((target) => target.status === 'eligible') && (previousMode?.id !== args.id || previousMode.worktreeId !== args.worktreeId) ) { + // Why switch: the send targets render on workspace cards, not activity rows. + get().setSidebarBody('workspaces') get().revealWorktreeInSidebar(args.worktreeId, { behavior: 'auto', highlight: true }) } }, diff --git a/src/renderer/src/store/slices/ui/ui-slice-agents-view-preference-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-agents-view-preference-actions.ts new file mode 100644 index 00000000000..098fd5c455f --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-agents-view-preference-actions.ts @@ -0,0 +1,63 @@ +import type { UISlice, UISliceSet } from './ui-slice-contract' +import { + DEFAULT_AGENTS_GROUP_BY, + DEFAULT_AGENTS_READ_FILTER +} from '../../../../../shared/agents-view-thread-filters' +import { normalizeVisibleExecutionHostIds } from '../../../../../shared/execution-host' + +/** Agents-view preferences; each setter persists immediately and stays separate from workspace-nav state. */ +export function createAgentsViewPreferenceActions(set: UISliceSet): Partial { + return { + agentsVisibleHostIds: null, + setAgentsVisibleHostIds: (ids) => { + const agentsVisibleHostIds = normalizeVisibleExecutionHostIds(ids) + set({ agentsVisibleHostIds }) + window.api.ui.set({ agentsVisibleHostIds }).catch(console.error) + }, + agentsFilterRepoIds: [], + setAgentsFilterRepoIds: (ids) => { + set({ agentsFilterRepoIds: ids }) + window.api.ui.set({ agentsFilterRepoIds: [...ids] }).catch(console.error) + }, + agentsHideWorkspacesFromOtherDevices: false, + setAgentsHideWorkspacesFromOtherDevices: (v) => { + set({ agentsHideWorkspacesFromOtherDevices: v }) + window.api.ui.set({ agentsHideWorkspacesFromOtherDevices: v }).catch(console.error) + }, + agentsHideAutomationGeneratedWorkspaces: false, + setAgentsHideAutomationGeneratedWorkspaces: (v) => { + set({ agentsHideAutomationGeneratedWorkspaces: v }) + window.api.ui.set({ agentsHideAutomationGeneratedWorkspaces: v }).catch(console.error) + }, + agentsHideCliCreatedWorkspaces: false, + setAgentsHideCliCreatedWorkspaces: (v) => { + set({ agentsHideCliCreatedWorkspaces: v }) + window.api.ui.set({ agentsHideCliCreatedWorkspaces: v }).catch(console.error) + }, + agentsShowChildAgents: false, + setAgentsShowChildAgents: (v) => { + set({ agentsShowChildAgents: v }) + window.api.ui.set({ agentsShowChildAgents: v }).catch(console.error) + }, + agentsCompactMode: true, + setAgentsCompactMode: (v) => { + set({ agentsCompactMode: v }) + window.api.ui.set({ agentsCompactMode: v }).catch(console.error) + }, + agentsShowSearch: true, + setAgentsShowSearch: (v) => { + set({ agentsShowSearch: v }) + window.api.ui.set({ agentsShowSearch: v }).catch(console.error) + }, + agentsReadFilter: DEFAULT_AGENTS_READ_FILTER, + setAgentsReadFilter: (v) => { + set({ agentsReadFilter: v }) + window.api.ui.set({ agentsReadFilter: v }).catch(console.error) + }, + agentsGroupBy: DEFAULT_AGENTS_GROUP_BY, + setAgentsGroupBy: (v) => { + set({ agentsGroupBy: v }) + window.api.ui.set({ agentsGroupBy: v }).catch(console.error) + } + } +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts b/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts index 085dd44c2b3..e8b9ff8eec8 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts @@ -71,6 +71,12 @@ export type UISlicePreferences = { setAgentsVisibleHostIds: (ids: VisibleWorkspaceHostIds) => void agentsFilterRepoIds: readonly string[] setAgentsFilterRepoIds: (ids: readonly string[]) => void + agentsHideWorkspacesFromOtherDevices: boolean + setAgentsHideWorkspacesFromOtherDevices: (v: boolean) => void + agentsHideAutomationGeneratedWorkspaces: boolean + setAgentsHideAutomationGeneratedWorkspaces: (v: boolean) => void + agentsHideCliCreatedWorkspaces: boolean + setAgentsHideCliCreatedWorkspaces: (v: boolean) => void agentsShowChildAgents: boolean setAgentsShowChildAgents: (v: boolean) => void agentsCompactMode: boolean 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 dea5b0c2e80..c8da9a29dcc 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 @@ -157,6 +157,10 @@ export function createUiHydrationActions(set: UISliceSet, _get: UISliceGet): Par ? persistedAgentsFilterRepoIds : persistedAgentsFilterRepoIds.filter((repoId) => validRepoIds.has(repoId)) ), + agentsHideWorkspacesFromOtherDevices: ui.agentsHideWorkspacesFromOtherDevices === true, + agentsHideAutomationGeneratedWorkspaces: + ui.agentsHideAutomationGeneratedWorkspaces === true, + agentsHideCliCreatedWorkspaces: ui.agentsHideCliCreatedWorkspaces === true, agentsShowChildAgents: ui.agentsShowChildAgents === true, agentsCompactMode: ui.agentsCompactMode !== false, agentsShowSearch: ui.agentsShowSearch !== false, diff --git a/src/renderer/src/store/slices/ui/ui-slice-preference-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-preference-actions.ts index 205a40acf9f..27a30efbc53 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-preference-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-preference-actions.ts @@ -1,8 +1,5 @@ import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' -import { - DEFAULT_AGENTS_GROUP_BY, - DEFAULT_AGENTS_READ_FILTER -} from '../../../../../shared/agents-view-thread-filters' +import { createAgentsViewPreferenceActions } from './ui-slice-agents-view-preference-actions' import { DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE, DEFAULT_SHOW_SLEEPING_WORKSPACES, @@ -167,42 +164,7 @@ export function createUiPreferenceActions(set: UISliceSet, get: UISliceGet): Par filterRepoIds: [], setFilterRepoIds: (ids) => set({ filterRepoIds: ids }), - agentsVisibleHostIds: null, - setAgentsVisibleHostIds: (ids) => { - const agentsVisibleHostIds = normalizeVisibleExecutionHostIds(ids) - set({ agentsVisibleHostIds }) - window.api.ui.set({ agentsVisibleHostIds }).catch(console.error) - }, - agentsFilterRepoIds: [], - setAgentsFilterRepoIds: (ids) => { - set({ agentsFilterRepoIds: ids }) - window.api.ui.set({ agentsFilterRepoIds: [...ids] }).catch(console.error) - }, - agentsShowChildAgents: false, - setAgentsShowChildAgents: (v) => { - set({ agentsShowChildAgents: v }) - window.api.ui.set({ agentsShowChildAgents: v }).catch(console.error) - }, - agentsCompactMode: true, - setAgentsCompactMode: (v) => { - set({ agentsCompactMode: v }) - window.api.ui.set({ agentsCompactMode: v }).catch(console.error) - }, - agentsShowSearch: true, - setAgentsShowSearch: (v) => { - set({ agentsShowSearch: v }) - window.api.ui.set({ agentsShowSearch: v }).catch(console.error) - }, - agentsReadFilter: DEFAULT_AGENTS_READ_FILTER, - setAgentsReadFilter: (v) => { - set({ agentsReadFilter: v }) - window.api.ui.set({ agentsReadFilter: v }).catch(console.error) - }, - agentsGroupBy: DEFAULT_AGENTS_GROUP_BY, - setAgentsGroupBy: (v) => { - set({ agentsGroupBy: v }) - window.api.ui.set({ agentsGroupBy: v }).catch(console.error) - }, + ...createAgentsViewPreferenceActions(set), collapsedGroups: new Set(), toggleCollapsedGroup: (key) => diff --git a/src/renderer/src/store/slices/ui/ui-slice-surface-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-surface-actions.ts index 7157a243d7a..40d55a16812 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-surface-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-surface-actions.ts @@ -148,28 +148,34 @@ export function createUiSurfaceActions(set: UISliceSet, _get: UISliceGet): Parti pendingRevealWorktree: null, pendingRevealSidebarRow: null, - // Why sidebarBody here: the worktree list (and its reveal consumer) is unmounted while the - // Agents body is showing, so a reveal that does not switch bodies silently no-ops. + // Why skipped rather than queued: the worktree list is unmounted in the activity view, and a + // reveal that switched bodies would kick the user out on every incidental activation. revealWorktreeInSidebar: (worktreeId, options) => - set({ - sidebarBody: 'workspaces', - pendingRevealWorktree: { - worktreeId, - ...(options?.executionHostId ? { executionHostId: options.executionHostId } : {}), - behavior: options?.behavior ?? 'smooth', - ...(options?.highlight ? { highlight: true } : {}), - ...(options?.beginRename ? { beginRename: true } : {}) - } - }), + set((state) => + state.sidebarBody === 'agents' + ? state + : { + pendingRevealWorktree: { + worktreeId, + ...(options?.executionHostId ? { executionHostId: options.executionHostId } : {}), + behavior: options?.behavior ?? 'smooth', + ...(options?.highlight ? { highlight: true } : {}), + ...(options?.beginRename ? { beginRename: true } : {}) + } + } + ), revealSidebarRow: (rowKey, options) => - set({ - sidebarBody: 'workspaces', - pendingRevealSidebarRow: { - rowKey, - behavior: options?.behavior ?? 'smooth', - ...(options?.highlight === false ? {} : { highlight: true }) - } - }), + set((state) => + state.sidebarBody === 'agents' + ? state + : { + pendingRevealSidebarRow: { + rowKey, + behavior: options?.behavior ?? 'smooth', + ...(options?.highlight === false ? {} : { highlight: true }) + } + } + ), clearPendingRevealWorktreeId: () => set({ pendingRevealWorktree: null }), clearPendingRevealSidebarRow: () => set({ pendingRevealSidebarRow: null }), scrollToDiffCommentId: null, diff --git a/src/renderer/src/web/preload-api/web-preference-normalization.ts b/src/renderer/src/web/preload-api/web-preference-normalization.ts index fd6239ba269..37b57d0e401 100644 --- a/src/renderer/src/web/preload-api/web-preference-normalization.ts +++ b/src/renderer/src/web/preload-api/web-preference-normalization.ts @@ -71,6 +71,9 @@ export function mergeHostWebUIState( workspaceHostOrder: local.workspaceHostOrder, agentsVisibleHostIds: local.agentsVisibleHostIds, agentsFilterRepoIds: local.agentsFilterRepoIds, + agentsHideWorkspacesFromOtherDevices: local.agentsHideWorkspacesFromOtherDevices === true, + agentsHideAutomationGeneratedWorkspaces: local.agentsHideAutomationGeneratedWorkspaces === true, + agentsHideCliCreatedWorkspaces: local.agentsHideCliCreatedWorkspaces === true, agentsShowChildAgents: local.agentsShowChildAgents, agentsCompactMode: local.agentsCompactMode, agentsShowSearch: local.agentsShowSearch, diff --git a/src/renderer/src/web/web-preload-api-ui-pairing-local-fields.test.ts b/src/renderer/src/web/web-preload-api-ui-pairing-local-fields.test.ts new file mode 100644 index 00000000000..e1f9ede3818 --- /dev/null +++ b/src/renderer/src/web/web-preload-api-ui-pairing-local-fields.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + PAIRING_LOCAL_UI_FIELDS, + type PairingLocalUiField +} from '../../../shared/pairing-local-ui-fields' +import type { PersistedUIState } from '../../../shared/persisted-ui-state-types' +import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import { + installBrowserGlobals, + writeStoredRuntimeEnvironment +} from './web-preload-api-test-harness' + +describe('web UI preload API pairing-local fields', () => { + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + vi.doUnmock('./web-runtime-client') + }) + + // Census-driven, matching the host-side seam tests: a field added to PAIRING_LOCAL_UI_FIELDS + // without wiring the web read seam fails here rather than shipping. The host sample differs from + // the browser's for every field, so only the pin makes this pass. + const browserLocalUiSamples: Record = { + automationHostFilter: { kind: 'host', hostKey: 'browser-local-host-key' }, + hideWorkspacesFromOtherDevices: true, + manualRepoOrder: [{ hostId: 'runtime:web-env-1', repoId: 'repo-b' }], + workspaceHostOrder: ['runtime:web-env-1', 'local'], + agentsVisibleHostIds: ['runtime:web-env-1'], + agentsFilterRepoIds: ['repo-b'], + agentsHideWorkspacesFromOtherDevices: true, + agentsHideAutomationGeneratedWorkspaces: true, + agentsHideCliCreatedWorkspaces: true, + agentsShowChildAgents: true, + agentsCompactMode: false, + agentsShowSearch: false, + agentsReadFilter: 'unread', + agentsGroupBy: 'project', + activityClearedAtByPaneKey: { 'tab-1:leaf-1': 123 }, + manuallyUnreadTurnsByPaneKey: { 'tab-1:leaf-1': 321 } + } + const hostUiSamples: Record = { + automationHostFilter: { kind: 'all' }, + hideWorkspacesFromOtherDevices: false, + manualRepoOrder: [{ hostId: 'local', repoId: 'repo-a' }], + workspaceHostOrder: ['local', 'ssh:box'], + agentsVisibleHostIds: ['local'], + agentsFilterRepoIds: ['repo-a'], + agentsHideWorkspacesFromOtherDevices: false, + agentsHideAutomationGeneratedWorkspaces: false, + agentsHideCliCreatedWorkspaces: false, + agentsShowChildAgents: false, + agentsCompactMode: true, + agentsShowSearch: true, + agentsReadFilter: 'all', + agentsGroupBy: 'status', + activityClearedAtByPaneKey: { 'tab-2:leaf-2': 456 }, + manuallyUnreadTurnsByPaneKey: { 'tab-2:leaf-2': 654 } + } + + it.each(PAIRING_LOCAL_UI_FIELDS.map((field) => [field] as const))( + 'keeps the browser-local %s and never sends it to the host', + async (field) => { + const runtimeCalls: { method: string; params: unknown }[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise> { + runtimeCalls.push({ method, params }) + return Promise.resolve({ + id: method, + ok: true, + result: { ui: { [field]: hostUiSamples[field] } }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const browserLocal = { [field]: browserLocalUiSamples[field] } as Partial + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + globals.storage.setItem('orca.web.ui.v1', JSON.stringify(browserLocal)) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await globals.window.api.ui.set({ ...browserLocal, sidebarWidth: 280 }) + + expect(runtimeCalls[0]).toEqual({ method: 'ui.set', params: { sidebarWidth: 280 } }) + await expect(globals.window.api.ui.get()).resolves.toMatchObject(browserLocal) + expect(JSON.parse(globals.storage.getItem('orca.web.ui.v1') ?? '{}')).toMatchObject( + browserLocal + ) + } + ) +}) diff --git a/src/renderer/src/web/web-preload-api-ui.test.ts b/src/renderer/src/web/web-preload-api-ui.test.ts index 751bf4e9bb6..649cf4d8b28 100644 --- a/src/renderer/src/web/web-preload-api-ui.test.ts +++ b/src/renderer/src/web/web-preload-api-ui.test.ts @@ -1,10 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { FeatureInteractionState } from '../../../shared/feature-interactions' -import { - PAIRING_LOCAL_UI_FIELDS, - type PairingLocalUiField -} from '../../../shared/pairing-local-ui-fields' -import type { PersistedUIState } from '../../../shared/persisted-ui-state-types' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import type { ManualRepoOrderEntry } from '../../../shared/ui-chrome-types' import { @@ -457,77 +452,6 @@ describe('web UI preload API', () => { }) }) - // Census-driven, matching the host-side seam tests: a field added to PAIRING_LOCAL_UI_FIELDS - // without wiring the web read seam fails here rather than shipping. The host sample differs from - // the browser's for every field, so only the pin makes this pass. - const browserLocalUiSamples: Record = { - automationHostFilter: { kind: 'host', hostKey: 'browser-local-host-key' }, - hideWorkspacesFromOtherDevices: true, - manualRepoOrder: [{ hostId: 'runtime:web-env-1', repoId: 'repo-b' }], - workspaceHostOrder: ['runtime:web-env-1', 'local'], - agentsVisibleHostIds: ['runtime:web-env-1'], - agentsFilterRepoIds: ['repo-b'], - agentsShowChildAgents: true, - agentsCompactMode: false, - agentsShowSearch: false, - agentsReadFilter: 'unread', - agentsGroupBy: 'project', - activityClearedAtByPaneKey: { 'tab-1:leaf-1': 123 }, - manuallyUnreadTurnsByPaneKey: { 'tab-1:leaf-1': 321 } - } - const hostUiSamples: Record = { - automationHostFilter: { kind: 'all' }, - hideWorkspacesFromOtherDevices: false, - manualRepoOrder: [{ hostId: 'local', repoId: 'repo-a' }], - workspaceHostOrder: ['local', 'ssh:box'], - agentsVisibleHostIds: ['local'], - agentsFilterRepoIds: ['repo-a'], - agentsShowChildAgents: false, - agentsCompactMode: true, - agentsShowSearch: true, - agentsReadFilter: 'all', - agentsGroupBy: 'status', - activityClearedAtByPaneKey: { 'tab-2:leaf-2': 456 }, - manuallyUnreadTurnsByPaneKey: { 'tab-2:leaf-2': 654 } - } - - it.each(PAIRING_LOCAL_UI_FIELDS.map((field) => [field] as const))( - 'keeps the browser-local %s and never sends it to the host', - async (field) => { - const runtimeCalls: { method: string; params: unknown }[] = [] - vi.doMock('./web-runtime-client', () => ({ - WebRuntimeClient: class { - call(method: string, params?: unknown): Promise> { - runtimeCalls.push({ method, params }) - return Promise.resolve({ - id: method, - ok: true, - result: { ui: { [field]: hostUiSamples[field] } }, - _meta: { runtimeId: 'runtime-1' } - }) - } - - close(): void {} - } - })) - - const browserLocal = { [field]: browserLocalUiSamples[field] } as Partial - const globals = installBrowserGlobals('Linux') - writeStoredRuntimeEnvironment(globals.storage) - globals.storage.setItem('orca.web.ui.v1', JSON.stringify(browserLocal)) - const { installWebPreloadApi } = await import('./web-preload-api') - installWebPreloadApi() - - await globals.window.api.ui.set({ ...browserLocal, sidebarWidth: 280 }) - - expect(runtimeCalls[0]).toEqual({ method: 'ui.set', params: { sidebarWidth: 280 } }) - await expect(globals.window.api.ui.get()).resolves.toMatchObject(browserLocal) - expect(JSON.parse(globals.storage.getItem('orca.web.ui.v1') ?? '{}')).toMatchObject( - browserLocal - ) - } - ) - it('union-merges local contextual tour seen ids when ui.get returns stale host state', async () => { vi.doMock('./web-runtime-client', () => ({ WebRuntimeClient: class { diff --git a/src/shared/constants.ts b/src/shared/constants.ts index f26fe093676..2b3b82b5a37 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -222,6 +222,9 @@ export function getDefaultUIState(): PersistedUIState { filterRepoIds: [], agentsVisibleHostIds: null, agentsFilterRepoIds: [], + agentsHideWorkspacesFromOtherDevices: false, + agentsHideAutomationGeneratedWorkspaces: false, + agentsHideCliCreatedWorkspaces: false, agentsShowChildAgents: false, agentsCompactMode: true, agentsShowSearch: true, diff --git a/src/shared/pairing-local-ui-fields.test.ts b/src/shared/pairing-local-ui-fields.test.ts index d3758c0246e..780b471a1ca 100644 --- a/src/shared/pairing-local-ui-fields.test.ts +++ b/src/shared/pairing-local-ui-fields.test.ts @@ -12,6 +12,9 @@ describe('pairing-local UI fields', () => { 'workspaceHostOrder', 'agentsVisibleHostIds', 'agentsFilterRepoIds', + 'agentsHideWorkspacesFromOtherDevices', + 'agentsHideAutomationGeneratedWorkspaces', + 'agentsHideCliCreatedWorkspaces', 'agentsShowChildAgents', 'agentsCompactMode', 'agentsShowSearch', diff --git a/src/shared/pairing-local-ui-fields.ts b/src/shared/pairing-local-ui-fields.ts index 60478018064..65e036699ac 100644 --- a/src/shared/pairing-local-ui-fields.ts +++ b/src/shared/pairing-local-ui-fields.ts @@ -15,6 +15,9 @@ export const PAIRING_LOCAL_UI_FIELDS = [ // Agent View filters and presentation belong to each client's host catalog and viewport. 'agentsVisibleHostIds', 'agentsFilterRepoIds', + 'agentsHideWorkspacesFromOtherDevices', + 'agentsHideAutomationGeneratedWorkspaces', + 'agentsHideCliCreatedWorkspaces', 'agentsShowChildAgents', 'agentsCompactMode', 'agentsShowSearch', diff --git a/src/shared/persisted-ui-state-types.ts b/src/shared/persisted-ui-state-types.ts index 6eaa295eed4..f7708d50206 100644 --- a/src/shared/persisted-ui-state-types.ts +++ b/src/shared/persisted-ui-state-types.ts @@ -81,6 +81,10 @@ export type PersistedUIState = { agentsVisibleHostIds?: VisibleWorkspaceHostIds /** Agents-view project filter; empty = all projects. Separate from filterRepoIds (workspace nav). */ agentsFilterRepoIds?: string[] + /** Agents-view workspace-origin filters; separate from the workspace-nav hide flags. Absent means off. */ + agentsHideWorkspacesFromOtherDevices?: boolean + agentsHideAutomationGeneratedWorkspaces?: boolean + agentsHideCliCreatedWorkspaces?: boolean /** Agents-view: include child (orchestration-dispatched) agent threads. Absent means off. */ agentsShowChildAgents?: boolean /** Agents-view compact thread rows. Absent means on. */ diff --git a/src/shared/rpc-contract/client-ui-params.ts b/src/shared/rpc-contract/client-ui-params.ts index 0295247a2e8..08113208e23 100644 --- a/src/shared/rpc-contract/client-ui-params.ts +++ b/src/shared/rpc-contract/client-ui-params.ts @@ -145,6 +145,9 @@ export const UiUpdateFields = z visibleWorkspaceHostIds: z.array(z.string()).nullable().optional(), agentsVisibleHostIds: z.array(z.string()).nullable().optional(), agentsFilterRepoIds: StringArray.optional(), + agentsHideWorkspacesFromOtherDevices: z.boolean().optional(), + agentsHideAutomationGeneratedWorkspaces: z.boolean().optional(), + agentsHideCliCreatedWorkspaces: z.boolean().optional(), agentsShowChildAgents: z.boolean().optional(), agentsCompactMode: z.boolean().optional(), agentsShowSearch: z.boolean().optional(),