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 d6c08ad44cf..2e7ac3e93b1 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 @@ -49,6 +49,8 @@ describe('client UI RPC pairing-local field seams', () => { agentsFilterRepoIds: ['repo-a'], agentsShowChildAgents: true, agentsCompactMode: false, + agentsReadFilter: 'unread', + agentsGroupBy: 'project', activityClearedAtByPaneKey: { 'tab-1:leaf-1': 123 }, manuallyUnreadTurnsByPaneKey: { 'tab-1:leaf-1': 321 } } diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index edb3f651c32..688de5c3f36 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -126,6 +126,8 @@ const UiUpdateFields = z agentsFilterRepoIds: StringArray.optional(), agentsShowChildAgents: z.boolean().optional(), agentsCompactMode: z.boolean().optional(), + agentsReadFilter: z.enum(['all', 'unread']).optional(), + agentsGroupBy: z.enum(['none', 'status', 'project', 'worktree', 'agent']).optional(), workspaceHostOrder: z.array(z.string()).optional(), automationHostFilter: z .union([ diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index 83581d4cf10..44c1f398122 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -21,22 +21,20 @@ import { useActivityTerminalLoadingLabel, useActivityTerminalPortalStatus } from './activity-terminal-portal-status' -import type { - ActivityGroupBy, - ActivityTerminalPortalSlotId, - ThreadReadFilter -} from './activity-thread-types' +import type { ActivityTerminalPortalSlotId } from './activity-thread-types' export * from './activity-prototype-page-exports' export default function ActivityPrototypePage(): React.JSX.Element { - const [readFilter, setReadFilter] = useState('all') - const [groupBy, setGroupBy] = useState('status') const [query, setQuery] = useState('') const activityFilterInputRef = useRef(null) // Why: bounds auto mark-read to one acknowledgement per selected thread turn. const autoAcknowledgedTurnRef = useRef(null) // Why store-backed: persisted preferences shared with the sidebar agents list. + const readFilter = useAppStore((s) => s.agentsReadFilter) + const setReadFilter = useAppStore((s) => s.setAgentsReadFilter) + const groupBy = useAppStore((s) => s.agentsGroupBy) + const setGroupBy = useAppStore((s) => s.setAgentsGroupBy) const compactMode = useAppStore((s) => s.agentsCompactMode) const setCompactMode = useAppStore((s) => s.setAgentsCompactMode) const showChildAgents = useAppStore((s) => s.agentsShowChildAgents) diff --git a/src/renderer/src/components/activity/activity-thread-types.ts b/src/renderer/src/components/activity/activity-thread-types.ts index 7225a71b935..ed73642c8eb 100644 --- a/src/renderer/src/components/activity/activity-thread-types.ts +++ b/src/renderer/src/components/activity/activity-thread-types.ts @@ -9,8 +9,8 @@ import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { Worktree } from '../../../../shared/worktree/types' import type { ActivityPortalReadinessStatus } from './activity-portal-readiness-oscillation' -export type ThreadReadFilter = 'all' | 'unread' -export type ActivityGroupBy = 'none' | 'status' | 'project' | 'worktree' | 'agent' +export type { ActivityGroupBy, ThreadReadFilter } from '../../../../shared/ui-chrome-types' + export type ActivityEventState = Extract export type ActivityHookLiveAgentState = Extract< AgentStatusState, diff --git a/src/renderer/src/components/sidebar/SidebarAgentsList.tsx b/src/renderer/src/components/sidebar/SidebarAgentsList.tsx index 5d82b56253a..ca5a8c5bce1 100644 --- a/src/renderer/src/components/sidebar/SidebarAgentsList.tsx +++ b/src/renderer/src/components/sidebar/SidebarAgentsList.tsx @@ -41,7 +41,7 @@ export default function SidebarAgentsList({ }: SidebarAgentsListProps): React.JSX.Element { // The search row is owned here and mounts conditionally, so subscribe this host to locale changes. useTranslation() - // Why store-backed: these are persisted preferences (agents* UI fields), unlike the momentary read filter/search. + // Why store-backed: these are persisted preferences (agents* UI fields), unlike the momentary search. const compactMode = useAppStore((s) => s.agentsCompactMode) const setCompactMode = useAppStore((s) => s.setAgentsCompactMode) const showChildAgents = useAppStore((s) => s.agentsShowChildAgents) diff --git a/src/renderer/src/components/sidebar/index.tsx b/src/renderer/src/components/sidebar/index.tsx index 90c31999bd3..8b837b5ef39 100644 --- a/src/renderer/src/components/sidebar/index.tsx +++ b/src/renderer/src/components/sidebar/index.tsx @@ -11,7 +11,6 @@ import WorkspaceKanbanDrawer from './WorkspaceKanbanDrawer' import type { VirtualizedScrollAnchor } from '@/hooks/useVirtualizedScrollAnchor' import { cn } from '@/lib/utils' import { FolderPlus, Loader2 } from 'lucide-react' -import type { ActivityGroupBy, ThreadReadFilter } from '@/components/activity/activity-thread-types' import { ActivityThreadCollapseContext } from '@/components/activity/activity-thread-collapse-context' import { useSidebarProjectDrop } from './useSidebarProjectDrop' import { useWorkspaceBoardPanel } from './useWorkspaceBoardPanel' @@ -59,8 +58,10 @@ function Sidebar({ const showAgentDashboard = settings?.experimentalAgentDashboardPopout === true const agentDashboardDrawerOpen = useAppStore((s) => s.agentDashboardDrawerOpen) const setAgentDashboardDrawerOpen = useAppStore((s) => s.setAgentDashboardDrawerOpen) - const [agentReadFilter, setAgentReadFilter] = React.useState('all') - const [agentGroupBy, setAgentGroupBy] = React.useState('status') + const agentReadFilter = useAppStore((s) => s.agentsReadFilter) + const setAgentReadFilter = useAppStore((s) => s.setAgentsReadFilter) + const agentGroupBy = useAppStore((s) => s.agentsGroupBy) + const setAgentGroupBy = useAppStore((s) => s.setAgentsGroupBy) const [agentQuery, setAgentQuery] = React.useState('') const [agentOptionsTarget, setAgentOptionsTarget] = React.useState(null) const agentsScrollTopRef = React.useRef(0) 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 642297bb109..6d3a559c582 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 @@ -551,6 +551,27 @@ describe('createUISlice hydratePersistedUI', () => { expect(store.getState().agentsFilterRepoIds).toEqual([]) expect(store.getState().agentsShowChildAgents).toBe(false) expect(store.getState().agentsCompactMode).toBe(true) + expect(store.getState().agentsReadFilter).toBe('all') + expect(store.getState().agentsGroupBy).toBe('status') + }) + + it('restores the persisted agents read filter and grouping, rejecting unknown values', () => { + const store = createUIStore() + + store + .getState() + .hydratePersistedUI(makePersistedUI({ agentsReadFilter: 'unread', agentsGroupBy: 'project' })) + expect(store.getState().agentsReadFilter).toBe('unread') + expect(store.getState().agentsGroupBy).toBe('project') + + store.getState().hydratePersistedUI( + makePersistedUI({ + agentsReadFilter: 'bogus' as unknown as PersistedUIState['agentsReadFilter'], + agentsGroupBy: 'bogus' as unknown as PersistedUIState['agentsGroupBy'] + }) + ) + expect(store.getState().agentsReadFilter).toBe('all') + expect(store.getState().agentsGroupBy).toBe('status') }) it('sanitizes malformed agents repo filters before the repo catalog loads', () => { 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 2e764e5cd79..1162f399cd5 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 @@ -1,9 +1,11 @@ import type { PersistedUIState } from '../../../../../shared/persisted-ui-state-types' import type { + ActivityGroupBy, AgentActivityDisplayMode, ManualRepoOrderEntry, ProjectOrderBy, StatusBarItem, + ThreadReadFilter, WorktreeCardMode, WorktreeCardProperty, WorkspaceHostOrder, @@ -71,6 +73,10 @@ export type UISlicePreferences = { setAgentsShowChildAgents: (v: boolean) => void agentsCompactMode: boolean setAgentsCompactMode: (v: boolean) => void + agentsReadFilter: ThreadReadFilter + setAgentsReadFilter: (v: ThreadReadFilter) => void + agentsGroupBy: ActivityGroupBy + setAgentsGroupBy: (v: ActivityGroupBy) => void collapsedGroups: Set toggleCollapsedGroup: (key: string) => void worktreeCardProperties: WorktreeCardProperty[] 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 f44774b3770..08adff8e2a3 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 @@ -20,6 +20,10 @@ import { normalizeWorktreeCardProperties, normalizeAgentActivityDisplayMode } from '../../../../../shared/constants' +import { + normalizeActivityGroupBy, + normalizeThreadReadFilter +} from '../../../../../shared/agents-view-thread-filters' import { clampWorkspaceBoardColumnWidth, clampWorkspaceBoardOpacity, @@ -182,6 +186,8 @@ export function createUiHydrationActions(set: UISliceSet, _get: UISliceGet): Par ), agentsShowChildAgents: ui.agentsShowChildAgents === true, agentsCompactMode: ui.agentsCompactMode !== false, + agentsReadFilter: normalizeThreadReadFilter(ui.agentsReadFilter), + agentsGroupBy: normalizeActivityGroupBy(ui.agentsGroupBy), collapsedGroups: new Set(ui.collapsedGroups ?? []), uiZoomLevel: ui.uiZoomLevel ?? 0, editorFontZoomLevel: ui.editorFontZoomLevel ?? 0, 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 971c64dfa52..9a9dc5947b1 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,4 +1,8 @@ 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 { DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE, DEFAULT_SHOW_SLEEPING_WORKSPACES, @@ -170,6 +174,16 @@ export function createUiPreferenceActions(set: UISliceSet, get: UISliceGet): Par set({ agentsCompactMode: v }) window.api.ui.set({ agentsCompactMode: 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) + }, collapsedGroups: new Set(), toggleCollapsedGroup: (key) => 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 9ae3ad00d4a..be7e02f029a 100644 --- a/src/renderer/src/web/preload-api/web-preference-normalization.ts +++ b/src/renderer/src/web/preload-api/web-preference-normalization.ts @@ -73,6 +73,8 @@ export function mergeHostWebUIState( agentsFilterRepoIds: local.agentsFilterRepoIds, agentsShowChildAgents: local.agentsShowChildAgents, agentsCompactMode: local.agentsCompactMode, + agentsReadFilter: local.agentsReadFilter, + agentsGroupBy: local.agentsGroupBy, activityClearedAtByPaneKey: local.activityClearedAtByPaneKey, manuallyUnreadTurnsByPaneKey: local.manuallyUnreadTurnsByPaneKey } satisfies Record & Partial 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 94e9ba8e1eb..a01ffbad567 100644 --- a/src/renderer/src/web/web-preload-api-ui.test.ts +++ b/src/renderer/src/web/web-preload-api-ui.test.ts @@ -469,6 +469,8 @@ describe('web UI preload API', () => { agentsFilterRepoIds: ['repo-b'], agentsShowChildAgents: true, agentsCompactMode: false, + agentsReadFilter: 'unread', + agentsGroupBy: 'project', activityClearedAtByPaneKey: { 'tab-1:leaf-1': 123 }, manuallyUnreadTurnsByPaneKey: { 'tab-1:leaf-1': 321 } } @@ -481,6 +483,8 @@ describe('web UI preload API', () => { agentsFilterRepoIds: ['repo-a'], agentsShowChildAgents: false, agentsCompactMode: true, + agentsReadFilter: 'all', + agentsGroupBy: 'status', activityClearedAtByPaneKey: { 'tab-2:leaf-2': 456 }, manuallyUnreadTurnsByPaneKey: { 'tab-2:leaf-2': 654 } } diff --git a/src/shared/agents-view-thread-filters.ts b/src/shared/agents-view-thread-filters.ts new file mode 100644 index 00000000000..7f28caa1ee3 --- /dev/null +++ b/src/shared/agents-view-thread-filters.ts @@ -0,0 +1,22 @@ +import type { ActivityGroupBy, ThreadReadFilter } from './ui-chrome-types' + +export const DEFAULT_AGENTS_READ_FILTER: ThreadReadFilter = 'all' +export const DEFAULT_AGENTS_GROUP_BY: ActivityGroupBy = 'status' + +const ACTIVITY_GROUP_BY_VALUES: ReadonlySet = new Set([ + 'none', + 'status', + 'project', + 'worktree', + 'agent' +]) + +export function normalizeThreadReadFilter(value: unknown): ThreadReadFilter { + return value === 'unread' || value === 'all' ? value : DEFAULT_AGENTS_READ_FILTER +} + +export function normalizeActivityGroupBy(value: unknown): ActivityGroupBy { + return typeof value === 'string' && ACTIVITY_GROUP_BY_VALUES.has(value) + ? (value as ActivityGroupBy) + : DEFAULT_AGENTS_GROUP_BY +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 9d26b140dec..bb2f5940f0e 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -11,6 +11,7 @@ import { DEFAULT_STATUS_BAR_ITEMS } from './status-bar-defaults' import type { VoiceSettings } from './speech-types' import { cloneDefaultWorkspaceStatuses } from './workspace-statuses' import { DEFAULT_WORKTREE_CARD_PROPERTIES } from './worktree/card-properties' +import { DEFAULT_AGENTS_GROUP_BY, DEFAULT_AGENTS_READ_FILTER } from './agents-view-thread-filters' import { DEFAULT_USAGE_PERCENTAGE_DISPLAY } from './usage-percentage-display' import { DEFAULT_STATUS_BAR_USAGE_MODE } from './status-bar-usage-mode' import { buildDefaultSettings } from './default-global-settings' @@ -273,6 +274,8 @@ export function getDefaultUIState(): PersistedUIState { agentsFilterRepoIds: [], agentsShowChildAgents: false, agentsCompactMode: true, + agentsReadFilter: DEFAULT_AGENTS_READ_FILTER, + agentsGroupBy: DEFAULT_AGENTS_GROUP_BY, collapsedGroups: [], uiZoomLevel: 0, editorFontZoomLevel: 0, diff --git a/src/shared/pairing-local-ui-fields.test.ts b/src/shared/pairing-local-ui-fields.test.ts index ffc7baafe07..35bd5f33331 100644 --- a/src/shared/pairing-local-ui-fields.test.ts +++ b/src/shared/pairing-local-ui-fields.test.ts @@ -14,6 +14,8 @@ describe('pairing-local UI fields', () => { 'agentsFilterRepoIds', 'agentsShowChildAgents', 'agentsCompactMode', + 'agentsReadFilter', + 'agentsGroupBy', 'activityClearedAtByPaneKey', 'manuallyUnreadTurnsByPaneKey' ]) diff --git a/src/shared/pairing-local-ui-fields.ts b/src/shared/pairing-local-ui-fields.ts index d925c3094d8..f642bb2b821 100644 --- a/src/shared/pairing-local-ui-fields.ts +++ b/src/shared/pairing-local-ui-fields.ts @@ -17,6 +17,8 @@ export const PAIRING_LOCAL_UI_FIELDS = [ 'agentsFilterRepoIds', 'agentsShowChildAgents', 'agentsCompactMode', + 'agentsReadFilter', + 'agentsGroupBy', 'activityClearedAtByPaneKey', 'manuallyUnreadTurnsByPaneKey' ] as const satisfies readonly (keyof PersistedUIState)[] diff --git a/src/shared/persisted-ui-state-types.ts b/src/shared/persisted-ui-state-types.ts index 14e97bc34fc..b6d40480017 100644 --- a/src/shared/persisted-ui-state-types.ts +++ b/src/shared/persisted-ui-state-types.ts @@ -8,6 +8,7 @@ import type { StatusBarUsageMode } from './status-bar-usage-mode' import type { PersistedTrustedOrcaHooks } from './orca-yaml-hook-types' import type { CustomPet } from './pet-types' import type { + ActivityGroupBy, AgentActivityDisplayMode, ManualRepoOrderEntry, ProjectOrderBy, @@ -15,6 +16,7 @@ import type { RightSidebarTab, StatusBarItem, TaskResumeState, + ThreadReadFilter, TopLevelView, VisibleWorkspaceHostIds, WorkspaceHostOrder, @@ -81,6 +83,10 @@ export type PersistedUIState = { agentsShowChildAgents?: boolean /** Agents-view compact thread rows. Absent means on. */ agentsCompactMode?: boolean + /** Agents-view unread-only thread filter. Absent means 'all'. */ + agentsReadFilter?: ThreadReadFilter + /** Agents-view thread grouping. Absent means 'status'. */ + agentsGroupBy?: ActivityGroupBy collapsedGroups: string[] uiZoomLevel: number editorFontZoomLevel: number diff --git a/src/shared/ui-chrome-types.ts b/src/shared/ui-chrome-types.ts index 90a12cf5b02..376b80b9d9a 100644 --- a/src/shared/ui-chrome-types.ts +++ b/src/shared/ui-chrome-types.ts @@ -49,6 +49,9 @@ export type WorktreeCardMode = 'Default' | 'Compact' export type AgentActivityDisplayMode = 'compact' | 'full' +export type ThreadReadFilter = 'all' | 'unread' +export type ActivityGroupBy = 'none' | 'status' | 'project' | 'worktree' | 'agent' + export type StatusBarItem = | 'claude' | 'codex'