From d9cc1d05286ff3d49f33f95ae7ca5291919d45dd Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:36:20 -0400 Subject: [PATCH] feat(activity): filters, matching header, row right-click menu, and stay in the activity view (#25376) * fix(sidebar): stop workspace reveals from leaving the activity view Reveals are skipped while the activity view is showing, and activation no longer lifts workspace-list filters there. Only explicit requests (Go to workspace, reveal-current-workspace, Cmd+J project row, agent send target, Cmd+1-9) switch to the workspace list via showSidebarWorkspaceList. * test(sidebar): keep test harnesses out of the sidebarBody writer census * feat(activity): workspace-origin filters and a matching options trigger Activity view gains its own persisted Hide automation-created, Hide CLI-created and Hide other-client agents toggles, applied through the existing activity scope filter so the selected-row exemption and identity-preserving return keep working. The options trigger now uses the workspace view's SlidersHorizontal icon and shared numeric badge, counting every active activity filter, and Add project stays visible so no header button shifts between views. * feat(activity): right-click menu on activity rows Open, Mark as read/unread, Go to workspace (real workspaces only), Copy (agent title, plus branch and path for real workspaces), and Clear from list for finished threads, reusing the row's existing actions and clearActivityThread. * fix(sidebar): leave the activity view only once activation commits Go to workspace and Cmd+1-9 switched the sidebar before activating, so a blocked folder activation still left the activity view. Activation now owns the switch via showWorkspaceList, applied after its failure gates. The separate showSidebarWorkspaceList action and the one-line sidebar-body-reveal module go away: setSidebarBody is the single writer, and the census pins its callers. * fix(activity): close the row preview when its right-click menu opens * fix(activity): keep the row preview closed while its right-click menu is open * fix(ui): give right-click menus the same solid surface as dropdowns * fix(activity): match the agent row menu to the workspace row menu * fix(activity): offer the same copy items as the workspace row menu --- .../client-ui-pairing-local-fields.test.ts | 3 + .../ActivityThreadOptionsMenu.test.tsx | 86 ++++++++- .../activity-scope-filter-controls.tsx | 102 +++++++---- .../activity/activity-scope-filter.test.ts | 65 +++++++ .../activity/activity-scope-filter.ts | 37 +++- .../activity/activity-thread-actions.test.ts | 1 + .../activity-thread-context-menu.test.tsx | 167 ++++++++++++++++++ .../activity/activity-thread-context-menu.tsx | 121 +++++++++++++ .../activity-thread-hover-card.test.tsx | 17 ++ .../activity/activity-thread-hover-card.tsx | 10 +- .../activity/activity-thread-options-menu.tsx | 123 +++++++------ .../activity/activity-thread-row.tsx | 5 +- .../activity/activity-thread-virtual-row.tsx | 48 +++-- .../activity/use-agent-pane-threads.ts | 35 +++- .../components/sidebar/SidebarHeader.test.tsx | 4 +- .../sidebar/SidebarWorkspaceOptionsMenu.tsx | 12 +- .../sidebar/options-filter-count-badge.tsx | 23 +++ .../sidebar/sidebar-header-actions.tsx | 15 +- .../sidebar/workspace-options-menu-items.tsx | 3 +- .../src/components/ui/context-menu.tsx | 4 +- ...worktree-jump-palette-selection-actions.ts | 2 + .../workspace-shortcut-ipc-bridge.ts | 2 + .../useIpcEvents-cmd-j-digit-chord.test.ts | 4 +- src/renderer/src/i18n/locales/en.json | 21 ++- src/renderer/src/i18n/locales/es.json | 1 - src/renderer/src/i18n/locales/fr.json | 1 - src/renderer/src/i18n/locales/ja.json | 1 - src/renderer/src/i18n/locales/ko.json | 1 - src/renderer/src/i18n/locales/zh.json | 1 - ...ktree-activation-automation-filter.test.ts | 121 ++++++++++--- .../worktree-activation-sidebar-filters.ts | 26 +++ .../worktree-activation-surface-selection.ts | 2 + src/renderer/src/lib/worktree-activation.ts | 27 ++- .../src/lib/worktree-jump-navigation.test.ts | 19 +- .../src/lib/worktree-jump-navigation.ts | 6 +- .../store/slices/ui-agent-send-target.test.ts | 18 ++ .../slices/ui-hydration-view-layout.test.ts | 21 ++- ...ui-hydration-workspace-preferences.test.ts | 21 +++ .../slices/ui/sidebar-body-writers.test.ts | 65 +++++++ .../store/slices/ui/ui-slice-agent-actions.ts | 2 + ...ui-slice-agents-view-preference-actions.ts | 63 +++++++ .../ui/ui-slice-contract-preferences.ts | 6 + .../slices/ui/ui-slice-hydration-actions.ts | 4 + .../slices/ui/ui-slice-preference-actions.ts | 42 +---- .../slices/ui/ui-slice-surface-actions.ts | 46 ++--- .../web-preference-normalization.ts | 3 + ...reload-api-ui-pairing-local-fields.test.ts | 100 +++++++++++ .../src/web/web-preload-api-ui.test.ts | 76 -------- src/shared/constants.ts | 3 + src/shared/pairing-local-ui-fields.test.ts | 3 + src/shared/pairing-local-ui-fields.ts | 3 + src/shared/persisted-ui-state-types.ts | 4 + src/shared/rpc-contract/client-ui-params.ts | 3 + 53 files changed, 1240 insertions(+), 359 deletions(-) create mode 100644 src/renderer/src/components/activity/activity-thread-context-menu.test.tsx create mode 100644 src/renderer/src/components/activity/activity-thread-context-menu.tsx create mode 100644 src/renderer/src/components/sidebar/options-filter-count-badge.tsx create mode 100644 src/renderer/src/lib/worktree-activation-sidebar-filters.ts create mode 100644 src/renderer/src/store/slices/ui/sidebar-body-writers.test.ts create mode 100644 src/renderer/src/store/slices/ui/ui-slice-agents-view-preference-actions.ts create mode 100644 src/renderer/src/web/web-preload-api-ui-pairing-local-fields.test.ts 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(),