Repository: PostHog/posthog
Pull request #30821: feat: modal filters for fun and great good
Author: login: veryayskiy
Created: 2025-04-04T12:46:07Z | Merged: 2025-04-14T12:17:16Z

## Problems

#### Problem 1
Our filters block is very small and very difficult to use.
What's even more important - it's very difficult to scale.

#### Problem 2
People are confused in terms of playlists.
Atm, the playlists are combinations of pinned recordings and set of filters.

## Changes

#### Change 1
First of all we move the filters into a modal window to give some space
Before:
<img width="339" alt="Screenshot 2025-04-09 at 16 54 03" src="https://github.com/user-attachments/assets/04537433-9b56-450e-ac89-0a0fc24dfe19" />

After:
<img width="782" alt="Screenshot 2025-04-09 at 16 54 25" src="https://github.com/user-attachments/assets/9eb49ef3-934b-4f00-9d51-301c16fc7b4b" />

#### Change 2
@pauldambra did an incredible job in splitting the logic of playlists and saved filters so let's keep that trend and add saved filters tab right in the filters.
<img width="770" alt="Screenshot 2025-04-09 at 16 56 09" src="https://github.com/user-attachments/assets/e70e2a08-8c18-4a3f-8902-3576cc4c358f" />

Also, let's add an ability to save the filters right in the filters box if we do not have any filters containing the same fields:
<img width="748" alt="Screenshot 2025-04-09 at 16 56 22" src="https://github.com/user-attachments/assets/7702b54a-aff4-45e4-ba0f-e55ba8eb7dd0" />





Diff:
diff --git a/frontend/src/lib/components/TaxonomicFilter/types.ts b/frontend/src/lib/components/TaxonomicFilter/types.ts
index 2a60835347af..43a8a7081a2b 100644
--- a/frontend/src/lib/components/TaxonomicFilter/types.ts
+++ b/frontend/src/lib/components/TaxonomicFilter/types.ts
@@ -35,7 +35,7 @@ export interface TaxonomicFilterProps {
     eventNames?: string[]
     schemaColumns?: DatabaseSchemaField[]
     height?: number
-    width?: number
+    width?: number | string
     popoverEnabled?: boolean
     selectFirstItem?: boolean
     autoSelectItem?: boolean
diff --git a/frontend/src/lib/components/UniversalFilters/UniversalFilters.tsx b/frontend/src/lib/components/UniversalFilters/UniversalFilters.tsx
index e74e881b764e..f6ac446381e8 100644
--- a/frontend/src/lib/components/UniversalFilters/UniversalFilters.tsx
+++ b/frontend/src/lib/components/UniversalFilters/UniversalFilters.tsx
@@ -162,8 +162,24 @@ const AddFilterButton = (props: Omit<LemonButtonProps, 'onClick' | 'sideAction'
     )
 }
 
+const PureTaxonomicFilter = (): JSX.Element => {
+    const { taxonomicGroupTypes } = useValues(universalFiltersLogic)
+    const { addGroupFilter } = useActions(universalFiltersLogic)
+
+    return (
+        <TaxonomicFilter
+            width="100%"
+            onChange={(taxonomicGroup, value, item, originalQuery) => {
+                addGroupFilter(taxonomicGroup, value, item, originalQuery)
+            }}
+            taxonomicGroupTypes={taxonomicGroupTypes}
+        />
+    )
+}
+
 UniversalFilters.Group = Group
 UniversalFilters.Value = Value
 UniversalFilters.AddFilterButton = AddFilterButton
+UniversalFilters.PureTaxonomicFilter = PureTaxonomicFilter
 
 export default UniversalFilters
diff --git a/frontend/src/scenes/session-recordings/filters/RecordingsUniversalFilters.tsx b/frontend/src/scenes/session-recordings/filters/RecordingsUniversalFilters.tsx
index d7d73a05a817..b1a0edb3ca55 100644
--- a/frontend/src/scenes/session-recordings/filters/RecordingsUniversalFilters.tsx
+++ b/frontend/src/scenes/session-recordings/filters/RecordingsUniversalFilters.tsx
@@ -1,12 +1,14 @@
 import { IconClock, IconEye, IconFilter, IconHide, IconRevert } from '@posthog/icons'
-import { LemonBadge, LemonButton, LemonButtonProps } from '@posthog/lemon-ui'
+import { LemonBadge, LemonButton, LemonButtonProps, LemonInput, LemonModal, LemonTabs } from '@posthog/lemon-ui'
 import clsx from 'clsx'
+import equal from 'fast-deep-equal'
 import { useActions, useMountedLogic, useValues } from 'kea'
 import { DateFilter } from 'lib/components/DateFilter/DateFilter'
 import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types'
 import UniversalFilters from 'lib/components/UniversalFilters/UniversalFilters'
 import { universalFiltersLogic } from 'lib/components/UniversalFilters/universalFiltersLogic'
 import { isUniversalGroupFilterLike } from 'lib/components/UniversalFilters/utils'
+import { eventUsageLogic } from 'lib/utils/eventUsageLogic'
 import { useEffect, useState } from 'react'
 import { TestAccountFilter } from 'scenes/insights/filters/TestAccountFilter'
 import { MaxTool } from 'scenes/max/MaxTool'
@@ -17,11 +19,14 @@ import { actionsModel } from '~/models/actionsModel'
 import { cohortsModel } from '~/models/cohortsModel'
 import { AndOrFilterSelect } from '~/queries/nodes/InsightViz/PropertyGroupFilters/AndOrFilterSelect'
 import { NodeKind } from '~/queries/schema/schema-general'
-import { RecordingUniversalFilters, UniversalFiltersGroup } from '~/types'
+import { RecordingUniversalFilters, ReplayTabs, UniversalFiltersGroup } from '~/types'
 
 import { playerSettingsLogic, TimestampFormat } from '../player/playerSettingsLogic'
 import { playlistLogic } from '../playlist/playlistLogic'
+import { createPlaylist } from '../playlist/playlistUtils'
+import { savedSessionRecordingPlaylistsLogic } from '../saved-playlists/savedSessionRecordingPlaylistsLogic'
 import { DurationFilter } from './DurationFilter'
