fix(activity): persist the agents unread filter and grouping

The Agents view's "Show unread threads only" toggle and Group-by select were
plain component state in the sidebar and the Activity page, so both reset on
every mount — including app restart — while their neighbours in the same
toolbar (compact rows, show child agents) survived via the persisted UI store.

Promote both to `agentsReadFilter` / `agentsGroupBy` persisted UI preferences,
wired through the same seams as `agentsCompactMode`: shared type, default,
strict client RPC schema, pairing-local field census, web read pin, store
contract/actions, and hydration normalizers that reject unknown values. Both
consumers now read the store, so the sidebar and the Activity page share one
filter the way they already share compact mode.
This commit is contained in:
Jinjing
2026-09-02 14:29:52 -07:00
parent 817827be5b
commit 719eb3f326
18 changed files with 107 additions and 13 deletions
@@ -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 }
}
@@ -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([
@@ -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<ThreadReadFilter>('all')
const [groupBy, setGroupBy] = useState<ActivityGroupBy>('status')
const [query, setQuery] = useState('')
const activityFilterInputRef = useRef<HTMLInputElement | null>(null)
// Why: bounds auto mark-read to one acknowledgement per selected thread turn.
const autoAcknowledgedTurnRef = useRef<string | null>(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)
@@ -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<AgentStatusState, 'done' | 'blocked' | 'waiting'>
export type ActivityHookLiveAgentState = Extract<
AgentStatusState,
@@ -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)
@@ -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<ThreadReadFilter>('all')
const [agentGroupBy, setAgentGroupBy] = React.useState<ActivityGroupBy>('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<HTMLDivElement | null>(null)
const agentsScrollTopRef = React.useRef(0)
@@ -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', () => {
@@ -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<string>
toggleCollapsedGroup: (key: string) => void
worktreeCardProperties: WorktreeCardProperty[]
@@ -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,
@@ -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<string>(),
toggleCollapsedGroup: (key) =>
@@ -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<PairingLocalUiField, unknown> & Partial<PersistedUIState>
@@ -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 }
}
+22
View File
@@ -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<string> = new Set<ActivityGroupBy>([
'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
}
+3
View File
@@ -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,
@@ -14,6 +14,8 @@ describe('pairing-local UI fields', () => {
'agentsFilterRepoIds',
'agentsShowChildAgents',
'agentsCompactMode',
'agentsReadFilter',
'agentsGroupBy',
'activityClearedAtByPaneKey',
'manuallyUnreadTurnsByPaneKey'
])
+2
View File
@@ -17,6 +17,8 @@ export const PAIRING_LOCAL_UI_FIELDS = [
'agentsFilterRepoIds',
'agentsShowChildAgents',
'agentsCompactMode',
'agentsReadFilter',
'agentsGroupBy',
'activityClearedAtByPaneKey',
'manuallyUnreadTurnsByPaneKey'
] as const satisfies readonly (keyof PersistedUIState)[]
+6
View File
@@ -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
+3
View File
@@ -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'