mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 00:02:05 +00:00
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:
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
])
|
||||
|
||||
@@ -17,6 +17,8 @@ export const PAIRING_LOCAL_UI_FIELDS = [
|
||||
'agentsFilterRepoIds',
|
||||
'agentsShowChildAgents',
|
||||
'agentsCompactMode',
|
||||
'agentsReadFilter',
|
||||
'agentsGroupBy',
|
||||
'activityClearedAtByPaneKey',
|
||||
'manuallyUnreadTurnsByPaneKey'
|
||||
] as const satisfies readonly (keyof PersistedUIState)[]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user