+import { SavedFilters } from './SavedFilters'
 
 function HideRecordingsMenu(): JSX.Element {
     const { hideViewedRecordings, hideRecordingsMenuLabelFor } = useValues(playerSettingsLogic)
@@ -74,13 +79,15 @@ export const RecordingsUniversalFilters = ({
     allowReplayFlagsFilters?: boolean
     allowReplayHogQLFilters?: boolean
 }): JSX.Element => {
+    const [savedFilterName, setSavedFilterName] = useState('')
+
     useMountedLogic(cohortsModel)
     useMountedLogic(actionsModel)
 
     const durationFilter = filters.duration[0]
 
-    const { isFiltersExpanded } = useValues(playlistLogic)
-    const { setIsFiltersExpanded } = useActions(playlistLogic)
+    const { isFiltersExpanded, activeFilterTab } = useValues(playlistLogic)
+    const { setIsFiltersExpanded, setActiveFilterTab } = useActions(playlistLogic)
     const { playlistTimestampFormat } = useValues(playerSettingsLogic)
     const { setPlaylistTimestampFormat } = useActions(playerSettingsLogic)
 
@@ -102,6 +109,177 @@ export const RecordingsUniversalFilters = ({
         taxonomicGroupTypes.push(TaxonomicFilterGroupType.EventFeatureFlags)
     }
 
+    const savedFiltersLogic = savedSessionRecordingPlaylistsLogic({ tab: ReplayTabs.Playlists })
+    const { savedFilters } = useValues(savedFiltersLogic)
+    const { loadSavedFilters } = useActions(savedFiltersLogic)
+
+    const { reportRecordingPlaylistCreated } = useActions(eventUsageLogic)
+
+    const newPlaylistHandler = async (): Promise<void> => {
+        await createPlaylist({ name: savedFilterName, filters }, false)
+        reportRecordingPlaylistCreated('new')
+        loadSavedFilters()
+        setSavedFilterName('')
+    }
+
+    /** Modal footer with buttons for reset and close */
+    const ModalFooter = (): JSX.Element => {
+        return (
+            <div className="flex justify-between p-2 gap-2">
+                {activeFilterTab === 'filters' && (
+                    <LemonButton
+                        type="secondary"
+                        size="small"
+                        onClick={resetFilters}
+                        icon={<IconRevert />}
+                        tooltip="Reset any changes you've made to the filters"
+                        disabledReason={
+                            !(resetFilters && (totalFiltersCount ?? 0) > 0) ? 'No filters applied' : undefined
+                        }
+                    >
+                        Reset filters
+                    </LemonButton>
+                )}
+                <LemonButton type="primary" size="small" onClick={() => setIsFiltersExpanded(false)}>
+                    Close
+                </LemonButton>
+            </div>
+        )
+    }
+
+    const tabs = [
+        {
+            key: 'filters',
+            label: <div className="px-2">Filters</div>,
+            content: (
+                <div className={clsx('relative bg-surface-primary w-full ', className)}>
+                    <div className="flex items-center py-2">
+                        <AndOrFilterSelect
+                            value={filters.filter_group.type}
+                            onChange={(type) => {
+                                let values = filters.filter_group.values
+
+                                // set the type on the nested child when only using a single filter group
+                                const hasSingleGroup = values.length === 1
+                                if (hasSingleGroup) {
+                                    const group = values[0] as UniversalFiltersGroup
+                                    values = [{ ...group, type }]
+                                }
+
+                                setFilters({
+                                    filter_group: {
+                                        type: type,
+                                        values: values,
+                                    },
+                                })
+                            }}
+                            topLevelFilter={true}
+                            suffix={['filter', 'filters']}
+                            size="small"
+                        />
+                    </div>
+                    <div className="flex justify-between px-2 py-2 flex-wrap gap-1">
+                        <div className="flex flex-wrap gap-2 items-center">
+                            <DateFilter
+                                dateFrom={filters.date_from ?? '-3d'}
+                                dateTo={filters.date_to}
+                                onChange={(changedDateFrom, changedDateTo) => {
+                                    setFilters({
+                                        date_from: changedDateFrom,
+                                        date_to: changedDateTo,
+                                    })
+                                }}
+                                dateOptions={[
+                                    { key: 'Custom', values: [] },
+                                    { key: 'Last 24 hours', values: ['-24h'] },
+                                    { key: 'Last 3 days', values: ['-3d'] },
+                                    { key: 'Last 7 days', values: ['-7d'] },
+                                    { key: 'Last 30 days', values: ['-30d'] },
+                                    { key: 'All time', values: ['-90d'] },
+                                ]}
+                                dropdownPlacement="bottom-start"
+                                size="small"
+                            />
+                            <DurationFilter
+                                onChange={(newRecordingDurationFilter, newDurationType) => {
+                                    setFilters({
+                                        duration: [
+                                            {
+                                                ...newRecordingDurationFilter,
+                                                key: newDurationType,
+                                            },
+                                        ],
+                                    })
+                                }}
+                                recordingDurationFilter={durationFilter}
+                                durationTypeFilter={durationFilter.key}
+                                pageKey="session-recordings"
+                                size="small"
+                            />
+                        </div>
+                        <div>
+                            <TestAccountFilter
+                                size="small"
+                                filters={filters}
+                                onChange={(testFilters) =>
+                                    setFilters({
+                                        filter_test_accounts: testFilters.filter_test_accounts,
+                                    })
+                                }
+                            />
+                        </div>
+                    </div>
+                    <div className="flex flex-wrap gap-2 p-2">
+                        <UniversalFilters
+                            rootKey="session-recordings"
+                            group={filters.filter_group}
+                            taxonomicGroupTypes={taxonomicGroupTypes}
+                            onChange={(filterGroup) => setFilters({ filter_group: filterGroup })}
+                        >
+                            <RecordingsUniversalFilterGroup size="small" totalFiltersCount={totalFiltersCount} />
+                        </UniversalFilters>
+                    </div>
+                    {(totalFiltersCount ?? 0) > 0 &&
+                        !savedFilters.results?.find((filter) => equal(filter.filters, filters)) && (
+                            <div className="flex gap-2 p-2 justify-start">
+                                <LemonInput
+                                    value={savedFilterName}
+                                    placeholder="Saved filter name"
+                                    onChange={setSavedFilterName}
+                                    size="small"
+                                    autoFocus
+                                    fullWidth
+                                    onClick={(e) => {
+                                        e.stopPropagation() // Prevent dropdown from closing
+                                    }}
+                                />
+                                <LemonButton type="primary" size="xsmall" onClick={() => void newPlaylistHandler()}>
+                                    Save filters
+                                </LemonButton>
+                            </div>
+                        )}
+                </div>
+            ),
+        },
+    ]
+
+    if (savedFilters.results?.length > 0) {
+        tabs.push({
+            key: 'saved',
+            label: (
+                <div className="px-2 flex">
+                    <span>
+                        {savedFilters.results?.length ? (
+                            <LemonBadge.Number count={savedFilters.results?.length} className="mr-2" />
+                        ) : null}
+                    </span>
+                    <span>Saved filters</span>
+                </div>
+            ),
+            content: <SavedFilters setFilters={setFilters} />,
+        })
+    }
+
     return (
         <>
             <MaxTool
@@ -126,6 +304,7 @@ export const RecordingsUniversalFilters = ({
                 <>
                     <LemonButton
                         type="secondary"
+                        size="small"
                         icon={<IconFilter />}
                         onClick={() => {
                             setIsFiltersExpanded(!isFiltersExpanded)
@@ -137,109 +316,25 @@ export const RecordingsUniversalFilters = ({
                             <LemonBadge.Number count={totalFiltersCount} />
                         ) : null}
                     </LemonButton>
-                    <div
-                        className={clsx(
-                            'flex justify-center relative border-r border-l rounded-b overflow-hidden transition-all duration-200',
-                            isFiltersExpanded ? 'max-h-[1000px] opacity-100' : 'max-h-0 opacity-0'
-                        )}
+                    <LemonModal
+                        isOpen={isFiltersExpanded}
+                        onClose={(): void => {
+                            setIsFiltersExpanded(false)
+                        }}
+                        width={750}
+                        footer={<ModalFooter />}
                     >
-                        <div className={clsx('divide-y bg-surface-primary rounded-b w-full', className)}>
-                            <div className="flex items-center py-2">
-                                <AndOrFilterSelect
-                                    value={filters.filter_group.type}
-                                    onChange={(type) => {
-                                        let values = filters.filter_group.values
-
-                                        // set the type on the nested child when only using a single filter group
-                                        const hasSingleGroup = values.length === 1
-                                        if (hasSingleGroup) {
-                                            const group = values[0] as UniversalFiltersGroup
-                                            values = [{ ...group, type }]
-                                        }
-
-                                        setFilters({
-                                            filter_group: {
-                                                type: type,
-                                                values: values,
-                                            },
-                                        })
-                                    }}
-                                    topLevelFilter={true}
-                                    suffix={['filter', 'filters']}
-                                    size="xsmall"
-                                />
-                            </div>
-                            <div className="flex justify-between px-2 py-2 flex-wrap gap-1">
-                                <div className="flex flex-wrap gap-2 items-center">
-                                    <DateFilter
-                                        dateFrom={filters.date_from ?? '-3d'}
-                                        dateTo={filters.date_to}
-                                        onChange={(changedDateFrom, changedDateTo) => {
-                                            setFilters({
-                                                date_from: changedDateFrom,
-                                                date_to: changedDateTo,
-                                            })
-                                        }}
-                                        dateOptions={[
-                                            { key: 'Custom', values: [] },
-                                            { key: 'Last 24 hours', values: ['-24h'] },
-                                            { key: 'Last 3 days', values: ['-3d'] },
-                                            { key: 'Last 7 days', values: ['-7d'] },
-                                            { key: 'Last 30 days', values: ['-30d'] },
-                                            { key: 'All time', values: ['-90d'] },
-                                        ]}
-                                        dropdownPlacement="bottom-start"
-                                        size="xsmall"
-                                    />
-                                    <DurationFilter
-                                        onChange={(newRecordingDurationFilter, newDurationType) => {
-                                            setFilters({
-                                                duration: [{ ...newRecordingDurationFilter, key: newDurationType }],
-                                            })
-                                        }}
-                                        recordingDurationFilter={durationFilter}
-                                        durationTypeFilter={durationFilter.key}
-                                        pageKey="session-recordings"
-                                        size="xsmall"
-                                    />
-                                </div>
-                                <div>
-                                    <TestAccountFilter
-                                        size="xsmall"
-                                        filters={filters}
-                                        onChange={(testFilters) =>
-                                            setFilters({ filter_test_accounts: testFilters.filter_test_accounts })
-                                        }
-                                    />
-                                </div>
-                            </div>
-                            <div className="flex flex-wrap gap-2 p-2">
-                                <UniversalFilters
-                                    rootKey="session-recordings"
-                                    group={filters.filter_group}
-                                    taxonomicGroupTypes={taxonomicGroupTypes}
-                                    onChange={(filterGroup) => setFilters({ filter_group: filterGroup })}
-                                >
-                                    <RecordingsUniversalFilterGroup size="xsmall" />
-                                </UniversalFilters>
-                            </div>
-                        </div>
-                    </div>
+                        <>
+                            <LemonTabs
+                                activeKey={activeFilterTab}
+                                onChange={(activeKey) => setActiveFilterTab(activeKey)}
+                                size="small"
+                                tabs={tabs}
+                            />
+                        </>
+                    </LemonModal>
                 </>
             </MaxTool>
-            {resetFilters && (totalFiltersCount ?? 0) > 0 && (
-                <div className="flex justify-start mt-2">
-                    <LemonButton
-                        type="tertiary"
-                        size="xsmall"
-                        onClick={resetFilters}
-                        icon={<IconRevert />}
-                        tooltip="Reset any changes you've made to the filters"
-                    >
-                        Reset filters
-                    </LemonButton>
-                </div>
-            )}
             <div className="flex gap-2 mt-2 justify-between">
                 <HideRecordingsMenu />
                 <SettingsMenu
@@ -270,7 +365,13 @@ export const RecordingsUniversalFilters = ({
     )
 }
 
-const RecordingsUniversalFilterGroup = ({ size = 'small' }: { size?: LemonButtonProps['size'] }): JSX.Element => {
+const RecordingsUniversalFilterGroup = ({
+    size = 'small',
+    totalFiltersCount,
+}: {
+    size?: LemonButtonProps['size']
+    totalFiltersCount?: number
+}): JSX.Element => {
     const { filterGroup } = useValues(universalFiltersLogic)
     const { replaceGroupValue, removeGroupValue } = useActions(universalFiltersLogic)
     const [allowInitiallyOpen, setAllowInitiallyOpen] = useState(false)
@@ -283,10 +384,17 @@ const RecordingsUniversalFilterGroup = ({ size = 'small' }: { size?: LemonButton
         <>
             {filterGroup.values.map((filterOrGroup, index) => {
                 return isUniversalGroupFilterLike(filterOrGroup) ? (
-                    <UniversalFilters.Group key={index} index={index} group={filterOrGroup}>
-                        <RecordingsUniversalFilterGroup size={size} />
-                        <UniversalFilters.AddFilterButton size={size} type="secondary" />
-                    </UniversalFilters.Group>
+                    <div className="w-full">
+                        <UniversalFilters.Group key={index} index={index} group={filterOrGroup}>
+                            <div className="flex items-center gap-2 border-t py-4">
+                                {(totalFiltersCount ?? 0) > 0 && (
+                                    <span className="font-semibold">Applied filters:</span>
+                                )}
+                                <RecordingsUniversalFilterGroup size={size} totalFiltersCount={totalFiltersCount} />
+                            </div>
+                            <UniversalFilters.PureTaxonomicFilter />
+                        </UniversalFilters.Group>
+                    </div>
                 ) : (
                     <UniversalFilters.Value
                         key={index}
diff --git a/frontend/src/scenes/session-recordings/filters/SavedFilters.tsx b/frontend/src/scenes/session-recordings/filters/SavedFilters.tsx
new file mode 100644
index 000000000000..600182bf2f6a
--- /dev/null
+++ b/frontend/src/scenes/session-recordings/filters/SavedFilters.tsx
@@ -0,0 +1,97 @@
+import { LemonButton, LemonTable, LemonTableColumn, LemonTableColumns } from '@posthog/lemon-ui'
+import { useActions, useValues } from 'kea'
+import { useFeatureFlag } from 'lib/hooks/useFeatureFlag'
+import { More } from 'lib/lemon-ui/LemonButton/More'
+
+import { RecordingUniversalFilters, ReplayTabs, SessionRecordingPlaylistType } from '~/types'
+
+import { playlistLogic } from '../playlist/playlistLogic'
+import { countColumn } from '../saved-playlists/SavedSessionRecordingPlaylists'
+import { SavedSessionRecordingPlaylistsEmptyState } from '../saved-playlists/SavedSessionRecordingPlaylistsEmptyState'
+import { savedSessionRecordingPlaylistsLogic } from '../saved-playlists/savedSessionRecordingPlaylistsLogic'
+
+export function SavedFilters({
+    setFilters,
+}: {
+    setFilters: (filters: Partial<RecordingUniversalFilters>) => void
+}): JSX.Element {
+    const savedFiltersLogic = savedSessionRecordingPlaylistsLogic({ tab: ReplayTabs.Playlists })
+    const { savedFilters, savedFiltersLoading, pagination } = useValues(savedFiltersLogic)
+    const { deletePlaylist } = useActions(savedFiltersLogic)
+    const { setActiveFilterTab } = useActions(playlistLogic)
+
+    const showCountColumn = useFeatureFlag('SESSION_RECORDINGS_PLAYLIST_COUNT_COLUMN')
+
+    if (savedFiltersLoading || savedFilters.results?.length === 0) {
+        return <SavedSessionRecordingPlaylistsEmptyState />
+    }
+
+    const nameColumn = (): LemonTableColumn<SessionRecordingPlaylistType, 'name'> => {
+        return {
+            title: 'Name',
+            dataIndex: 'name',
+            render: function Render(name, { short_id, derived_name }) {
+                const filter = savedFilters.results.find((filter) => filter.short_id === short_id)
+                return (
+                    <>
+                        <div
+                            onClick={() => {
+                                if (filter && filter.filters) {
+                                    setFilters(filter.filters)
+                                    setActiveFilterTab('filters')
+                                }
+                            }}
+                            className="cursor-pointer text-current hover:text-accent"
+                        >
+                            {name || derived_name || 'Unnamed'}
+                        </div>
+                    </>
+                )
+            },
+        }
+    }
+
+    const columns: LemonTableColumns<SessionRecordingPlaylistType> = [
+        countColumn({ showCountColumn }) as LemonTableColumn<
+            SessionRecordingPlaylistType,
+            keyof SessionRecordingPlaylistType | undefined
+        >,
+        nameColumn() as LemonTableColumn<SessionRecordingPlaylistType, keyof SessionRecordingPlaylistType | undefined>,
+        {
+            width: 0,
+            render: function Render(_, playlist) {
+                return (
+                    <More
+                        overlay={
+                            <>
+                                <LemonButton
+                                    status="danger"
+                                    onClick={() => {
+                                        deletePlaylist(playlist)
+                                        if (savedFilters.results?.length === 1) {
+                                            setActiveFilterTab('filters')
+                                        }
+                                    }}
+                                    fullWidth
+                                    loading={savedFiltersLoading}
+                                >
+                                    Delete saved filter
+                                </LemonButton>
+                            </>
+                        }
+                    />
+                )
+            },
+        },
+    ]
+
+    return (
+        <LemonTable
+            loading={savedFiltersLoading}
+            dataSource={savedFilters.results}
+            columns={columns}
+            pagination={pagination}
+            noSortingCancellation
+        />
+    )
+}
diff --git a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.tsx b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.tsx
index 7ac7d0f454ed..f4c5334b087c 100644
--- a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.tsx
+++ b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.tsx
@@ -120,7 +120,6 @@ export function SessionRecordingsPlaylist({
                                 filters={filters}
                                 setFilters={setFilters}
                                 totalFiltersCount={totalFiltersCount}
-                                className="border-b"
                                 allowReplayHogQLFilters={allowHogQLFilters}
                                 allowReplayFlagsFilters={allowFlagsFilters}
                             />
diff --git a/frontend/src/scenes/session-recordings/playlist/playlistLogic.ts b/frontend/src/scenes/session-recordings/playlist/playlistLogic.ts
index b8e2ab37cbcb..93dd5aed3f06 100644
--- a/frontend/src/scenes/session-recordings/playlist/playlistLogic.ts
+++ b/frontend/src/scenes/session-recordings/playlist/playlistLogic.ts
@@ -7,6 +7,7 @@ export const playlistLogic = kea<playlistLogicType>([
     actions({
         setIsExpanded: (isExpanded: boolean) => ({ isExpanded }), // WIll be removed together with Mix (R.I.P. Mix)
         setIsFiltersExpanded: (isFiltersExpanded: boolean) => ({ isFiltersExpanded }),
+        setActiveFilterTab: (activeFilterTab: string) => ({ activeFilterTab }),
     }),
     reducers({
         isExpanded: [
@@ -16,10 +17,16 @@ export const playlistLogic = kea<playlistLogicType>([
             },
         ],
         isFiltersExpanded: [
-            true,
+            false,
             {
                 setIsFiltersExpanded: (_, { isFiltersExpanded }) => isFiltersExpanded,
             },
         ],
+        activeFilterTab: [
+            'filters',
+            {
+                setActiveFilterTab: (_, { activeFilterTab }) => activeFilterTab,
+            },
+        ],
     }),
 ])
diff --git a/frontend/src/scenes/session-recordings/saved-playlists/SavedSessionRecordingPlaylists.tsx b/frontend/src/scenes/session-recordings/saved-playlists/SavedSessionRecordingPlaylists.tsx
index e2e7fac69c7b..06da6dad50a2 100644
--- a/frontend/src/scenes/session-recordings/saved-playlists/SavedSessionRecordingPlaylists.tsx
+++ b/frontend/src/scenes/session-recordings/saved-playlists/SavedSessionRecordingPlaylists.tsx
@@ -39,7 +39,103 @@ function nameColumn(): LemonTableColumn<SessionRecordingPlaylistType, 'name'> {
     }
 }
 
-function isPlaylistRecordingsCounts(x: unknown): x is PlaylistRecordingsCounts {
+export function countColumn({
+    showCountColumn,
+}: {
+    showCountColumn: boolean
+}): LemonTableColumn<SessionRecordingPlaylistType, 'recordings_counts'> {
+    return {
+        dataIndex: 'recordings_counts',
+        title: 'Count',
+        tooltip: 'Count of recordings in the playlist',
+        isHidden: !showCountColumn,
+        width: 0,
+        render: function Render(recordings_counts) {
+            if (!isPlaylistRecordingsCounts(recordings_counts)) {
+                return null
+            }
+
+            const hasResults =
+                recordings_counts.collection.count !== null || recordings_counts.saved_filters?.count !== null
+
+            const totalPinnedCount: number | null = recordings_counts.collection.count
+            const unwatchedPinnedCount =
+                (recordings_counts.collection.count || 0) - (recordings_counts.collection.watched_count || 0)
+            const totalSavedFiltersCount = recordings_counts.saved_filters?.count || 0
+            const unwatchedSavedFiltersCount =
+                (recordings_counts.saved_filters?.count || 0) - (recordings_counts.saved_filters?.watched_count || 0)
+
+            // we don't allow both saved filters and pinned anymore
+            const isShowingSavedFilters = hasResults && !totalPinnedCount
+            const totalCount = isShowingSavedFilters ? totalSavedFiltersCount : totalPinnedCount
+            const unwatchedCount = isShowingSavedFilters ? unwatchedSavedFiltersCount : unwatchedPinnedCount
+            // if we're showing saved filters, then we might have more results
+            const hasMoreResults = isShowingSavedFilters && recordings_counts.saved_filters?.has_more
+
+            const lastRefreshedAt = isShowingSavedFilters ? recordings_counts.saved_filters?.last_refreshed_at : null
+
+            const description = isShowingSavedFilters ? 'that match these saved filters' : 'in this collection'
+
+            const tooltip = (
+                <div className="text-start">
+                    {hasResults ? (
+                        totalCount > 0 ? (
+                            unwatchedCount > 0 ? (
+                                <p>
+                                    You have {unwatchedCount} unwatched recordings to watch out of a total of{' '}
+                                    {totalCount}
+                                    {hasMoreResults ? '+' : ''} {description}.
+                                </p>
+                            ) : (
+                                <p>
+                                    You have watched all of the {totalCount} recordings {description}.
+                                </p>
+                            )
+                        ) : (
+                            <p>No results found for this playlist.</p>
+                        )
+                    ) : (
+                        <p>Counts have not yet been calculated for this playlist.</p>
+                    )}
+                    {isShowingSavedFilters && lastRefreshedAt ? (
+                        <div className="text-xs items-center flex flex-row gap-x-1">
+                            Last refreshed: <TZLabel time={lastRefreshedAt} showPopover={false} />
+                        </div>
+                    ) : null}
+                </div>
+            )
+
+            return (
+                <div className="flex items-center justify-start w-full h-full">
+                    <Tooltip title={tooltip}>
+                        {hasResults ? (
+                            <span className="flex items-center deprecated-space-x-1">
+                                <LemonBadge.Number
+                                    status={unwatchedCount ? 'primary' : 'muted'}
+                                    className="text-xs cursor-pointer"
+                                    count={totalCount}
+                                    maxDigits={3}
+                                    showZero={true}
+                                    forcePlus={
+                                        !!recordings_counts.saved_filters?.count &&
+                                        !!recordings_counts.saved_filters?.has_more
+                                    }
+                                />
+                                {recordings_counts.saved_filters?.increased ? <IconArrowUp /> : null}
+                            </span>
+                        ) : (
+                            <span>
+                                <LemonBadge status="muted" content="?" className="cursor-pointer" />
+                            </span>
+                        )}
+                    </Tooltip>
+                </div>
+            )
+        },
+    }
+}
+
+export function isPlaylistRecordingsCounts(x: unknown): x is PlaylistRecordingsCounts {
     return isObject(x) && ('collection' in x || 'saved_filters' in x)
 }
 
@@ -64,98 +160,10 @@ export function SavedSessionRecordingPlaylists({ tab }: SavedSessionRecordingPla
                 )
             },
         },
-        {
-            dataIndex: 'recordings_counts',
-            title: 'Count',
-            tooltip: 'Count of recordings in the playlist',
-            isHidden: !showCountColumn,
-            width: 0,
-            render: function Render(recordings_counts) {
-                if (!isPlaylistRecordingsCounts(recordings_counts)) {
-                    return null
-                }
-
-                const hasResults =
-                    recordings_counts.collection.count !== null || recordings_counts.saved_filters?.count !== null
-
-                const totalPinnedCount: number | null = recordings_counts.collection.count
-                const unwatchedPinnedCount =
-                    (recordings_counts.collection.count || 0) - (recordings_counts.collection.watched_count || 0)
-                const totalSavedFiltersCount = recordings_counts.saved_filters?.count || 0
-                const unwatchedSavedFiltersCount =
-                    (recordings_counts.saved_filters?.count || 0) -
-                    (recordings_counts.saved_filters?.watched_count || 0)
-
-                // we don't allow both saved filters and pinned anymore
-                const isShowingSavedFilters = hasResults && !totalPinnedCount
-                const totalCount = isShowingSavedFilters ? totalSavedFiltersCount : totalPinnedCount
-                const unwatchedCount = isShowingSavedFilters ? unwatchedSavedFiltersCount : unwatchedPinnedCount
-                // if we're showing saved filters, then we might have more results
-                const hasMoreResults = isShowingSavedFilters && recordings_counts.saved_filters?.has_more
-
-                const lastRefreshedAt = isShowingSavedFilters
-                    ? recordings_counts.saved_filters?.last_refreshed_at
-                    : null
-
-                const description = isShowingSavedFilters ? 'that match these saved filters' : 'in this collection'
-
-                const tooltip = (
-                    <div className="text-start">
-                        {hasResults ? (
-                            totalCount > 0 ? (
-                                unwatchedCount > 0 ? (
-                                    <p>
-                                        You have {unwatchedCount} unwatched recordings to watch out of a total of{' '}
-                                        {totalCount}
-                                        {hasMoreResults ? '+' : ''} {description}.
-                                    </p>
-                                ) : (
-                                    <p>
-                                        You have watched all of the {totalCount} recordings {description}.
-                                    </p>
-                                )
-                            ) : (
-                                <p>No results found for this playlist.</p>
-                            )
-                        ) : (
-                            <p>Counts have not yet been calculated for this playlist.</p>
-                        )}
-                        {isShowingSavedFilters && lastRefreshedAt ? (
-                            <div className="text-xs items-center flex flex-row gap-x-1">
-                                Last refreshed: <TZLabel time={lastRefreshedAt} showPopover={false} />
-                            </div>
-                        ) : null}
-                    </div>
-                )
-
-                return (
-                    <div className="flex items-center justify-start w-full h-full">
-                        <Tooltip title={tooltip}>
-                            {hasResults ? (
-                                <span className="flex items-center deprecated-space-x-1">
-                                    <LemonBadge.Number
-                                        status={unwatchedCount ? 'primary' : 'muted'}
-                                        className="text-xs cursor-pointer"
-                                        count={totalCount}
-                                        maxDigits={3}
-                                        showZero={true}
-                                        forcePlus={
-                                            !!recordings_counts.saved_filters?.count &&
-                                            !!recordings_counts.saved_filters?.has_more
-                                        }
-                                    />
-                                    {recordings_counts.saved_filters?.increased ? <IconArrowUp /> : null}
-                                </span>
-                            ) : (
-                                <span>
-                                    <LemonBadge status="muted" content="?" className="cursor-pointer" />
-                                </span>
-                            )}
-                        </Tooltip>
-                    </div>
-                )
-            },
-        },
+        countColumn({ showCountColumn }) as LemonTableColumn<
+            SessionRecordingPlaylistType,
+            keyof SessionRecordingPlaylistType | undefined
+        >,
         nameColumn() as LemonTableColumn<SessionRecordingPlaylistType, keyof SessionRecordingPlaylistType | undefined>,
         {
             ...(createdByColumn<SessionRecordingPlaylistType>() as LemonTableColumn<
diff --git a/frontend/src/scenes/session-recordings/saved-playlists/savedSessionRecordingPlaylistsLogic.ts b/frontend/src/scenes/session-recordings/saved-playlists/savedSessionRecordingPlaylistsLogic.ts
index 0b07bc21d6b0..6308bac3e32c 100644
--- a/frontend/src/scenes/session-recordings/saved-playlists/savedSessionRecordingPlaylistsLogic.ts
+++ b/frontend/src/scenes/session-recordings/saved-playlists/savedSessionRecordingPlaylistsLogic.ts
@@ -58,6 +58,7 @@ export const savedSessionRecordingPlaylistsLogic = kea<savedSessionRecordingPlay
             filters,
         }),
         loadPlaylists: true,
+        loadSavedFilters: true,
         updatePlaylist: (
             shortId: SessionRecordingPlaylistType['short_id'],
             properties: Partial<SessionRecordingPlaylistType>
@@ -88,6 +89,29 @@ export const savedSessionRecordingPlaylistsLogic = kea<savedSessionRecordingPlay
         ],
     })),
     loaders(({ values, actions }) => ({
+        savedFilters: {
+            __default: { results: [], count: 0, filters: null } as SavedSessionRecordingPlaylistsResult,
+            loadSavedFilters: async (_, breakpoint) => {
+                const filters = { ...values.filters }
+
+                const params = {
+                    limit: PLAYLISTS_PER_PAGE,
+                    offset: Math.max(0, (filters.page - 1) * PLAYLISTS_PER_PAGE),
+                    order: filters.order ?? '-last_modified_at', // Sync with `sorting` selector
+                    created_by: undefined,
+                    search: filters.search || undefined,
+                    date_from: undefined,
+                    date_to: undefined,
+                    pinned: undefined,
+                    type: 'saved_filters',
+                }
+
+                const response = await api.recordings.listPlaylists(toParams(params))
+                breakpoint()
+
+                return response
+            },
+        },
         playlists: {
             __default: { results: [], count: 0, filters: null } as SavedSessionRecordingPlaylistsResult,
             loadPlaylists: async (_, breakpoint) => {
@@ -129,6 +153,7 @@ export const savedSessionRecordingPlaylistsLogic = kea<savedSessionRecordingPlay
             deletePlaylist: async ({ playlist }) => {
                 await deletePlaylist(playlist, () => actions.loadPlaylists())
                 values.playlists.results = values.playlists.results.filter((x) => x.short_id !== playlist.short_id)
+                actions.loadSavedFilters()
                 return values.playlists
             },
 
@@ -264,5 +289,6 @@ export const savedSessionRecordingPlaylistsLogic = kea<savedSessionRecordingPlay
     })),
     afterMount(({ actions }) => {
         actions.loadPlaylists()
+        actions.loadSavedFilters()
     }),
 ])
diff --git a/playwright/e2e/event-definitions.spec.ts b/playwright/e2e/event-definitions.spec.ts
index d2b212dd3ac3..e1758917dcf7 100644
--- a/playwright/e2e/event-definitions.spec.ts
+++ b/playwright/e2e/event-definitions.spec.ts
@@ -18,6 +18,8 @@ test.describe('Event Definitions', () => {
         await page.locator('[data-attr=event-definitions-table-view-recordings]').click()
         expect(page.url()).toMatch(/replay/)
 
+        await page.locator('.LemonButton--has-icon .LemonButton__content').filter({ hasText: 'Filters' }).click()
+
         await expect(page.locator('.UniversalFilterButton').first()).toContainText(eventName, { ignoreCase: true })
     })
 })
diff --git a/playwright/e2e/toolbar.spec.ts b/playwright/e2e/toolbar.spec.ts
index 6bb6a43c1b84..e4faf38a0376 100644
--- a/playwright/e2e/toolbar.spec.ts
+++ b/playwright/e2e/toolbar.spec.ts
@@ -14,7 +14,7 @@ test.describe('Toolbar', () => {
             await page.goto(href)
         }
 
-        await expect(page.locator('#__POSTHOG_TOOLBAR__ .Toolbar')).toBeVisible()
+        await expect(page.locator('#__POSTHOG_TOOLBAR__ .Toolbar')).toBeVisible({ timeout: 5000 })
     })
 
     test('Toolbar item in sidebar has launch options', async ({ page }) => {
diff --git a/posthog/session_recordings/session_recording_playlist_api.py b/posthog/session_recordings/session_recording_playlist_api.py
index 65701567c9f0..928d7ccc2062 100644
--- a/posthog/session_recordings/session_recording_playlist_api.py
+++ b/posthog/session_recordings/session_recording_playlist_api.py
@@ -274,6 +274,11 @@ def _filter_request(self, request: request.Request, queryset: QuerySet) -> Query
         for key in filters:
             if key == "user":
                 queryset = queryset.filter(created_by=request.user)
+            elif key == "type" and filters["type"] == "saved_filters":
+                # Filter for playlists that have filters and no items
+                queryset = queryset.exclude(
+                    Q(filters={}) | Q(id__in=SessionRecordingPlaylistItem.objects.values_list("playlist_id", flat=True))
+                )
             elif key == "pinned":
                 queryset = queryset.filter(pinned=True)
             elif key == "date_from":
diff --git a/posthog/session_recordings/test/test_session_recording_playlist.py b/posthog/session_recordings/test/test_session_recording_playlist.py
index 21a08a9de3b5..b8c2bcac85e3 100644
--- a/posthog/session_recordings/test/test_session_recording_playlist.py
+++ b/posthog/session_recordings/test/test_session_recording_playlist.py
@@ -397,6 +397,52 @@ def test_filters_based_on_params(self):
         assert len(results) == 1
         assert results[0]["short_id"] == playlist3.short_id
 
+    def test_filters_saved_filters_type(self):
+        # Create a playlist with pinned recordings and no filters
+        playlist1 = SessionRecordingPlaylist.objects.create(team=self.team, name="pinned only", created_by=self.user)
+        recording1 = SessionRecording.objects.create(team=self.team, session_id=str(uuid4()))
+        SessionRecordingPlaylistItem.objects.create(playlist=playlist1, recording=recording1)
+
+        # Create a playlist with both pinned recordings and filters
+        playlist2 = SessionRecordingPlaylist.objects.create(
+            team=self.team,
+            name="pinned and filters",
+            created_by=self.user,
+            filters={"events": [{"id": "test"}]},
+        )
+        recording2 = SessionRecording.objects.create(team=self.team, session_id=str(uuid4()))
+        SessionRecordingPlaylistItem.objects.create(playlist=playlist2, recording=recording2)
+
+        # Create a playlist with only filters
+        playlist3 = SessionRecordingPlaylist.objects.create(
+            team=self.team,
+            name="filters only",
+            created_by=self.user,
+            filters={"events": [{"id": "test"}]},
+        )
+
+        # Create a playlist with only deleted pinned items
+        playlist4 = SessionRecordingPlaylist.objects.create(
+            team=self.team,
+            name="deleted pinned only",
+            created_by=self.user,
+        )
+        recording4 = SessionRecording.objects.create(team=self.team, session_id=str(uuid4()))
+        SessionRecordingPlaylistItem.objects.create(playlist=playlist4, recording=recording4)
+        SessionRecordingPlaylistItem.objects.filter(playlist=playlist4, recording=recording4).update(deleted=True)
+
+        response = self.client.get(
+            f"/api/projects/{self.team.id}/session_recording_playlists?type=saved_filters",
+        )
+        assert response.status_code == status.HTTP_200_OK
+        results = response.json()["results"]
+
+        # Should only return the playlist with filters and no pinned recordings
+        # since playlist 4 only has deleted pinned items, then it technically has no pinned
+        # so counts has having 0 pinned items
+        # but it also has no filters, so it should not be included
+        assert [r["name"] for r in results] == [playlist3.name]
+
     @patch("ee.session_recordings.session_recording_extensions.object_storage.copy_objects")
     def test_get_pinned_recordings_for_playlist(self, mock_copy_objects: MagicMock) -> None:
         mock_copy_objects.return_value = 2


Reviews:
user: login: greptile-apps[bot]
body: 
## PR Summary

The changes refactor the filters experience, moving them into a modal and adding a dedicated saved filters tab while cleanly separating saved filters from pinned recordings.  

- Updated UniversalFilters to include a new PureTaxonomicFilter component for modal use.  
- Modified RecordingsUniversalFilters to introduce a modal footer and saved filters tab.  
- Enhanced playlistLogic with a new activeFilterTab state for managing modal view.  
- Adjusted saved playlists logic to load saved filters distinctly.  
- Updated backend API filtering to isolate saved filters from playlists with pinned items.

<sub>10 file(s) reviewed, no comment(s)</sub>
<sub>[Edit PR Review Bot Settings](https://app.greptile.com/review/github) | [Greptile](https://greptile.com?utm_source=greptile_expert&utm_medium=github&utm_campaign=code_reviews&utm_content=posthog_30821)</sub>
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T15:40:05Z
user: login: pauldambra
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T11:29:24Z
user: login: pauldambra
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T11:30:37Z
user: login: pauldambra
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T11:31:38Z
user: login: pauldambra
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T11:33:26Z
user: login: pauldambra
body: i think this is amazing, wrap it in a flag and everything else is nitpicking until we see how real users use it

i think when i saved as a filter i was surprised i didn't have some indication i was looking at a saved filter but we can improve that over time (and it might not surprise other people)

would be good to show a loading state and a number of results as well while it's loading in the background so folk know what's happening - we know from familiarity but...

i'm happy to YOLO as always but my gut says we should A/B test since it's a pretty big switch of UX and we've/I've been bad at testing those in the past
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T11:36:11Z
user: login: veryayskiy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T13:37:12Z
user: login: veryayskiy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T13:38:23Z
user: login: pauldambra
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T13:41:31Z
user: login: veryayskiy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T13:44:45Z
user: login: veryayskiy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T13:44:54Z
user: login: veryayskiy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T13:46:08Z
user: login: veryayskiy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T13:47:12Z
user: login: pauldambra
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T13:50:26Z
user: login: pauldambra
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T13:51:43Z
user: login: veryayskiy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T13:55:04Z
user: login: sortafreel
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T10:33:09Z
user: login: sortafreel
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T10:33:11Z

Review comments:
diff_hunk: @@ -102,6 +109,40 @@ export const RecordingsUniversalFilters = ({
         taxonomicGroupTypes.push(TaxonomicFilterGroupType.EventFeatureFlags)
     }
 
+    const savedFiltersLogic = savedSessionRecordingPlaylistsLogic({ tab: ReplayTabs.Playlists })
+    const { savedFilters } = useValues(savedFiltersLogic)
+    const { loadSavedFilters } = useActions(savedFiltersLogic)
+
+    const { reportRecordingPlaylistCreated } = useActions(eventUsageLogic)
+
+    const newPlaylistHandler = async (): Promise<void> => {
+        await createPlaylist({ name: savedFilterName, filters }, false)
+        reportRecordingPlaylistCreated('new')
+        loadSavedFilters()
+        setSavedFilterName('')
+    }
+
+    /** Modal footer with buttons for reset and close */
+    const ModalFooter = (): JSX.Element => {
+        return (
+            <div className="flex justify-between p-2 gap-2">
+                <LemonButton
path: frontend/src/scenes/session-recordings/filters/RecordingsUniversalFilters.tsx
user: login: pauldambra
body: reset filters confused me when on the saved filters tab
makes sense on the main tab but i think we should hide when looking at saved filters
created_at: 2025-04-10T11:29:24Z
author_association: MEMBER
diff_hunk: @@ -137,109 +179,168 @@ export const RecordingsUniversalFilters = ({
                             <LemonBadge.Number count={totalFiltersCount} />
                         ) : null}
                     </LemonButton>
-                    <div
-                        className={clsx(
-                            'flex justify-center relative border-r border-l rounded-b overflow-hidden transition-all duration-200',
-                            isFiltersExpanded ? 'max-h-[1000px] opacity-100' : 'max-h-0 opacity-0'
-                        )}
+                    <LemonModal
path: frontend/src/scenes/session-recordings/filters/RecordingsUniversalFilters.tsx
user: login: pauldambra
body: shall we wrap in a flag so we can test it in comparison the current?
created_at: 2025-04-10T11:30:36Z
author_association: MEMBER
diff_hunk: @@ -283,10 +390,17 @@ const RecordingsUniversalFilterGroup = ({ size = 'small' }: { size?: LemonButton
         <>
             {filterGroup.values.map((filterOrGroup, index) => {
                 return isUniversalGroupFilterLike(filterOrGroup) ? (
-                    <UniversalFilters.Group key={index} index={index} group={filterOrGroup}>
-                        <RecordingsUniversalFilterGroup size={size} />
-                        <UniversalFilters.AddFilterButton size={size} type="secondary" />
-                    </UniversalFilters.Group>
+                    <div className="w-full">
+                        <UniversalFilters.Group key={index} index={index} group={filterOrGroup}>
+                            <UniversalFilters.PureTaxonomicFilter />
+                            <div className="flex items-center gap-2 border-t pt-2">
+                                {(totalFiltersCount ?? 0) > 0 && (
+                                    <span className="font-semibold">Applied filters:</span>
path: frontend/src/scenes/session-recordings/filters/RecordingsUniversalFilters.tsx
user: login: pauldambra
body: this felt like it should be above the taxonomic filter. block to me... so I could see all the actual filters in one block, then the area to choose new ones, then the area to save if I want to
created_at: 2025-04-10T11:31:38Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,95 @@
+import { LemonButton, LemonTable, LemonTableColumn, LemonTableColumns } from '@posthog/lemon-ui'
+import { useActions, useValues } from 'kea'
+import { useFeatureFlag } from 'lib/hooks/useFeatureFlag'
+import { More } from 'lib/lemon-ui/LemonButton/More'
+
+import { RecordingUniversalFilters, ReplayTabs, SessionRecordingPlaylistType } from '~/types'
+
+import { playlistLogic } from '../playlist/playlistLogic'
+import { countColumn } from '../saved-playlists/SavedSessionRecordingPlaylists'
+import { SavedSessionRecordingPlaylistsEmptyState } from '../saved-playlists/SavedSessionRecordingPlaylistsEmptyState'
+import { savedSessionRecordingPlaylistsLogic } from '../saved-playlists/savedSessionRecordingPlaylistsLogic'
+
+export function SavedFilters({
+    setFilters,
+}: {
+    setFilters: (filters: Partial<RecordingUniversalFilters>) => void
+}): JSX.Element {
+    const savedFiltersLogic = savedSessionRecordingPlaylistsLogic({ tab: ReplayTabs.Playlists })
+    const { savedFilters, savedFiltersLoading, pagination } = useValues(savedFiltersLogic)
+    const { deletePlaylist } = useActions(savedFiltersLogic)
+    const { setIsFiltersExpanded, setActiveFilterTab } = useActions(playlistLogic)
+
+    const showCountColumn = useFeatureFlag('SESSION_RECORDINGS_PLAYLIST_COUNT_COLUMN')
+
+    if (savedFiltersLoading || savedFilters.results?.length === 0) {
+        return <SavedSessionRecordingPlaylistsEmptyState />
+    }
+
+    const nameColumn = (): LemonTableColumn<SessionRecordingPlaylistType, 'name'> => {
+        return {
+            title: 'Name',
+            dataIndex: 'name',
+            render: function Render(name, { short_id, derived_name }) {
+                const filter = savedFilters.results.find((filter) => filter.short_id === short_id)
+                return (
+                    <>
+                        <div
+                            onClick={() => {
+                                if (filter && filter.filters) {
path: frontend/src/scenes/session-recordings/filters/SavedFilters.tsx
user: login: pauldambra
body: when i chose the filter i actually expected it to apply to the modal and keep the modal open

but I can see why you might just want to jump straight to it

maybe we should have a pencil button alongside the more button so you have a secondary action of starting from that saved filter and editing in one click
created_at: 2025-04-10T11:33:26Z
author_association: MEMBER
diff_hunk: @@ -102,6 +109,40 @@ export const RecordingsUniversalFilters = ({
         taxonomicGroupTypes.push(TaxonomicFilterGroupType.EventFeatureFlags)
     }
 
+    const savedFiltersLogic = savedSessionRecordingPlaylistsLogic({ tab: ReplayTabs.Playlists })
+    const { savedFilters } = useValues(savedFiltersLogic)
+    const { loadSavedFilters } = useActions(savedFiltersLogic)
+
+    const { reportRecordingPlaylistCreated } = useActions(eventUsageLogic)
+
+    const newPlaylistHandler = async (): Promise<void> => {
+        await createPlaylist({ name: savedFilterName, filters }, false)
+        reportRecordingPlaylistCreated('new')
+        loadSavedFilters()
+        setSavedFilterName('')
+    }
+
+    /** Modal footer with buttons for reset and close */
+    const ModalFooter = (): JSX.Element => {
+        return (
+            <div className="flex justify-between p-2 gap-2">
+                <LemonButton
path: frontend/src/scenes/session-recordings/filters/RecordingsUniversalFilters.tsx
user: login: veryayskiy
body: Good point. Fixed.
created_at: 2025-04-10T13:37:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -137,109 +179,168 @@ export const RecordingsUniversalFilters = ({
                             <LemonBadge.Number count={totalFiltersCount} />
                         ) : null}
                     </LemonButton>
-                    <div
-                        className={clsx(
-                            'flex justify-center relative border-r border-l rounded-b overflow-hidden transition-all duration-200',
-                            isFiltersExpanded ? 'max-h-[1000px] opacity-100' : 'max-h-0 opacity-0'
-                        )}
+                    <LemonModal
path: frontend/src/scenes/session-recordings/filters/RecordingsUniversalFilters.tsx
user: login: veryayskiy
body: What would we measure? 
The click on the filters would be the same (the buttons stays the same). 

In this case it's all about usability - as it's bigger it's easier to use.
created_at: 2025-04-10T13:38:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -137,109 +179,168 @@ export const RecordingsUniversalFilters = ({
                             <LemonBadge.Number count={totalFiltersCount} />
                         ) : null}
                     </LemonButton>
-                    <div
-                        className={clsx(
-                            'flex justify-center relative border-r border-l rounded-b overflow-hidden transition-all duration-200',
-                            isFiltersExpanded ? 'max-h-[1000px] opacity-100' : 'max-h-0 opacity-0'
-                        )}
+                    <LemonModal
path: frontend/src/scenes/session-recordings/filters/RecordingsUniversalFilters.tsx
user: login: pauldambra
body: i guess does it affect retention or recordings analysed or filters applied would be the measure

but we don't have to test everything
created_at: 2025-04-10T13:41:31Z
author_association: MEMBER
diff_hunk: @@ -283,10 +390,17 @@ const RecordingsUniversalFilterGroup = ({ size = 'small' }: { size?: LemonButton
         <>
             {filterGroup.values.map((filterOrGroup, index) => {
                 return isUniversalGroupFilterLike(filterOrGroup) ? (
-                    <UniversalFilters.Group key={index} index={index} group={filterOrGroup}>
-                        <RecordingsUniversalFilterGroup size={size} />
-                        <UniversalFilters.AddFilterButton size={size} type="secondary" />
-                    </UniversalFilters.Group>
+                    <div className="w-full">
+                        <UniversalFilters.Group key={index} index={index} group={filterOrGroup}>
+                            <UniversalFilters.PureTaxonomicFilter />
+                            <div className="flex items-center gap-2 border-t pt-2">
+                                {(totalFiltersCount ?? 0) > 0 && (
+                                    <span className="font-semibold">Applied filters:</span>
path: frontend/src/scenes/session-recordings/filters/RecordingsUniversalFilters.tsx
user: login: veryayskiy
body: No problems, moved it up.
created_at: 2025-04-10T13:44:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -283,10 +390,17 @@ const RecordingsUniversalFilterGroup = ({ size = 'small' }: { size?: LemonButton
         <>
             {filterGroup.values.map((filterOrGroup, index) => {
                 return isUniversalGroupFilterLike(filterOrGroup) ? (
-                    <UniversalFilters.Group key={index} index={index} group={filterOrGroup}>
-                        <RecordingsUniversalFilterGroup size={size} />
-                        <UniversalFilters.AddFilterButton size={size} type="secondary" />
-                    </UniversalFilters.Group>
+                    <div className="w-full">
+                        <UniversalFilters.Group key={index} index={index} group={filterOrGroup}>
+                            <UniversalFilters.PureTaxonomicFilter />
+                            <div className="flex items-center gap-2 border-t pt-2">
+                                {(totalFiltersCount ?? 0) > 0 && (
+                                    <span className="font-semibold">Applied filters:</span>
path: frontend/src/scenes/session-recordings/filters/RecordingsUniversalFilters.tsx
user: login: veryayskiy
body: <img width="787" alt="Screenshot 2025-04-10 at 15 44 23" src="https://github.com/user-attachments/assets/cf7141cc-dffd-4070-8f2b-75121d032feb" />

created_at: 2025-04-10T13:44:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,95 @@
+import { LemonButton, LemonTable, LemonTableColumn, LemonTableColumns } from '@posthog/lemon-ui'
+import { useActions, useValues } from 'kea'
+import { useFeatureFlag } from 'lib/hooks/useFeatureFlag'
+import { More } from 'lib/lemon-ui/LemonButton/More'
+
+import { RecordingUniversalFilters, ReplayTabs, SessionRecordingPlaylistType } from '~/types'
+
+import { playlistLogic } from '../playlist/playlistLogic'
+import { countColumn } from '../saved-playlists/SavedSessionRecordingPlaylists'
+import { SavedSessionRecordingPlaylistsEmptyState } from '../saved-playlists/SavedSessionRecordingPlaylistsEmptyState'
+import { savedSessionRecordingPlaylistsLogic } from '../saved-playlists/savedSessionRecordingPlaylistsLogic'
+
+export function SavedFilters({
+    setFilters,
+}: {
+    setFilters: (filters: Partial<RecordingUniversalFilters>) => void
+}): JSX.Element {
+    const savedFiltersLogic = savedSessionRecordingPlaylistsLogic({ tab: ReplayTabs.Playlists })
+    const { savedFilters, savedFiltersLoading, pagination } = useValues(savedFiltersLogic)
+    const { deletePlaylist } = useActions(savedFiltersLogic)
+    const { setIsFiltersExpanded, setActiveFilterTab } = useActions(playlistLogic)
+
+    const showCountColumn = useFeatureFlag('SESSION_RECORDINGS_PLAYLIST_COUNT_COLUMN')
+
+    if (savedFiltersLoading || savedFilters.results?.length === 0) {
+        return <SavedSessionRecordingPlaylistsEmptyState />
+    }
+
+    const nameColumn = (): LemonTableColumn<SessionRecordingPlaylistType, 'name'> => {
+        return {
+            title: 'Name',
+            dataIndex: 'name',
+            render: function Render(name, { short_id, derived_name }) {
+                const filter = savedFilters.results.find((filter) => filter.short_id === short_id)
+                return (
+                    <>
+                        <div
+                            onClick={() => {
+                                if (filter && filter.filters) {
path: frontend/src/scenes/session-recordings/filters/SavedFilters.tsx
user: login: veryayskiy
body: yeah, I was in between those to behaviors tbh... happy to change if needed

created_at: 2025-04-10T13:46:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -137,109 +179,168 @@ export const RecordingsUniversalFilters = ({
                             <LemonBadge.Number count={totalFiltersCount} />
                         ) : null}
                     </LemonButton>
-                    <div
-                        className={clsx(
-                            'flex justify-center relative border-r border-l rounded-b overflow-hidden transition-all duration-200',
-                            isFiltersExpanded ? 'max-h-[1000px] opacity-100' : 'max-h-0 opacity-0'
-                        )}
+                    <LemonModal
path: frontend/src/scenes/session-recordings/filters/RecordingsUniversalFilters.tsx
user: login: veryayskiy
body: If we would change the filters completely then yes.

We just moved from a small toggle to a proper modal.


created_at: 2025-04-10T13:47:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,95 @@
+import { LemonButton, LemonTable, LemonTableColumn, LemonTableColumns } from '@posthog/lemon-ui'
+import { useActions, useValues } from 'kea'
+import { useFeatureFlag } from 'lib/hooks/useFeatureFlag'
+import { More } from 'lib/lemon-ui/LemonButton/More'
+
+import { RecordingUniversalFilters, ReplayTabs, SessionRecordingPlaylistType } from '~/types'
+
+import { playlistLogic } from '../playlist/playlistLogic'
+import { countColumn } from '../saved-playlists/SavedSessionRecordingPlaylists'
+import { SavedSessionRecordingPlaylistsEmptyState } from '../saved-playlists/SavedSessionRecordingPlaylistsEmptyState'
+import { savedSessionRecordingPlaylistsLogic } from '../saved-playlists/savedSessionRecordingPlaylistsLogic'
+
+export function SavedFilters({
+    setFilters,
+}: {
+    setFilters: (filters: Partial<RecordingUniversalFilters>) => void
+}): JSX.Element {
+    const savedFiltersLogic = savedSessionRecordingPlaylistsLogic({ tab: ReplayTabs.Playlists })
+    const { savedFilters, savedFiltersLoading, pagination } = useValues(savedFiltersLogic)
+    const { deletePlaylist } = useActions(savedFiltersLogic)
+    const { setIsFiltersExpanded, setActiveFilterTab } = useActions(playlistLogic)
+
+    const showCountColumn = useFeatureFlag('SESSION_RECORDINGS_PLAYLIST_COUNT_COLUMN')
+
+    if (savedFiltersLoading || savedFilters.results?.length === 0) {
+        return <SavedSessionRecordingPlaylistsEmptyState />
+    }
+
+    const nameColumn = (): LemonTableColumn<SessionRecordingPlaylistType, 'name'> => {
+        return {
+            title: 'Name',
+            dataIndex: 'name',
+            render: function Render(name, { short_id, derived_name }) {
+                const filter = savedFilters.results.find((filter) => filter.short_id === short_id)
+                return (
+                    <>
+                        <div
+                            onClick={() => {
+                                if (filter && filter.filters) {
path: frontend/src/scenes/session-recordings/filters/SavedFilters.tsx
user: login: pauldambra
body: i think if we do primary and secondary action then it makes sense to have "edit" as the secondary not the primary

or we can just ship and then try and grab some early user feedback and see what folk think if they all say "oh, i thought I'd stay in the modal" then we switch it and if they don't then we saved some lines of code 🤣 
created_at: 2025-04-10T13:50:26Z
author_association: MEMBER
diff_hunk: @@ -137,109 +179,168 @@ export const RecordingsUniversalFilters = ({
                             <LemonBadge.Number count={totalFiltersCount} />
                         ) : null}
                     </LemonButton>
-                    <div
-                        className={clsx(
-                            'flex justify-center relative border-r border-l rounded-b overflow-hidden transition-all duration-200',
-                            isFiltersExpanded ? 'max-h-[1000px] opacity-100' : 'max-h-0 opacity-0'
-                        )}
+                    <LemonModal
path: frontend/src/scenes/session-recordings/filters/RecordingsUniversalFilters.tsx
user: login: pauldambra
body: yep, am as happy for the test to be "we shipped it and got 42 tickets i 6 hours saying (good|bad)" 

i remember we removed the flags from the playlist once and people were on the streets setting fire to things
created_at: 2025-04-10T13:51:43Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,95 @@
+import { LemonButton, LemonTable, LemonTableColumn, LemonTableColumns } from '@posthog/lemon-ui'
+import { useActions, useValues } from 'kea'
+import { useFeatureFlag } from 'lib/hooks/useFeatureFlag'
+import { More } from 'lib/lemon-ui/LemonButton/More'
+
+import { RecordingUniversalFilters, ReplayTabs, SessionRecordingPlaylistType } from '~/types'
+
+import { playlistLogic } from '../playlist/playlistLogic'
+import { countColumn } from '../saved-playlists/SavedSessionRecordingPlaylists'
+import { SavedSessionRecordingPlaylistsEmptyState } from '../saved-playlists/SavedSessionRecordingPlaylistsEmptyState'
+import { savedSessionRecordingPlaylistsLogic } from '../saved-playlists/savedSessionRecordingPlaylistsLogic'
+
+export function SavedFilters({
+    setFilters,
+}: {
+    setFilters: (filters: Partial<RecordingUniversalFilters>) => void
+}): JSX.Element {
+    const savedFiltersLogic = savedSessionRecordingPlaylistsLogic({ tab: ReplayTabs.Playlists })
+    const { savedFilters, savedFiltersLoading, pagination } = useValues(savedFiltersLogic)
+    const { deletePlaylist } = useActions(savedFiltersLogic)
+    const { setIsFiltersExpanded, setActiveFilterTab } = useActions(playlistLogic)
+
+    const showCountColumn = useFeatureFlag('SESSION_RECORDINGS_PLAYLIST_COUNT_COLUMN')
+
+    if (savedFiltersLoading || savedFilters.results?.length === 0) {
+        return <SavedSessionRecordingPlaylistsEmptyState />
+    }
+
+    const nameColumn = (): LemonTableColumn<SessionRecordingPlaylistType, 'name'> => {
+        return {
+            title: 'Name',
+            dataIndex: 'name',
+            render: function Render(name, { short_id, derived_name }) {
+                const filter = savedFilters.results.find((filter) => filter.short_id === short_id)
+                return (
+                    <>
+                        <div
+                            onClick={() => {
+                                if (filter && filter.filters) {
path: frontend/src/scenes/session-recordings/filters/SavedFilters.tsx
user: login: veryayskiy
body: Done
created_at: 2025-04-10T13:55:04Z
author_association: CONTRIBUTOR

PR comments:
user: login: posthog-bot
created_at: 2025-04-04T12:56:16Z
body: ## 📸 UI snapshots have been updated

**1** snapshot changes in total. **0** added, **1** modified, **0** deleted:

- **`chromium`**: **0** added, **1** modified, **0** deleted ([diff for shard 5](https://github.com/PostHog/posthog/pull/30821/commits/d5f0db28dbfff0d51ba7036743a31d568f64df2b))
- `webkit`: **0** added, **0** modified, **0** deleted

Triggered by [this commit](https://github.com/PostHog/posthog/pull/30821/commits/5e0842bdf927cc6092dbd7b50469fd18968e2370).

👉 **[Review this PR's diff of snapshots.](https://github.com/PostHog/posthog/pull/30821/files#:~:text=frontend/__snapshots__/)**
author_association: CONTRIBUTOR
user: login: posthog-bot
created_at: 2025-04-04T13:05:29Z
body: ## 📸 UI snapshots have been updated

**1** snapshot changes in total. **0** added, **1** modified, **0** deleted:

- **`chromium`**: **0** added, **1** modified, **0** deleted ([diff for shard 5](https://github.com/PostHog/posthog/pull/30821/commits/4913e57cb7f1bb0dd8add0f228102b11f6199ecf))
- `webkit`: **0** added, **0** modified, **0** deleted

Triggered by [this commit](https://github.com/PostHog/posthog/pull/30821/commits/173749672116dde9d8d90906b8faed15b38b918b).

👉 **[Review this PR's diff of snapshots.](https://github.com/PostHog/posthog/pull/30821/files#:~:text=frontend/__snapshots__/)**
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-04T13:16:18Z
body: **Size Change:** -155 B (0%) 

**Total Size:** 13.4 MB



<details><summary>ℹ️ <strong>View Unchanged</strong></summary>

| Filename | Size | Change |
| :--- | :---: | :---: |
| `frontend/dist/toolbar.js` | 13.4 MB | -155 B (0%) |

</details>



<a href="https://github.com/preactjs/compressed-size-action"><sub>compressed-size-action</sub></a>
author_association: CONTRIBUTOR
user: login: posthog-bot
created_at: 2025-04-08T06:46:38Z
body: ## 📸 UI snapshots have been updated

**1** snapshot changes in total. **0** added, **1** modified, **0** deleted:

- **`chromium`**: **0** added, **1** modified, **0** deleted ([diff for shard 5](https://github.com/PostHog/posthog/pull/30821/commits/65e2285ced0f04b13877382f818ad861eacc5a37))
- `webkit`: **0** added, **0** modified, **0** deleted

Triggered by [this commit](https://github.com/PostHog/posthog/pull/30821/commits/2099712dcb2fcd3f832f8374b162276fcb7e2379).

👉 **[Review this PR's diff of snapshots.](https://github.com/PostHog/posthog/pull/30821/files#:~:text=frontend/__snapshots__/)**
author_association: CONTRIBUTOR
user: login: posthog-bot
created_at: 2025-04-08T06:57:12Z
body: ## 📸 UI snapshots have been updated

**1** snapshot changes in total. **0** added, **1** modified, **0** deleted:

- **`chromium`**: **0** added, **1** modified, **0** deleted ([diff for shard 5](https://github.com/PostHog/posthog/pull/30821/commits/43f86fa5d63916d4fff50de967dc512f7a1c593e))
- `webkit`: **0** added, **0** modified, **0** deleted

Triggered by [this commit](https://github.com/PostHog/posthog/pull/30821/commits/1e73a3bd4ad191efec06e70a80a7ef96acb47164).

👉 **[Review this PR's diff of snapshots.](https://github.com/PostHog/posthog/pull/30821/files#:~:text=frontend/__snapshots__/)**
author_association: CONTRIBUTOR
user: login: posthog-bot
created_at: 2025-04-09T06:52:47Z
body: ## 📸 UI snapshots have been updated

**2** snapshot changes in total. **0** added, **2** modified, **0** deleted:

- **`chromium`**: **0** added, **2** modified, **0** deleted ([diff for shard 1](https://github.com/PostHog/posthog/pull/30821/commits/1b434913022e48eaae76ad825f1c607ac33526fe))
- `webkit`: **0** added, **0** modified, **0** deleted

Triggered by [this commit](https://github.com/PostHog/posthog/pull/30821/commits/dbdfca8e2cb7d5cce8b7824dbc05e8d5de2d28bb).

👉 **[Review this PR's diff of snapshots.](https://github.com/PostHog/posthog/pull/30821/files#:~:text=frontend/__snapshots__/)**
author_association: CONTRIBUTOR
user: login: posthog-bot
created_at: 2025-04-11T14:26:30Z
body: ## 📸 UI snapshots have been updated

**2** snapshot changes in total. **0** added, **2** modified, **0** deleted:

- **`chromium`**: **0** added, **2** modified, **0** deleted ([diff for shard 1](https://github.com/PostHog/posthog/pull/30821/commits/ed9528653fc902d7a2e99a01b31b884c0a60e9da))
- `webkit`: **0** added, **0** modified, **0** deleted

Triggered by [this commit](https://github.com/PostHog/posthog/pull/30821/commits/0ef4b27ea6b8792811e1201635d471e37e15d162).

👉 **[Review this PR's diff of snapshots.](https://github.com/PostHog/posthog/pull/30821/files#:~:text=frontend/__snapshots__/)**
author_association: CONTRIBUTOR