feat(ai): Render recordings filters ui_payload (#40814)

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Michael Matloka
2025-11-13 19:10:51 +01:00
committed by GitHub
parent 452cb5794a
commit f0ebb10e7b
81 changed files with 1273 additions and 360 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

View File

@@ -160,7 +160,7 @@ describe('the activity log logic', () => {
let renderedExtendedDescription = render(<>{actual[0].extendedDescription}</>).container
expect(renderedExtendedDescription).toHaveTextContent(
"Query summaryAShowing \"Views\"Pageviewcounted by total countwhere event'sBrowser= equals Chromeand person belongs to cohortUser in ID 2FiltersEvent'sCurrent URL= equals https://hedgebox.net/files/or event'sCountry code= equals US or AUBreakdown byCountry code"
"QueryACounting \"Views\"Pageviewby total countwhere event'sBrowser= equals Chromeand person belongs to cohortUser in ID 2FiltersEvent'sCurrent URL= equals https://hedgebox.net/files/or event'sCountry code= equals US or AUBreakdown byCountry code"
)
;(insightMock.after.breakdownFilter as BreakdownFilter) = {
breakdowns: [
@@ -183,7 +183,7 @@ describe('the activity log logic', () => {
renderedExtendedDescription = render(<>{actual[0].extendedDescription}</>).container
expect(renderedExtendedDescription).toHaveTextContent(
"Query summaryAShowing \"Views\"Pageviewcounted by total countwhere event'sBrowser= equals Chromeand person belongs to cohortUser in ID 2FiltersEvent'sCurrent URL= equals https://hedgebox.net/files/or event'sCountry code= equals US or AUBreakdown byCountry codeSession duration"
"QueryACounting \"Views\"Pageviewby total countwhere event'sBrowser= equals Chromeand person belongs to cohortUser in ID 2FiltersEvent'sCurrent URL= equals https://hedgebox.net/files/or event'sCountry code= equals US or AUBreakdown byCountry code"
)
})

View File

@@ -0,0 +1,155 @@
import { useValues } from 'kea'
import React from 'react'
import {
PROPERTY_FILTER_TYPE_TO_TAXONOMIC_FILTER_GROUP_TYPE,
formatPropertyLabel,
isAnyPropertyfilter,
isCohortPropertyFilter,
isPropertyFilterWithOperator,
} from 'lib/components/PropertyFilters/utils'
import { PropertyKeyInfo } from 'lib/components/PropertyKeyInfo'
import { Link } from 'lib/lemon-ui/Link'
import { allOperatorsMapping, capitalizeFirstLetter } from 'lib/utils'
import { urls } from 'scenes/urls'
import { cohortsModel } from '~/models/cohortsModel'
import { propertyDefinitionsModel } from '~/models/propertyDefinitionsModel'
import {
ActionFilter,
FilterLogicalOperator,
PropertyFilterBaseValue,
PropertyGroupFilter,
UniversalFiltersGroup,
UniversalFiltersGroupValue,
} from '~/types'
function isActionFilter(filter: UniversalFiltersGroupValue): filter is ActionFilter {
return (filter as ActionFilter).type !== undefined && 'id' in filter
}
function isUniversalFiltersGroup(value: UniversalFiltersGroupValue): value is UniversalFiltersGroup {
return (value as UniversalFiltersGroup).type !== undefined && (value as UniversalFiltersGroup).values !== undefined
}
export function CompactUniversalFiltersDisplay({
groupFilter,
embedded,
}: {
groupFilter: UniversalFiltersGroup | PropertyGroupFilter | null
embedded?: boolean
}): JSX.Element {
const { cohortsById } = useValues(cohortsModel)
const { formatPropertyValueForDisplay } = useValues(propertyDefinitionsModel)
if (!groupFilter || !groupFilter.values.length) {
return <i>None</i>
}
return (
<>
{groupFilter.values.map((filterOrGroup, index) => {
if (isUniversalFiltersGroup(filterOrGroup)) {
// Nested group
return (
<React.Fragment key={index}>
{index > 0 && (
<em className="text-[11px] font-semibold leading-5">
{groupFilter.type === FilterLogicalOperator.Or ? 'OR' : 'AND'}
</em>
)}
<CompactUniversalFiltersDisplay groupFilter={filterOrGroup} embedded={embedded} />
</React.Fragment>
)
}
if (isActionFilter(filterOrGroup)) {
return (
<div key={index} className="SeriesDisplay__condition">
<span>
{embedded && index === 0 ? 'where ' : null}
{index > 0 ? (groupFilter.type === FilterLogicalOperator.Or ? 'or ' : 'and ') : null}
Performed action
<Link
to={urls.action(filterOrGroup.id as number)}
className="SeriesDisplay__raw-name SeriesDisplay__raw-name--action mx-1"
title="Action"
>
{filterOrGroup.name || `Action ${filterOrGroup.id}`}
</Link>
</span>
</div>
)
}
// Property filter
const isFirstFilterOverall = index === 0
return (
<div key={index} className="SeriesDisplay__condition">
<span>
{isFirstFilterOverall && embedded ? 'where ' : null}
{index > 0 ? (
<strong>{groupFilter.type === FilterLogicalOperator.Or ? 'or ' : 'and '}</strong>
) : null}
{isCohortPropertyFilter(filterOrGroup) ? (
<>
{isFirstFilterOverall && !embedded ? 'Person' : 'person'} belongs to cohort
<span className="SeriesDisplay__raw-name">
{formatPropertyLabel(
filterOrGroup,
cohortsById,
(s) =>
formatPropertyValueForDisplay(filterOrGroup.key, s)?.toString() || '?'
)}
</span>
</>
) : (
<>
{isFirstFilterOverall && !embedded
? capitalizeFirstLetter(filterOrGroup.type || 'event')
: filterOrGroup.type || 'event'}
's
<span className="SeriesDisplay__raw-name">
{isAnyPropertyfilter(filterOrGroup) && filterOrGroup.key && (
<PropertyKeyInfo
value={filterOrGroup.key}
type={
PROPERTY_FILTER_TYPE_TO_TAXONOMIC_FILTER_GROUP_TYPE[
filterOrGroup.type
]
}
/>
)}
</span>
<em>
{
allOperatorsMapping[
(isPropertyFilterWithOperator(filterOrGroup) &&
filterOrGroup.operator) ||
'exact'
]
}
</em>{' '}
{isAnyPropertyfilter(filterOrGroup) &&
(Array.isArray(filterOrGroup.value) ? (
filterOrGroup.value.map((subValue, index) => (
<React.Fragment key={index}>
<code className="SeriesDisplay__value">{subValue}</code>
{index <
(filterOrGroup.value as PropertyFilterBaseValue[]).length - 1 &&
' or '}
</React.Fragment>
))
) : filterOrGroup.value != undefined ? (
<code className="SeriesDisplay__value">{filterOrGroup.value}</code>
) : null)}
</>
)}
</span>
</div>
)
})}
</>
)
}

View File

@@ -69,38 +69,6 @@
}
}
.InsightDetails__query {
padding: 0.375rem 0.5rem;
margin-top: 0.25rem;
border-width: 1px;
border-radius: var(--radius);
.LemonRow {
padding-right: 0;
padding-left: 0;
}
}
.InsightDetails__formula {
code {
margin-left: 0.375rem;
font-weight: 600;
}
}
.InsightDetails__series {
margin: -0.125rem 0;
&:not(:first-child) {
margin-top: 0.5rem;
}
.LemonDivider {
width: calc(100% - 1.5rem);
margin-left: 1.5rem;
}
}
.InsightDetails__footer {
display: flex;
flex-wrap: wrap;
@@ -121,13 +89,21 @@
}
.SeriesDisplay {
line-height: 1.5rem;
display: flex;
gap: 0.375rem;
align-items: start;
min-height: 1.5rem;
&:not(:last-child) {
margin-bottom: 0.25rem;
}
}
.SeriesDisplay__raw-name {
display: inline-flex;
align-items: center;
padding: 0.125rem 0.25rem;
height: 1.25rem;
padding: 0 0.25rem;
margin: 0 0.25rem;
font-size: 0.6875rem;
font-weight: 600;
@@ -135,38 +111,51 @@
color: var(--color-text-secondary);
vertical-align: middle;
background: var(--color-accent-highlight-secondary);
border-radius: var(--radius);
&.SeriesDisplay__raw-name--action,
&.SeriesDisplay__raw-name--event {
padding: 0.25rem;
&::before {
display: inline-block;
flex-shrink: 0;
width: 1rem;
margin-right: 0.25rem;
font-size: 0.625rem;
font-weight: 700;
line-height: 1rem;
color: var(--color-bg-surface-primary);
text-align: center;
background: var(--color-accent);
border-radius: var(--radius);
}
}
border: 1px solid var(--color-accent-highlight-secondary-border);
border-radius: var(--radius-sm);
&.SeriesDisplay__raw-name--event::before,
&.SeriesDisplay__raw-name--action::before {
content: 'A';
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 1rem;
height: calc(100% + 2px);
margin-top: -1px;
margin-right: 0.25rem;
margin-left: calc(-0.25rem - 1px); // Align to overlay the container's border on the left
font-size: 0.625rem;
font-weight: 700;
line-height: 1rem;
color: var(--color-bg-surface-primary);
text-align: center;
background: var(--color-accent);
border-radius: inherit;
}
&.SeriesDisplay__raw-name--event::before {
content: 'E';
}
&.SeriesDisplay__raw-name--action::before {
content: 'A';
}
}
.SeriesDisplay__value {
display: inline-flex;
align-items: center;
height: 1.25rem;
padding: 0 0.125rem;
background: var(--color-bg-surface-secondary);
border-width: 1px;
border-radius: var(--radius-sm);
}
.SeriesDisplay__condition {
display: flex;
line-height: 1.5rem;
}
.SeriesDisplay__arrow {

View File

@@ -1,31 +1,25 @@
import { useValues } from 'kea'
import React from 'react'
import { IconCalculator, IconCalendar, IconCode2, IconFilter, IconPencil, IconSort, IconUser } from '@posthog/icons'
import { Lettermark, LettermarkColor } from '@posthog/lemon-ui'
import { CodeSnippet, Language } from 'lib/components/CodeSnippet'
import {
PROPERTY_FILTER_TYPE_TO_TAXONOMIC_FILTER_GROUP_TYPE,
convertPropertiesToPropertyGroup,
formatPropertyLabel,
isAnyPropertyfilter,
isCohortPropertyFilter,
isPropertyFilterWithOperator,
} from 'lib/components/PropertyFilters/utils'
import { convertPropertiesToPropertyGroup } from 'lib/components/PropertyFilters/utils'
import { SeriesLetter } from 'lib/components/SeriesGlyph'
import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types'
import { LemonDivider } from 'lib/lemon-ui/LemonDivider'
import { LemonRow } from 'lib/lemon-ui/LemonRow'
import { LemonTag } from 'lib/lemon-ui/LemonTag/LemonTag'
import { Link } from 'lib/lemon-ui/Link'
import { ProfilePicture } from 'lib/lemon-ui/ProfilePicture'
import { IconCalculate } from 'lib/lemon-ui/icons'
import { allOperatorsMapping, capitalizeFirstLetter, dateFilterToText } from 'lib/utils'
import { capitalizeFirstLetter, dateFilterToText } from 'lib/utils'
import { BreakdownTag } from 'scenes/insights/filters/BreakdownFilter/BreakdownTag'
import { humanizePathsEventTypes } from 'scenes/insights/utils'
import { QUERY_TYPES_METADATA } from 'scenes/saved-insights/SavedInsights'
import { MathCategory, MathDefinition, apiValueToMathType, mathsLogic } from 'scenes/trends/mathsLogic'
import { urls } from 'scenes/urls'
import { cohortsModel } from '~/models/cohortsModel'
import { propertyDefinitionsModel } from '~/models/propertyDefinitionsModel'
import {
AnyEntityNode,
BreakdownFilter,
@@ -62,98 +56,25 @@ import { AnyPropertyFilter, FilterLogicalOperator, PropertyGroupFilter, UserBasi
import { PropertyKeyInfo } from '../../PropertyKeyInfo'
import { TZLabel } from '../../TZLabel'
import { CompactUniversalFiltersDisplay } from './CompactUniversalFiltersDisplay'
function CompactPropertyFiltersDisplay({
groupFilter,
embedded,
export function InsightDetailSectionDisplay({
icon,
label,
children,
}: {
groupFilter: PropertyGroupFilter | null
embedded?: boolean
icon: React.ReactNode
label: string | JSX.Element
children: React.ReactNode
}): JSX.Element {
const { cohortsById } = useValues(cohortsModel)
const { formatPropertyValueForDisplay } = useValues(propertyDefinitionsModel)
const areAnyFiltersPresent = !!groupFilter && groupFilter.values.flatMap((subValues) => subValues.values).length > 0
if (!areAnyFiltersPresent) {
return <i>None</i>
}
return (
<>
{groupFilter.values.map(({ values: subValues, type: subType }, subIndex) => (
<React.Fragment key={subIndex}>
{subIndex === 0 ? null : (
<em className="text-[11px] font-semibold">
{groupFilter.type === FilterLogicalOperator.Or ? 'OR' : 'AND'}
</em>
)}
{subValues.map((leafFilter, leafIndex) => {
const isFirstFilterWithinSubgroup = leafIndex === 0
const isFirstFilterOverall = isFirstFilterWithinSubgroup && subIndex === 0
return (
<div key={leafIndex} className="SeriesDisplay__condition">
<span>
{isFirstFilterWithinSubgroup
? embedded
? 'where '
: null
: subType === FilterLogicalOperator.Or
? 'or '
: 'and '}
{isCohortPropertyFilter(leafFilter) ? (
<>
{isFirstFilterOverall && !embedded ? 'Person' : 'person'} belongs to cohort
<span className="SeriesDisplay__raw-name">
{formatPropertyLabel(
leafFilter,
cohortsById,
(s) =>
formatPropertyValueForDisplay(leafFilter.key, s)?.toString() ||
'?'
)}
</span>
</>
) : (
<>
{isFirstFilterOverall && !embedded
? capitalizeFirstLetter(leafFilter.type || 'event')
: leafFilter.type || 'event'}
's
<span className="SeriesDisplay__raw-name">
{isAnyPropertyfilter(leafFilter) && leafFilter.key && (
<PropertyKeyInfo
value={leafFilter.key}
type={
PROPERTY_FILTER_TYPE_TO_TAXONOMIC_FILTER_GROUP_TYPE[
leafFilter.type
]
}
/>
)}
</span>
{
allOperatorsMapping[
(isPropertyFilterWithOperator(leafFilter) && leafFilter.operator) ||
'exact'
]
}{' '}
<b>
{isAnyPropertyfilter(leafFilter) &&
(Array.isArray(leafFilter.value)
? leafFilter.value.join(' or ')
: leafFilter.value)}
</b>
</>
)}
</span>
</div>
)
})}
</React.Fragment>
))}
</>
<section className="flex items-start gap-2 text-xs">
<div className="flex text-muted-alt mt-px flex-shrink-0 text-sm">{icon}</div>
<div className="flex-1 min-w-0">
<div className="text-muted-alt mb-0.5">{label}</div>
<div className="leading-6">{children}</div>
</div>
</section>
)
}
@@ -201,29 +122,18 @@ function SeriesDisplay({
] as MathDefinition | undefined
return (
<LemonRow
fullWidth
className="SeriesDisplay"
icon={<SeriesLetter seriesIndex={seriesIndex} hasBreakdown={hasBreakdown} />}
extendedContent={
series.properties &&
series.properties.length > 0 && (
<CompactPropertyFiltersDisplay
groupFilter={{
type: FilterLogicalOperator.And,
values: [{ type: FilterLogicalOperator.And, values: series.properties }],
}}
embedded
/>
)
}
>
<span>
{isFunnelsQuery(query) ? 'Performed' : 'Showing'}
<div className="SeriesDisplay">
{isFunnelsQuery(query) ? (
<Lettermark name={seriesIndex + 1} color={LettermarkColor.Gray} className="mt-px" />
) : (
<SeriesLetter seriesIndex={seriesIndex} hasBreakdown={hasBreakdown} className="mt-0.5" />
)}
<div>
{isFunnelsQuery(query) ? 'Performed' : 'Counting'}
<EntityDisplay entity={series} />
{!isFunnelsQuery(query) && (
<span className="leading-none">
counted by{' '}
<>
by{' '}
{mathDefinition?.category === MathCategory.HogQLExpression ? (
<code>{series.math_hogql}</code>
) : (
@@ -243,10 +153,19 @@ function SeriesDisplay({
<b>{mathDefinition?.name.toLowerCase()}</b>
</>
)}
</span>
</>
)}
</span>
</LemonRow>
{series.properties && series.properties.length > 0 && (
<CompactUniversalFiltersDisplay
groupFilter={{
type: FilterLogicalOperator.And,
values: [{ type: FilterLogicalOperator.And, values: series.properties }],
}}
embedded
/>
)}
</div>
</div>
)
}
@@ -256,18 +175,20 @@ function PathsSummary({ query }: { query: PathsQuery }): JSX.Element {
return (
<div className="SeriesDisplay">
<div>
User paths based on <b>{humanizePathsEventTypes(includeEventTypes).join(' and ')}</b>
<div>
User paths based on <b>{humanizePathsEventTypes(includeEventTypes).join(' and ')}</b>
</div>
{startPoint && (
<div>
starting at <b>{startPoint}</b>
</div>
)}
{endPoint && (
<div>
ending at <b>{endPoint}</b>
</div>
)}
</div>
{startPoint && (
<div>
starting at <b>{startPoint}</b>
</div>
)}
{endPoint && (
<div>
ending at <b>{endPoint}</b>
</div>
)}
</div>
)
}
@@ -305,7 +226,7 @@ function RetentionSummary({ query }: { query: RetentionQuery }): JSX.Element {
{query.retentionFilter.period?.toLocaleLowerCase() ?? 'day'}s
</strong>
<br />
and came back to perform
<strong>and</strong> who came back to perform
<EntityDisplay
entity={
query.retentionFilter.returningEntity?.type === 'actions'
@@ -321,7 +242,6 @@ function RetentionSummary({ query }: { query: RetentionQuery }): JSX.Element {
}
}
/>
in any of the next periods
</>
)
}
@@ -333,37 +253,33 @@ export function SeriesSummary({
query: InsightQueryNode | HogQLQuery
heading?: JSX.Element | null
}): JSX.Element {
const IconComponent = QUERY_TYPES_METADATA[query.kind].icon
return (
<section>
{heading !== null && <h5>{heading || 'Query summary'}</h5>}
<InsightDetailSectionDisplay icon={<IconComponent />} label={heading !== null ? heading || 'Query' : ''}>
{isHogQLQuery(query) ? (
<CodeSnippet language={Language.SQL} maxLinesWithoutExpansion={8} compact>
{query.query}
</CodeSnippet>
) : (
<div className="InsightDetails__query">
<>
{isTrendsQuery(query) && <FormulaSummary query={query} />}
<div className="InsightDetails__series">
{isPathsQuery(query) ? (
<PathsSummary query={query} />
) : isRetentionQuery(query) ? (
<RetentionSummary query={query} />
) : isInsightQueryWithSeries(query) ? (
<>
{query.series.map((_entity, index) => (
<React.Fragment key={index}>
{index !== 0 && <LemonDivider className="my-1" />}
<SeriesDisplay query={query} seriesIndex={index} />
</React.Fragment>
))}
</>
) : (
<i>Query summary is not available for {(query as Node).kind} yet</i>
)}
</div>
</div>
{isPathsQuery(query) ? (
<PathsSummary query={query} />
) : isRetentionQuery(query) ? (
<RetentionSummary query={query} />
) : isInsightQueryWithSeries(query) ? (
<>
{query.series.map((_entity, index) => (
<SeriesDisplay key={index} query={query} seriesIndex={index} />
))}
</>
) : (
<i>Query summary is not available for {(query as Node).kind} yet</i>
)}
</>
)}
</section>
</InsightDetailSectionDisplay>
)
}
@@ -378,22 +294,22 @@ export function FormulaSummary({ query }: { query: TrendsQuery }): JSX.Element |
return (
<>
{formulaNodes.map((node) => (
<>
<LemonRow className="InsightDetails__formula" icon={<IconCalculate />} fullWidth>
{node.custom_name ? (
<span>
Formula <b>"{node.custom_name}"</b>: <code>{node.formula}</code>
</span>
) : (
<span>
Formula: <code>{node.formula}</code>
</span>
{formulaNodes.map((node, index) => (
<div className="SeriesDisplay" key={index}>
<IconCalculate className="text-xl m-px text-text-secondary-3000" />
<span>
Formula
{node.custom_name && (
<>
{' '}
<b>{node.custom_name}</b>
</>
)}
</LemonRow>
<LemonDivider />
</>
: <code>{node.formula}</code>
</span>
</div>
))}
<LemonDivider className="mt-1 mb-2" />
</>
)
}
@@ -404,12 +320,9 @@ export function PropertiesSummary({
properties: PropertyGroupFilter | AnyPropertyFilter[] | undefined | null
}): JSX.Element {
return (
<section>
<h5>Filters</h5>
<div>
<CompactPropertyFiltersDisplay groupFilter={convertPropertiesToPropertyGroup(properties)} />
</div>
</section>
<InsightDetailSectionDisplay icon={<IconFilter />} label="Filters">
<CompactUniversalFiltersDisplay groupFilter={convertPropertiesToPropertyGroup(properties)} />
</InsightDetailSectionDisplay>
)
}
@@ -425,29 +338,25 @@ export function VariablesSummary({
}
return (
<section>
<h5>Variables</h5>
<div>
{Object.entries(variables).map(([key, variable]) => {
const overrideValue = variablesOverride?.[key]?.value
const hasOverride = overrideValue !== undefined && overrideValue !== variable.value
<InsightDetailSectionDisplay icon={<IconCode2 />} label="Variables">
{Object.entries(variables).map(([key, variable]) => {
const overrideValue = variablesOverride?.[key]?.value
const hasOverride = overrideValue !== undefined && overrideValue !== variable.value
return (
<div key={key} className="flex items-center gap-2">
<span>
{variable.code_name}:{' '}
{variable.value ? <strong>{variable.value}</strong> : <em>null</em>}
</span>
{hasOverride && (
<LemonTag type="highlight">
Overridden: {overrideValue ? <strong>{overrideValue}</strong> : <em>null</em>}
</LemonTag>
)}
</div>
)
})}
</div>
</section>
return (
<div key={key} className="flex items-center gap-2">
<span>
{variable.code_name}: {variable.value ? <strong>{variable.value}</strong> : <em>null</em>}
</span>
{hasOverride && (
<LemonTag type="highlight">
Overridden: {overrideValue ? <strong>{overrideValue}</strong> : <em>null</em>}
</LemonTag>
)}
</div>
)
})}
</InsightDetailSectionDisplay>
)
}
@@ -464,26 +373,39 @@ export function BreakdownSummary({
}: {
breakdownFilter: BreakdownFilter | null | undefined
}): JSX.Element | null {
if (!isValidBreakdown(breakdownFilter)) {
return null
}
const content = Array.isArray(breakdownFilter.breakdowns) ? (
<>
{breakdownFilter.breakdowns.map((b) => (
<BreakdownTag
key={`${b.type}-${b.property}`}
breakdown={b.property}
breakdownType={b.type}
size="small"
/>
))}
</>
) : breakdownFilter.breakdown ? (
<>
{(Array.isArray(breakdownFilter.breakdown) ? breakdownFilter.breakdown : [breakdownFilter.breakdown]).map(
(b) => (
<BreakdownTag key={b} breakdown={b} breakdownType={breakdownFilter.breakdown_type} size="small" />
)
)}
</>
) : null
if (!content) {
return null
}
return (
<section>
<h5>Breakdown by</h5>
<div>
{!isValidBreakdown(breakdownFilter) ? (
<i>None</i>
) : Array.isArray(breakdownFilter.breakdowns) ? (
breakdownFilter.breakdowns.map((b) => (
<BreakdownTag key={`${b.type}-${b.property}`} breakdown={b.property} breakdownType={b.type} />
))
) : (
breakdownFilter.breakdown &&
(Array.isArray(breakdownFilter.breakdown)
? breakdownFilter.breakdown
: [breakdownFilter.breakdown].map((b) => (
<BreakdownTag key={b} breakdown={b} breakdownType={breakdownFilter.breakdown_type} />
)))
)}
</div>
</section>
<InsightDetailSectionDisplay icon={<IconSort />} label="Breakdown by">
{content}
</InsightDetailSectionDisplay>
)
}
@@ -494,11 +416,14 @@ export function DateRangeSummary({
dateFrom: string | null | undefined
dateTo: string | null | undefined
}): JSX.Element | null {
const dateFilterText = dateFilterToText(dateFrom, dateTo, null)
if (!dateFilterText) {
return null
}
return (
<section>
<h5>Date range</h5>
<div>{dateFilterToText(dateFrom, dateTo, null)}</div>
</section>
<InsightDetailSectionDisplay icon={<IconCalendar />} label="Date range">
<div className="font-medium">{dateFilterText}</div>
</InsightDetailSectionDisplay>
)
}
@@ -519,10 +444,11 @@ export const InsightDetails = React.memo(
{ query, footerInfo, variablesOverride },
ref
): JSX.Element {
// TODO: Implement summaries for HogQL query insights
return (
<div className="InsightDetails" ref={ref}>
{isInsightVizNode(query) || isDataVisualizationNode(query) || isDataTableNodeWithHogQLQuery(query) ? (
<div className="InsightDetails space-y-2" ref={ref}>
{(isInsightVizNode(query) ||
isDataVisualizationNode(query) ||
isDataTableNodeWithHogQLQuery(query)) && (
<>
<SeriesSummary query={query.source} />
<VariablesSummary
@@ -536,32 +462,27 @@ export const InsightDetails = React.memo(
/>
<InsightBreakdownSummary query={query.source} />
</>
) : null}
)}
{footerInfo && (
<div className="InsightDetails__footer">
<div>
<h5>Created by</h5>
<section>
<ProfilePicture user={footerInfo.created_by} showName size="md" />{' '}
<>
<InsightDetailSectionDisplay icon={<IconUser />} label="Created by">
<div className="flex items-center py-px gap-1.5">
<ProfilePicture user={footerInfo.created_by} showName size="sm" />
<TZLabel time={footerInfo.created_at} />
</section>
</div>
<div>
<h5>Last modified by</h5>
<section>
<ProfilePicture user={footerInfo.last_modified_by} showName size="md" />{' '}
<TZLabel time={footerInfo.last_modified_at} />
</section>
</div>
{footerInfo.last_refresh && (
<div>
<h5>Last computed</h5>
<section>
<TZLabel time={footerInfo.last_refresh} />
</section>
</div>
</InsightDetailSectionDisplay>
<InsightDetailSectionDisplay icon={<IconPencil />} label="Last modified by">
<div className="flex items-center py-px gap-1.5">
<ProfilePicture user={footerInfo.last_modified_by} showName size="sm" />
<TZLabel time={footerInfo.last_modified_at} />
</div>
</InsightDetailSectionDisplay>
{footerInfo.last_refresh && (
<InsightDetailSectionDisplay icon={<IconCalculator />} label="Last computed">
<TZLabel time={footerInfo.last_refresh} />
</InsightDetailSectionDisplay>
)}
</div>
</>
)}
</div>
)

View File

@@ -0,0 +1,386 @@
import { Meta, StoryFn } from '@storybook/react'
import { FilterLogicalOperator, PropertyFilterType, PropertyOperator, RecordingUniversalFilters } from '~/types'
import { RecordingsUniversalFiltersDisplay as RecordingsUniversalFiltersDisplayComponent } from './RecordingsUniversalFiltersDisplay'
const meta: Meta = {
title: 'Components/Cards/Recordings Universal Filters Display',
component: RecordingsUniversalFiltersDisplayComponent,
}
export default meta
const Template: StoryFn<{ filters: RecordingUniversalFilters }> = ({ filters }) => {
return (
<div className="bg-surface-primary w-[24rem] p-4 rounded">
<RecordingsUniversalFiltersDisplayComponent filters={filters} />
</div>
)
}
export const BasicDateRange = Template.bind({})
BasicDateRange.args = {
filters: {
date_from: '-7d',
date_to: null,
duration: [],
filter_group: {
type: FilterLogicalOperator.And,
values: [],
},
},
}
export const CustomDateRange = Template.bind({})
CustomDateRange.args = {
filters: {
date_from: '2024-01-01',
date_to: '2024-01-31',
duration: [],
filter_group: {
type: FilterLogicalOperator.And,
values: [],
},
},
}
export const WithDurationFilter = Template.bind({})
WithDurationFilter.args = {
filters: {
date_from: '-7d',
date_to: null,
duration: [
{
type: PropertyFilterType.Recording,
key: 'duration',
value: 60,
operator: PropertyOperator.GreaterThan,
},
],
filter_group: {
type: FilterLogicalOperator.And,
values: [],
},
},
}
export const WithMultipleDurationFilters = Template.bind({})
WithMultipleDurationFilters.args = {
filters: {
date_from: '-7d',
date_to: null,
duration: [
{
type: PropertyFilterType.Recording,
key: 'duration',
value: 30,
operator: PropertyOperator.GreaterThan,
},
{
type: PropertyFilterType.Recording,
key: 'duration',
value: 300,
operator: PropertyOperator.LessThan,
},
],
filter_group: {
type: FilterLogicalOperator.And,
values: [],
},
},
}
export const WithPropertyFilters = Template.bind({})
WithPropertyFilters.args = {
filters: {
date_from: '-7d',
date_to: null,
duration: [],
filter_group: {
type: FilterLogicalOperator.And,
values: [
{
type: PropertyFilterType.Event,
key: 'browser',
value: ['Chrome', 'Firefox'],
operator: PropertyOperator.Exact,
},
{
type: PropertyFilterType.Person,
key: 'email',
value: 'user@example.com',
operator: PropertyOperator.IContains,
},
],
},
},
}
export const WithOrFilters = Template.bind({})
WithOrFilters.args = {
filters: {
date_from: '-7d',
date_to: null,
duration: [],
filter_group: {
type: FilterLogicalOperator.Or,
values: [
{
type: PropertyFilterType.Event,
key: 'browser',
value: 'Chrome',
operator: PropertyOperator.Exact,
},
{
type: PropertyFilterType.Event,
key: 'browser',
value: 'Safari',
operator: PropertyOperator.Exact,
},
],
},
},
}
export const WithCohortFilter = Template.bind({})
WithCohortFilter.args = {
filters: {
date_from: '-7d',
date_to: null,
duration: [],
filter_group: {
type: FilterLogicalOperator.And,
values: [
{
type: PropertyFilterType.Cohort,
key: 'id',
value: 1,
operator: PropertyOperator.In,
},
],
},
},
}
export const WithTestAccountsExcluded = Template.bind({})
WithTestAccountsExcluded.args = {
filters: {
date_from: '-7d',
date_to: null,
duration: [],
filter_group: {
type: FilterLogicalOperator.And,
values: [],
},
filter_test_accounts: true,
},
}
export const WithOrderingAscending = Template.bind({})
WithOrderingAscending.args = {
filters: {
date_from: '-7d',
date_to: null,
duration: [],
filter_group: {
type: FilterLogicalOperator.And,
values: [],
},
order: 'start_time',
order_direction: 'ASC',
},
}
export const WithOrderingDescending = Template.bind({})
WithOrderingDescending.args = {
filters: {
date_from: '-7d',
date_to: null,
duration: [],
filter_group: {
type: FilterLogicalOperator.And,
values: [],
},
order: 'console_error_count',
order_direction: 'DESC',
},
}
export const WithActivityScoreOrdering = Template.bind({})
WithActivityScoreOrdering.args = {
filters: {
date_from: '-7d',
date_to: null,
duration: [],
filter_group: {
type: FilterLogicalOperator.And,
values: [],
},
order: 'activity_score',
order_direction: 'DESC',
},
}
export const ComplexFilters = Template.bind({})
ComplexFilters.args = {
filters: {
date_from: '-30d',
date_to: null,
duration: [
{
type: PropertyFilterType.Recording,
key: 'duration',
value: 60,
operator: PropertyOperator.GreaterThan,
},
],
filter_group: {
type: FilterLogicalOperator.And,
values: [
{
type: PropertyFilterType.Event,
key: 'browser',
value: ['Chrome', 'Firefox'],
operator: PropertyOperator.Exact,
},
{
type: PropertyFilterType.Person,
key: 'email',
value: '@company.com',
operator: PropertyOperator.IContains,
},
{
type: PropertyFilterType.Event,
key: '$current_url',
value: '/checkout',
operator: PropertyOperator.IContains,
},
],
},
filter_test_accounts: true,
order: 'console_error_count',
order_direction: 'DESC',
},
}
export const NestedFilterGroups = Template.bind({})
NestedFilterGroups.args = {
filters: {
date_from: '-7d',
date_to: null,
duration: [],
filter_group: {
type: FilterLogicalOperator.And,
values: [
{
type: FilterLogicalOperator.Or,
values: [
{
type: PropertyFilterType.Event,
key: 'browser',
value: 'Chrome',
operator: PropertyOperator.Exact,
},
{
type: PropertyFilterType.Event,
key: 'browser',
value: 'Safari',
operator: PropertyOperator.Exact,
},
],
},
{
type: PropertyFilterType.Person,
key: 'is_identified',
value: true,
operator: PropertyOperator.Exact,
},
],
},
},
}
export const AllFiltersEnabled = Template.bind({})
AllFiltersEnabled.args = {
filters: {
date_from: '-14d',
date_to: '-1d',
duration: [
{
type: PropertyFilterType.Recording,
key: 'duration',
value: 30,
operator: PropertyOperator.GreaterThan,
},
{
type: PropertyFilterType.Recording,
key: 'duration',
value: 600,
operator: PropertyOperator.LessThan,
},
],
filter_group: {
type: FilterLogicalOperator.And,
values: [
{
type: PropertyFilterType.Event,
key: 'browser',
value: 'Chrome',
operator: PropertyOperator.Exact,
},
{
type: PropertyFilterType.Event,
key: '$os',
value: ['Mac OS X', 'Windows'],
operator: PropertyOperator.Exact,
},
{
type: FilterLogicalOperator.Or,
values: [
{
type: PropertyFilterType.Person,
key: 'email',
value: '@gmail.com',
operator: PropertyOperator.IContains,
},
{
type: PropertyFilterType.Person,
key: 'email',
value: '@yahoo.com',
operator: PropertyOperator.IContains,
},
],
},
],
},
filter_test_accounts: true,
order: 'activity_score',
order_direction: 'DESC',
},
}
export const MinimalFilters = Template.bind({})
MinimalFilters.args = {
filters: {
date_from: '-3d',
date_to: null,
duration: [],
filter_group: {
type: FilterLogicalOperator.And,
values: [],
},
},
}
export const EmptyFilters = Template.bind({})
EmptyFilters.args = {
filters: {
date_from: '-7d',
date_to: null,
duration: [],
filter_group: {
type: FilterLogicalOperator.And,
values: [],
},
},
}

View File

@@ -0,0 +1,87 @@
import React from 'react'
import { IconClock, IconFilter, IconSort } from '@posthog/icons'
import { LemonTag } from 'lib/lemon-ui/LemonTag/LemonTag'
import { humanFriendlyDurationFilter } from 'scenes/session-recordings/filters/DurationFilter'
import { DurationType, RecordingUniversalFilters } from '~/types'
import { CompactUniversalFiltersDisplay } from './CompactUniversalFiltersDisplay'
import { DateRangeSummary, InsightDetailSectionDisplay } from './InsightDetails'
function DurationSummary({ filters }: { filters: RecordingUniversalFilters }): JSX.Element | null {
if (!filters.duration || filters.duration.length === 0) {
return null
}
return (
<InsightDetailSectionDisplay icon={<IconClock />} label="Duration">
{filters.duration.map((durationFilter, index) => (
<React.Fragment key={index}>
<span className="font-medium">
{humanFriendlyDurationFilter(durationFilter, durationFilter.key as DurationType)}
</span>
{index < filters.duration.length - 1 && ' and '}
</React.Fragment>
))}
</InsightDetailSectionDisplay>
)
}
function FiltersSummary({ filters }: { filters: RecordingUniversalFilters }): JSX.Element | null {
const hasFilters = filters.filter_group && filters.filter_group.values.length > 0
if (!hasFilters && !filters.filter_test_accounts) {
return null
}
return (
<InsightDetailSectionDisplay icon={<IconFilter />} label="Filters">
<CompactUniversalFiltersDisplay groupFilter={filters.filter_group} />
{filters.filter_test_accounts && (
<div>
<LemonTag size="small">Test accounts excluded</LemonTag>
</div>
)}
</InsightDetailSectionDisplay>
)
}
const ORDERABLE_FIELD_LABELS: Record<string, string> = {
start_time: 'Start time',
console_error_count: 'Console errors',
click_count: 'Clicks',
keypress_count: 'Key presses',
mouse_activity_count: 'Mouse activity',
activity_score: 'Activity score',
recording_ttl: 'Recording TTL',
}
function OrderingSummary({ filters }: { filters: RecordingUniversalFilters }): JSX.Element | null {
if (!filters.order && !filters.order_direction) {
return null
}
const orderLabel = filters.order ? ORDERABLE_FIELD_LABELS[filters.order] || filters.order : 'Start time'
const direction = filters.order_direction === 'ASC' ? 'ascending' : 'descending'
return (
<InsightDetailSectionDisplay icon={<IconSort />} label="Sort order">
<div className="font-medium">
{orderLabel} ({direction})
</div>
</InsightDetailSectionDisplay>
)
}
export function RecordingsUniversalFiltersDisplay({ filters }: { filters: RecordingUniversalFilters }): JSX.Element {
return (
<div className="px-3 py-2 space-y-2">
<DateRangeSummary dateFrom={filters.date_from} dateTo={filters.date_to} />
<DurationSummary filters={filters} />
<FiltersSummary filters={filters} />
<OrderingSummary filters={filters} />
</div>
)
}

View File

@@ -23,6 +23,7 @@ export interface LettermarkProps {
outlined?: boolean
/** @default 'medium' */
size?: 'xsmall' | 'small' | 'medium' | 'xlarge'
className?: string
}
/** An icon-sized lettermark.
@@ -30,7 +31,7 @@ export interface LettermarkProps {
* When given a string, the initial letter is shown. Numbers up to 99 are displayed in full, in integer form.
*/
export const Lettermark = React.forwardRef<HTMLDivElement, LettermarkProps>(function Lettermark(
{ name, index, color, outlined = false, rounded = false, size = 'medium' },
{ name, index, color, outlined = false, rounded = false, size = 'medium', className },
ref
) {
const representation = name
@@ -48,7 +49,8 @@ export const Lettermark = React.forwardRef<HTMLDivElement, LettermarkProps>(func
colorIndex && `Lettermark--variant-${colorIndex}`,
outlined && 'Lettermark--outlined',
rounded && 'Lettermark--rounded',
representation === '?' && 'Lettermark--unknown'
representation === '?' && 'Lettermark--unknown',
className
)}
ref={ref}
>

View File

@@ -318,9 +318,9 @@ export const featureFlagOperatorMap: Record<string, string> = {
}
export const stickinessOperatorMap: Record<string, string> = {
exact: '= Exactly',
gte: '≥ At least',
lte: '≤ At most (but at least once)',
exact: '= exactly',
gte: '≥ at least',
lte: '≤ at most (but at least once)',
}
export const cleanedPathOperatorMap: Record<string, string> = {

View File

@@ -22,11 +22,13 @@ import { mswDecorator, useStorybookMocks } from '~/mocks/browser'
import {
AssistantMessage,
AssistantMessageType,
AssistantToolCallMessage,
MultiVisualizationMessage,
NotebookUpdateMessage,
} from '~/queries/schema/schema-assistant-messages'
import { FunnelsQuery, TrendsQuery } from '~/queries/schema/schema-general'
import { InsightShortId } from '~/types'
import { recordings } from '~/scenes/session-recordings/__mocks__/recordings'
import { FilterLogicalOperator, InsightShortId, PropertyFilterType, PropertyOperator } from '~/types'
import { MaxInstance, MaxInstanceProps } from './Max'
import conversationList from './__mocks__/conversationList.json'
@@ -1309,6 +1311,245 @@ export const ThreadWithSQLQueryOverflow: StoryFn = () => {
return <Template />
}
export const SearchSessionRecordingsEmpty: StoryFn = () => {
// This story demonstrates the search_session_recordings tool with nested filter groups
// showcasing the fix for proper rendering of nested OR/AND groups
const toolCallMessage: AssistantMessage = {
type: AssistantMessageType.Assistant,
content: 'Let me search for those recordings...',
id: 'search-recordings-msg',
tool_calls: [
{
id: 'search_tool_1',
name: 'search_session_recordings',
type: 'tool_call',
args: {},
},
],
}
// Tool call result with nested filter groups: (Chrome AND Mac) OR (Firefox AND Windows)
const toolCallResult: AssistantToolCallMessage = {
type: AssistantMessageType.ToolCall,
tool_call_id: 'search_tool_1',
content: 'Found recordings matching your criteria',
id: 'tool-result-1',
ui_payload: {
search_session_recordings: {
date_from: '-7d',
date_to: null,
duration: [],
filter_group: {
type: FilterLogicalOperator.Or,
values: [
{
type: FilterLogicalOperator.And,
values: [
{
type: PropertyFilterType.Event,
key: 'browser',
value: 'Chrome',
operator: PropertyOperator.Exact,
},
{
type: PropertyFilterType.Event,
key: '$os',
value: 'Mac OS X',
operator: PropertyOperator.Exact,
},
],
},
{
type: FilterLogicalOperator.And,
values: [
{
type: PropertyFilterType.Event,
key: 'browser',
value: 'Firefox',
operator: PropertyOperator.Exact,
},
{
type: PropertyFilterType.Event,
key: '$os',
value: 'Windows',
operator: PropertyOperator.Exact,
},
],
},
],
},
},
},
}
useStorybookMocks({
post: {
'/api/environments/:team_id/conversations/': (_, res, ctx) =>
res(
ctx.text(
generateChunk([
'event: conversation',
`data: ${JSON.stringify({ id: CONVERSATION_ID })}`,
'event: message',
`data: ${JSON.stringify({
...humanMessage,
content:
'Show me recordings where users are on Chrome with Mac OR Firefox with Windows',
})}`,
'event: message',
`data: ${JSON.stringify(toolCallMessage)}`,
'event: message',
`data: ${JSON.stringify(toolCallResult)}`,
])
)
),
},
})
const { setConversationId } = useActions(maxLogic({ tabId: 'storybook' }))
const threadLogic = maxThreadLogic({ conversationId: CONVERSATION_ID, conversation: null, tabId: 'storybook' })
const { askMax } = useActions(threadLogic)
const { dataProcessingAccepted } = useValues(maxGlobalLogic)
useEffect(() => {
if (dataProcessingAccepted) {
setTimeout(() => {
setConversationId(CONVERSATION_ID)
askMax('Show me recordings where users are on Chrome with Mac OR Firefox with Windows')
}, 0)
}
}, [dataProcessingAccepted, setConversationId, askMax])
if (!dataProcessingAccepted) {
return <></>
}
return <Template />
}
SearchSessionRecordingsEmpty.parameters = {
testOptions: {
waitForLoadersToDisappear: false,
},
}
export const SearchSessionRecordingsWithResults: StoryFn = () => {
const toolCallMessage: AssistantMessage = {
type: AssistantMessageType.Assistant,
content: 'Let me search for those recordings...',
id: 'search-recordings-with-results-msg',
tool_calls: [
{
id: 'search_tool_1',
name: 'search_session_recordings',
type: 'tool_call',
args: {},
},
],
}
// Tool call result with filter for Microsoft Edge on Linux
const toolCallResult: AssistantToolCallMessage = {
type: AssistantMessageType.ToolCall,
tool_call_id: 'search_tool_1',
content: 'Found 2 recordings matching your criteria',
id: 'tool-result-with-recordings-1',
ui_payload: {
search_session_recordings: {
date_from: '-7d',
date_to: null,
duration: [],
filter_group: {
type: FilterLogicalOperator.And,
values: [
{
type: FilterLogicalOperator.And,
values: [
{
type: PropertyFilterType.Event,
key: '$browser',
value: 'Microsoft Edge',
operator: PropertyOperator.Exact,
},
{
type: PropertyFilterType.Event,
key: '$os',
value: 'Linux',
operator: PropertyOperator.Exact,
},
],
},
],
},
},
},
}
useStorybookMocks({
post: {
'/api/environments/:team_id/conversations/': (_, res, ctx) =>
res(
ctx.text(
generateChunk([
'event: conversation',
`data: ${JSON.stringify({ id: CONVERSATION_ID })}`,
'event: message',
`data: ${JSON.stringify({
...humanMessage,
content: 'Show me recordings where users are on Microsoft Edge with Linux',
})}`,
'event: message',
`data: ${JSON.stringify(toolCallMessage)}`,
'event: message',
`data: ${JSON.stringify(toolCallResult)}`,
])
)
),
},
})
const { setConversationId } = useActions(maxLogic({ tabId: 'storybook' }))
const threadLogic = maxThreadLogic({ conversationId: CONVERSATION_ID, conversation: null, tabId: 'storybook' })
const { askMax } = useActions(threadLogic)
const { dataProcessingAccepted } = useValues(maxGlobalLogic)
useEffect(() => {
if (dataProcessingAccepted) {
setTimeout(() => {
setConversationId(CONVERSATION_ID)
askMax('Show me recordings where users are on Microsoft Edge with Linux')
}, 0)
}
}, [dataProcessingAccepted, setConversationId, askMax])
if (!dataProcessingAccepted) {
return <></>
}
return <Template />
}
SearchSessionRecordingsWithResults.decorators = [
mswDecorator({
get: {
'/api/environments/:team_id/session_recordings': (req) => {
const version = req.url.searchParams.get('version')
return [
200,
{
has_next: false,
results: recordings,
version,
},
]
},
},
}),
]
SearchSessionRecordingsWithResults.parameters = {
testOptions: {
waitForLoadersToDisappear: false,
},
}
function generateChunk(events: string[]): string {
return events.map((event) => (event.startsWith('event:') ? `${event}\n` : `${event}\n\n`)).join('')
}

View File

@@ -75,6 +75,8 @@ import { ToolRegistration, getToolDefinition } from './max-constants'
import { maxGlobalLogic } from './maxGlobalLogic'
import { MessageStatus, ThreadMessage, maxLogic } from './maxLogic'
import { maxThreadLogic } from './maxThreadLogic'
import { MessageTemplate } from './messages/MessageTemplate'
import { UIPayloadAnswer } from './messages/UIPayloadAnswer'
import { MAX_SLASH_COMMANDS } from './slash-commands'
import {
castAssistantQuery,
@@ -302,6 +304,20 @@ function Message({ message, isLastInGroup, isFinal }: MessageProps): JSX.Element
{actionsElement}
</div>
)
} else if (
isAssistantToolCallMessage(message) &&
message.ui_payload &&
Object.keys(message.ui_payload).length > 0
) {
const [toolName, toolPayload] = Object.entries(message.ui_payload)[0]
return (
<UIPayloadAnswer
key={key}
toolCallId={message.tool_call_id}
toolName={toolName}
toolPayload={toolPayload}
/>
)
} else if (isAssistantToolCallMessage(message) || isFailureMessage(message)) {
return (
<TextAnswer
@@ -360,48 +376,6 @@ function MessageGroupSkeleton({
)
}
interface MessageTemplateProps {
type: 'human' | 'ai'
action?: React.ReactNode
className?: string
boxClassName?: string
wrapperClassName?: string
children?: React.ReactNode
header?: React.ReactNode
}
const MessageTemplate = React.forwardRef<HTMLDivElement, MessageTemplateProps>(function MessageTemplate(
{ type, children, className, boxClassName, wrapperClassName, action, header },
ref
) {
return (
<div
className={twMerge(
'flex flex-col gap-px w-full break-words scroll-mt-12',
type === 'human' ? 'items-end' : 'items-start',
className
)}
ref={ref}
>
<div className={twMerge('max-w-full', wrapperClassName)}>
{header}
{children && (
<div
className={twMerge(
'border py-2 px-3 rounded-lg bg-surface-primary',
type === 'human' && 'font-medium',
boxClassName
)}
>
{children}
</div>
)}
</div>
{action}
</div>
)
})
interface TextAnswerProps {
message: (AssistantMessage | FailureMessage | AssistantToolCallMessage) & ThreadMessage
interactable?: boolean

View File

@@ -35,6 +35,7 @@ import {
AssistantGenerationStatusType,
AssistantMessage,
AssistantMessageType,
AssistantTool,
AssistantUpdateEvent,
FailureMessage,
HumanMessage,
@@ -47,6 +48,7 @@ import { maxBillingContextLogic } from './maxBillingContextLogic'
import { maxGlobalLogic } from './maxGlobalLogic'
import { maxLogic } from './maxLogic'
import type { maxThreadLogicType } from './maxThreadLogicType'
import { RENDERABLE_UI_PAYLOAD_TOOLS } from './messages/UIPayloadAnswer'
import { MAX_SLASH_COMMANDS, SlashCommand } from './slash-commands'
import { isAssistantMessage, isAssistantToolCallMessage, isHumanMessage, isNotebookUpdateMessage } from './utils'
import { getRandomThinkingMessage } from './utils/thinkingMessages'
@@ -516,12 +518,15 @@ export const maxThreadLogic = kea<maxThreadLogicType>([
for (let i = 0; i < thread.length; i++) {
const currentMessage: ThreadMessage = thread[i]
// Skip AssistantToolCallMessage - they're now merged into AssistantMessage tool_calls
if (currentMessage.type === AssistantMessageType.ToolCall) {
// Skip AssistantToolCallMessage that don't have a renderable UI payload
if (
currentMessage.type === AssistantMessageType.ToolCall &&
!Object.keys(currentMessage.ui_payload || {}).some((toolName) =>
RENDERABLE_UI_PAYLOAD_TOOLS.includes(toolName as AssistantTool)
)
) {
continue
}
// Skip empty assistant messages with no content, tool calls, or thinking
if (
currentMessage.type === AssistantMessageType.Assistant &&
@@ -533,7 +538,6 @@ export const maxThreadLogic = kea<maxThreadLogicType>([
) {
continue
}
processedThread.push(currentMessage)
}

View File

@@ -0,0 +1,44 @@
import React from 'react'
import { twMerge } from 'tailwind-merge'
interface MessageTemplateProps {
type: 'human' | 'ai'
action?: React.ReactNode
className?: string
boxClassName?: string
wrapperClassName?: string
children?: React.ReactNode
header?: React.ReactNode
}
export const MessageTemplate = React.forwardRef<HTMLDivElement, MessageTemplateProps>(function MessageTemplate(
{ type, children, className, boxClassName, wrapperClassName, action, header },
ref
) {
return (
<div
className={twMerge(
'flex flex-col gap-px w-full break-words scroll-mt-12',
type === 'human' ? 'items-end' : 'items-start',
className
)}
ref={ref}
>
<div className={twMerge('max-w-full', wrapperClassName)}>
{header}
{children && (
<div
className={twMerge(
'border py-2 px-3 rounded-lg bg-surface-primary',
type === 'human' && 'font-medium',
boxClassName
)}
>
{children}
</div>
)}
</div>
{action}
</div>
)
})

View File

@@ -0,0 +1,7 @@
import { RecordingsUniversalFiltersDisplay } from 'lib/components/Cards/InsightCard/RecordingsUniversalFiltersDisplay'
import { RecordingUniversalFilters } from '~/types'
export function RecordingsFiltersSummary({ filters }: { filters: RecordingUniversalFilters }): JSX.Element {
return <RecordingsUniversalFiltersDisplay filters={filters} />
}

View File

@@ -0,0 +1,103 @@
import { BindLogic, useActions, useValues } from 'kea'
import { LemonButton, Spinner } from '@posthog/lemon-ui'
import { EmptyMessage } from 'lib/components/EmptyMessage/EmptyMessage'
import { SessionRecordingPreview } from 'scenes/session-recordings/playlist/SessionRecordingPreview'
import {
SessionRecordingPlaylistLogicProps,
sessionRecordingsPlaylistLogic,
} from 'scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic'
import { AssistantTool } from '~/queries/schema/schema-assistant-messages'
import { RecordingUniversalFilters } from '~/types'
import { MessageTemplate } from './MessageTemplate'
import { RecordingsFiltersSummary } from './RecordingsFiltersSummary'
export const RENDERABLE_UI_PAYLOAD_TOOLS: AssistantTool[] = ['search_session_recordings']
export function UIPayloadAnswer({
toolCallId,
toolName,
toolPayload,
}: {
toolCallId: string
toolName: string
toolPayload: any
}): JSX.Element | null {
if (toolName === 'search_session_recordings') {
const filters = toolPayload as RecordingUniversalFilters
return <RecordingsWidget toolCallId={toolCallId} filters={filters} />
}
// It's not expected to hit the null branch below, because such a case SHOULD have already been filtered out
// in maxThreadLogic.selectors.threadGrouped, but better safe than sorry - there can be deployments mismatches etc.
return null
}
function RecordingsWidget({
toolCallId,
filters,
}: {
toolCallId: string
filters: RecordingUniversalFilters
}): JSX.Element {
const logicProps: SessionRecordingPlaylistLogicProps = {
logicKey: `ai-recordings-widget-${toolCallId}`,
filters,
updateSearchParams: false,
autoPlay: false,
}
return (
<BindLogic logic={sessionRecordingsPlaylistLogic} props={logicProps}>
<MessageTemplate type="ai" wrapperClassName="w-full" boxClassName="p-0 overflow-hidden">
<RecordingsFiltersSummary filters={filters} />
<RecordingsListContent />
</MessageTemplate>
</BindLogic>
)
}
function RecordingsListContent(): JSX.Element {
const { otherRecordings, sessionRecordingsResponseLoading, hasNext } = useValues(sessionRecordingsPlaylistLogic)
const { maybeLoadSessionRecordings } = useActions(sessionRecordingsPlaylistLogic)
const hasRecordings = otherRecordings.length > 0
return (
<div className="*:border-t max-h-80 overflow-y-auto">
{sessionRecordingsResponseLoading && !hasRecordings ? (
<div className="flex items-center justify-center gap-2 py-12 text-muted">
<Spinner textColored />
<span>Loading recordings...</span>
</div>
) : !hasRecordings ? (
<div className="py-2">
<EmptyMessage title="No recordings found" description="No recordings match the specified filters" />
</div>
) : (
<>
{otherRecordings.map((recording) => (
<div key={recording.id}>
<SessionRecordingPreview recording={recording} selectable={false} />
</div>
))}
{hasNext && (
<div className="p-3">
<LemonButton
fullWidth
type="secondary"
size="small"
onClick={() => maybeLoadSessionRecordings('older')}
loading={sessionRecordingsResponseLoading}
>
Load more recordings
</LemonButton>
</div>
)}
</>
)}
</div>
)
}