diff --git a/frontend/__snapshots__/scenes-app-max-ai--thread-with-opened-suggestions--dark.png b/frontend/__snapshots__/scenes-app-max-ai--thread-with-opened-suggestions--dark.png
new file mode 100644
index 000000000000..7cebd1e475f8
Binary files /dev/null and b/frontend/__snapshots__/scenes-app-max-ai--thread-with-opened-suggestions--dark.png differ
diff --git a/frontend/__snapshots__/scenes-app-max-ai--thread-with-opened-suggestions--light.png b/frontend/__snapshots__/scenes-app-max-ai--thread-with-opened-suggestions--light.png
new file mode 100644
index 000000000000..1e98f9c306c3
Binary files /dev/null and b/frontend/__snapshots__/scenes-app-max-ai--thread-with-opened-suggestions--light.png differ
diff --git a/frontend/__snapshots__/scenes-app-max-ai--thread-with-opened-suggestions-mobile--dark.png b/frontend/__snapshots__/scenes-app-max-ai--thread-with-opened-suggestions-mobile--dark.png
new file mode 100644
index 000000000000..7cebd1e475f8
Binary files /dev/null and b/frontend/__snapshots__/scenes-app-max-ai--thread-with-opened-suggestions-mobile--dark.png differ
diff --git a/frontend/__snapshots__/scenes-app-max-ai--thread-with-opened-suggestions-mobile--light.png b/frontend/__snapshots__/scenes-app-max-ai--thread-with-opened-suggestions-mobile--light.png
new file mode 100644
index 000000000000..1e98f9c306c3
Binary files /dev/null and b/frontend/__snapshots__/scenes-app-max-ai--thread-with-opened-suggestions-mobile--light.png differ
diff --git a/frontend/__snapshots__/scenes-app-max-ai--welcome--dark.png b/frontend/__snapshots__/scenes-app-max-ai--welcome--dark.png
index 20171f25c6e8..426212ae3038 100644
Binary files a/frontend/__snapshots__/scenes-app-max-ai--welcome--dark.png and b/frontend/__snapshots__/scenes-app-max-ai--welcome--dark.png differ
diff --git a/frontend/__snapshots__/scenes-app-max-ai--welcome--light.png b/frontend/__snapshots__/scenes-app-max-ai--welcome--light.png
index 5e35e475837b..faaa3a80a527 100644
Binary files a/frontend/__snapshots__/scenes-app-max-ai--welcome--light.png and b/frontend/__snapshots__/scenes-app-max-ai--welcome--light.png differ
diff --git a/frontend/__snapshots__/scenes-app-max-ai--welcome-feature-preview-auto-enrolled--dark.png b/frontend/__snapshots__/scenes-app-max-ai--welcome-feature-preview-auto-enrolled--dark.png
index 3fe4a47524a7..d3f498469745 100644
Binary files a/frontend/__snapshots__/scenes-app-max-ai--welcome-feature-preview-auto-enrolled--dark.png and b/frontend/__snapshots__/scenes-app-max-ai--welcome-feature-preview-auto-enrolled--dark.png differ
diff --git a/frontend/__snapshots__/scenes-app-max-ai--welcome-feature-preview-auto-enrolled--light.png b/frontend/__snapshots__/scenes-app-max-ai--welcome-feature-preview-auto-enrolled--light.png
index b67a0e9d9857..92f9be50be87 100644
Binary files a/frontend/__snapshots__/scenes-app-max-ai--welcome-feature-preview-auto-enrolled--light.png and b/frontend/__snapshots__/scenes-app-max-ai--welcome-feature-preview-auto-enrolled--light.png differ
diff --git a/frontend/__snapshots__/scenes-app-max-ai--welcome-loading-suggestions--dark.png b/frontend/__snapshots__/scenes-app-max-ai--welcome-loading-suggestions--dark.png
index 74bf41a03cf4..a28e65f3aa33 100644
Binary files a/frontend/__snapshots__/scenes-app-max-ai--welcome-loading-suggestions--dark.png and b/frontend/__snapshots__/scenes-app-max-ai--welcome-loading-suggestions--dark.png differ
diff --git a/frontend/__snapshots__/scenes-app-max-ai--welcome-loading-suggestions--light.png b/frontend/__snapshots__/scenes-app-max-ai--welcome-loading-suggestions--light.png
index d64c059bf3a6..b13896abff50 100644
Binary files a/frontend/__snapshots__/scenes-app-max-ai--welcome-loading-suggestions--light.png and b/frontend/__snapshots__/scenes-app-max-ai--welcome-loading-suggestions--light.png differ
diff --git a/frontend/__snapshots__/scenes-app-max-ai--welcome-suggestions-available--dark.png b/frontend/__snapshots__/scenes-app-max-ai--welcome-suggestions-available--dark.png
index 24f0c4b3d3d6..a28e65f3aa33 100644
Binary files a/frontend/__snapshots__/scenes-app-max-ai--welcome-suggestions-available--dark.png and b/frontend/__snapshots__/scenes-app-max-ai--welcome-suggestions-available--dark.png differ
diff --git a/frontend/__snapshots__/scenes-app-max-ai--welcome-suggestions-available--light.png b/frontend/__snapshots__/scenes-app-max-ai--welcome-suggestions-available--light.png
index 7cc0c36aa683..b13896abff50 100644
Binary files a/frontend/__snapshots__/scenes-app-max-ai--welcome-suggestions-available--light.png and b/frontend/__snapshots__/scenes-app-max-ai--welcome-suggestions-available--light.png differ
diff --git a/frontend/__snapshots__/scenes-app-max-ai--welcome-with-latest-conversations--dark.png b/frontend/__snapshots__/scenes-app-max-ai--welcome-with-latest-conversations--dark.png
index 4d572e50148e..c088b7c81061 100644
Binary files a/frontend/__snapshots__/scenes-app-max-ai--welcome-with-latest-conversations--dark.png and b/frontend/__snapshots__/scenes-app-max-ai--welcome-with-latest-conversations--dark.png differ
diff --git a/frontend/__snapshots__/scenes-app-max-ai--welcome-with-latest-conversations--light.png b/frontend/__snapshots__/scenes-app-max-ai--welcome-with-latest-conversations--light.png
index 3c20cdbbc788..eac9a87afcd0 100644
Binary files a/frontend/__snapshots__/scenes-app-max-ai--welcome-with-latest-conversations--light.png and b/frontend/__snapshots__/scenes-app-max-ai--welcome-with-latest-conversations--light.png differ
diff --git a/frontend/__snapshots__/scenes-app-sidepanels--side-panel-max--dark.png b/frontend/__snapshots__/scenes-app-sidepanels--side-panel-max--dark.png
index e978bc2d20f4..60799f15fcdc 100644
Binary files a/frontend/__snapshots__/scenes-app-sidepanels--side-panel-max--dark.png and b/frontend/__snapshots__/scenes-app-sidepanels--side-panel-max--dark.png differ
diff --git a/frontend/__snapshots__/scenes-app-sidepanels--side-panel-max--light.png b/frontend/__snapshots__/scenes-app-sidepanels--side-panel-max--light.png
index 1fe513842d56..87ea55f0efb0 100644
Binary files a/frontend/__snapshots__/scenes-app-sidepanels--side-panel-max--light.png and b/frontend/__snapshots__/scenes-app-sidepanels--side-panel-max--light.png differ
diff --git a/frontend/package.json b/frontend/package.json
index d058ff60616f..1c4658c90ec9 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -88,9 +88,11 @@
"@posthog/tailwind": "workspace:*",
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-context-menu": "^2.2.6",
+ "@radix-ui/react-dismissable-layer": "^1.1.10",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-slot": "^1.1.2",
+ "@radix-ui/react-toggle-group": "^1.1.10",
"@react-hook/size": "^2.1.2",
"@stripe/react-stripe-js": "^2.8.0",
"@stripe/stripe-js": "^4.5.0",
diff --git a/frontend/src/scenes/data-warehouse/editor/QueryPane.tsx b/frontend/src/scenes/data-warehouse/editor/QueryPane.tsx
index 87b956e07831..62a584d433a8 100644
--- a/frontend/src/scenes/data-warehouse/editor/QueryPane.tsx
+++ b/frontend/src/scenes/data-warehouse/editor/QueryPane.tsx
@@ -85,7 +85,7 @@ export function QueryPane(props: QueryPaneProps): JSX.Element {
}}
introOverride={{
headline: 'What data do you want to analyze?',
- description: 'On this page, I can help you write and tweak SQL queries.',
+ description: 'Let me help you quickly write SQL, and tweak it.',
}}
>
diff --git a/frontend/src/scenes/max/Max.stories.tsx b/frontend/src/scenes/max/Max.stories.tsx
index a4788d8497db..50d09cb3bb08 100644
--- a/frontend/src/scenes/max/Max.stories.tsx
+++ b/frontend/src/scenes/max/Max.stories.tsx
@@ -2,7 +2,6 @@ import { Meta, StoryFn } from '@storybook/react'
import { useActions, useValues } from 'kea'
import { MOCK_DEFAULT_ORGANIZATION } from 'lib/api.mock'
import { useEffect } from 'react'
-import { maxSettingsLogic } from 'scenes/settings/environment/maxSettingsLogic'
import { twMerge } from 'tailwind-merge'
import { mswDecorator, useStorybookMocks } from '~/mocks/browser'
@@ -17,7 +16,7 @@ import {
} from './__mocks__/chatResponse.mocks'
import conversationList from './__mocks__/conversationList.json'
import { MaxInstance, MaxInstanceProps } from './Max'
-import { maxLogic } from './maxLogic'
+import { maxLogic, QUESTION_SUGGESTIONS_DATA } from './maxLogic'
import { maxThreadLogic } from './maxThreadLogic'
const meta: Meta = {
@@ -72,53 +71,6 @@ export const Welcome: StoryFn = () => {
return
}
-export const WelcomeSuggestionsAvailable: StoryFn = () => {
- useStorybookMocks({
- post: {
- '/api/environments/:team_id/query/': () => [
- 200,
- {
- questions: [
- 'What are our most popular pages in the blog?',
- 'Where are our new users located?',
- 'Who are the biggest customers using our paid product?',
- 'Which feature drives most usage?',
- ],
- },
- ],
- },
- })
-
- const { loadCoreMemorySuccess } = useActions(maxSettingsLogic)
-
- useEffect(() => {
- loadCoreMemorySuccess({ id: 'x', text: 'A Storybook test.' })
- }, [])
-
- return
-}
-
-export const WelcomeLoadingSuggestions: StoryFn = () => {
- useStorybookMocks({
- post: {
- '/api/environments/:team_id/query/': (_req, _res, ctx) => [ctx.delay('infinite')],
- },
- })
-
- const { loadCoreMemorySuccess } = useActions(maxSettingsLogic)
-
- useEffect(() => {
- loadCoreMemorySuccess({ id: 'x', text: 'A Storybook test.' })
- }, [])
-
- return
-}
-WelcomeLoadingSuggestions.parameters = {
- testOptions: {
- waitForLoadersToDisappear: false,
- },
-}
-
export const WelcomeFeaturePreviewAutoEnrolled: StoryFn = () => {
return
}
@@ -380,3 +332,38 @@ ChatHistoryLoading.parameters = {
waitForLoadersToDisappear: false,
},
}
+
+export const ThreadWithOpenedSuggestionsMobile: StoryFn = () => {
+ const { setActiveGroup } = useActions(maxLogic)
+
+ useEffect(() => {
+ // The largest group is the set up group
+ setActiveGroup(QUESTION_SUGGESTIONS_DATA[3])
+ }, [])
+
+ return
+}
+ThreadWithOpenedSuggestionsMobile.parameters = {
+ testOptions: {
+ waitForLoadersToDisappear: false,
+ },
+ viewport: {
+ defaultViewport: 'mobile2',
+ },
+}
+
+export const ThreadWithOpenedSuggestions: StoryFn = () => {
+ const { setActiveGroup } = useActions(maxLogic)
+
+ useEffect(() => {
+ // The largest group is the set up group
+ setActiveGroup(QUESTION_SUGGESTIONS_DATA[3])
+ }, [])
+
+ return
+}
+ThreadWithOpenedSuggestions.parameters = {
+ testOptions: {
+ waitForLoadersToDisappear: false,
+ },
+}
diff --git a/frontend/src/scenes/max/Max.tsx b/frontend/src/scenes/max/Max.tsx
index 064bd363d8b2..535c4cf42746 100644
--- a/frontend/src/scenes/max/Max.tsx
+++ b/frontend/src/scenes/max/Max.tsx
@@ -34,7 +34,7 @@ import { maxGlobalLogic } from './maxGlobalLogic'
import { maxLogic } from './maxLogic'
import { maxThreadLogic, MaxThreadLogicProps } from './maxThreadLogic'
import { QuestionInput } from './QuestionInput'
-import { QuestionSuggestions } from './QuestionSuggestions'
+import { QuestionInputWithSuggestions } from './QuestionInputWithSuggestions'
import { Thread } from './Thread'
export const scene: SceneExport = {
@@ -207,8 +207,7 @@ export const MaxInstance = React.memo(function MaxInstance({ sidePanel }: MaxIns
)}
-
-
+
diff --git a/frontend/src/scenes/max/QuestionInput.scss b/frontend/src/scenes/max/QuestionInput.scss
new file mode 100644
index 000000000000..01841f7486ce
--- /dev/null
+++ b/frontend/src/scenes/max/QuestionInput.scss
@@ -0,0 +1,41 @@
+.QuestionInput__SuggestionsList {
+ &.QuestionInput__SuggestionsList-enter {
+ opacity: 0;
+ transform: translateY(-0.5rem);
+ }
+
+ &.QuestionInput__SuggestionsList-enter-active {
+ opacity: 1;
+ transition: opacity 150ms ease-out, transform 150ms ease-out;
+ transform: translateY(0);
+ }
+
+ &.QuestionInput__SuggestionsList-exit {
+ opacity: 1;
+ transform: scale(1) translateY(0);
+ }
+
+ &.QuestionInput__SuggestionsList-exit-active {
+ opacity: 0;
+ transition: opacity 150ms ease-out, transform 150ms ease-out;
+ transform: scale(0.95) translateY(-0.5rem);
+ }
+}
+
+.QuestionInput__QuestionSuggestion {
+ opacity: 0;
+ animation: QuestionInput__QuestionSuggestion__slide-in-fade-down 60ms ease-out forwards;
+ animation-delay: calc(var(--index) * 25ms);
+}
+
+@keyframes QuestionInput__QuestionSuggestion__slide-in-fade-down {
+ from {
+ opacity: 0;
+ transform: translateY(-0.5rem);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
diff --git a/frontend/src/scenes/max/QuestionInput.tsx b/frontend/src/scenes/max/QuestionInput.tsx
index 7fe1661df271..e0e12c1db3c4 100644
--- a/frontend/src/scenes/max/QuestionInput.tsx
+++ b/frontend/src/scenes/max/QuestionInput.tsx
@@ -1,15 +1,20 @@
+import './QuestionInput.scss'
+
import { IconArrowRight, IconStopFilled } from '@posthog/icons'
import { LemonButton, LemonTextArea } from '@posthog/lemon-ui'
+import { ToggleGroup, ToggleGroupItem } from '@radix-ui/react-toggle-group'
import clsx from 'clsx'
import { useActions, useValues } from 'kea'
import { IconTools } from 'lib/lemon-ui/icons'
import { useEffect, useRef } from 'react'
+import { CSSTransition } from 'react-transition-group'
import { KeyboardShortcut } from '~/layout/navigation-3000/components/KeyboardShortcut'
import { maxGlobalLogic } from './maxGlobalLogic'
-import { maxLogic } from './maxLogic'
+import { maxLogic, SuggestionGroup } from './maxLogic'
import { maxThreadLogic } from './maxThreadLogic'
+import { checkSuggestionRequiresUserInput, formatSuggestion, stripSuggestionPlaceholders } from './utils'
interface QuestionInputProps {
isFloating?: boolean
@@ -18,7 +23,7 @@ interface QuestionInputProps {
export function QuestionInput({ isFloating }: QuestionInputProps): JSX.Element {
const { tools } = useValues(maxGlobalLogic)
- const { question } = useValues(maxLogic)
+ const { question, focusCounter } = useValues(maxLogic)
const { setQuestion } = useActions(maxLogic)
const { threadLoading, inputDisabled, submissionDisabledReason } = useValues(maxThreadLogic)
@@ -34,11 +39,10 @@ export function QuestionInput({ isFloating }: QuestionInputProps): JSX.Element {
useEffect(() => {
if (textAreaRef.current) {
- // Autofocus, but a version that also moves cursor to end of text
textAreaRef.current.focus()
textAreaRef.current.setSelectionRange(textAreaRef.current.value.length, textAreaRef.current.value.length)
}
- }, [])
+ }, [focusCounter]) // Update focus when focusCounter changes
return (
: }
/>
+ {!isFloating && }
{tools.length > 0 && (
)
}
+
+function SuggestionsList(): JSX.Element {
+ const focusElementRef = useRef
(null)
+ const previousSuggestionGroup = useRef(null)
+
+ const { setQuestion, focusInput, setActiveGroup } = useActions(maxLogic)
+ const { activeSuggestionGroup } = useValues(maxLogic)
+ const { askMax } = useActions(maxThreadLogic)
+
+ useEffect(() => {
+ if (focusElementRef.current && activeSuggestionGroup) {
+ focusElementRef.current.focus()
+ }
+ previousSuggestionGroup.current = activeSuggestionGroup
+ }, [activeSuggestionGroup])
+
+ const memoizedSuggestion = activeSuggestionGroup || previousSuggestionGroup.current
+
+ return (
+
+ {
+ const suggestion = activeSuggestionGroup?.suggestions[Number(index)]
+ if (!suggestion) {
+ return
+ }
+
+ if (checkSuggestionRequiresUserInput(suggestion.content)) {
+ // Content requires to write something to continue
+ setQuestion(stripSuggestionPlaceholders(suggestion.content))
+ focusInput()
+ } else {
+ // Otherwise, just launch the generation
+ askMax(suggestion.content)
+ }
+
+ // Close suggestions after asking
+ setActiveGroup(null)
+ }}
+ >
+ {memoizedSuggestion?.suggestions.map((suggestion, index) => (
+
+
+ {formatSuggestion(suggestion.content)}
+
+
+ ))}
+
+
+ )
+}
diff --git a/frontend/src/scenes/max/QuestionInputWithSuggestions.tsx b/frontend/src/scenes/max/QuestionInputWithSuggestions.tsx
new file mode 100644
index 000000000000..ecc9a82eb3e6
--- /dev/null
+++ b/frontend/src/scenes/max/QuestionInputWithSuggestions.tsx
@@ -0,0 +1,84 @@
+import { IconGear } from '@posthog/icons'
+import { LemonButton } from '@posthog/lemon-ui'
+import { DismissableLayer } from '@radix-ui/react-dismissable-layer'
+import { useActions, useValues } from 'kea'
+import { router } from 'kea-router'
+
+import { sidePanelSettingsLogic } from '~/layout/navigation-3000/sidepanel/panels/sidePanelSettingsLogic'
+
+import { maxLogic, QUESTION_SUGGESTIONS_DATA } from './maxLogic'
+import { maxThreadLogic } from './maxThreadLogic'
+import { QuestionInput } from './QuestionInput'
+import { checkSuggestionRequiresUserInput, stripSuggestionPlaceholders } from './utils'
+
+export function QuestionInputWithSuggestions(): JSX.Element {
+ const { dataProcessingAccepted, activeSuggestionGroup } = useValues(maxLogic)
+ const { setQuestion, focusInput, setActiveGroup } = useActions(maxLogic)
+ const { askMax } = useActions(maxThreadLogic)
+ const { openSettingsPanel } = useActions(sidePanelSettingsLogic)
+
+ return (
+ {
+ if (activeSuggestionGroup) {
+ setActiveGroup(null)
+ }
+ }}
+ >
+
+
+
Ask Max about…
+
+ {QUESTION_SUGGESTIONS_DATA.map((group) => (
+ -
+ {
+ // If it's a product-based skill, open the URL first
+ if (group.url && !router.values.currentLocation.pathname.includes(group.url)) {
+ router.actions.push(group.url)
+ }
+
+ // If there's only one suggestion, we can just ask Max directly
+ if (group.suggestions.length <= 1) {
+ if (checkSuggestionRequiresUserInput(group.suggestions[0].content)) {
+ // Content requires to write something to continue
+ setQuestion(stripSuggestionPlaceholders(group.suggestions[0].content))
+ focusInput()
+ } else {
+ // Otherwise, just launch the generation
+ askMax(group.suggestions[0].content)
+ }
+ } else {
+ setActiveGroup(group)
+ }
+ }}
+ size="xsmall"
+ type="secondary"
+ icon={group.icon}
+ center
+ disabledReason={
+ !dataProcessingAccepted ? 'Please accept OpenAI processing data' : undefined
+ }
+ tooltip={group.tooltip}
+ >
+ {group.label}
+
+
+ ))}
+ -
+
+ openSettingsPanel({ sectionId: 'environment-max', settingId: 'core-memory' })
+ }
+ size="xsmall"
+ type="secondary"
+ icon={}
+ tooltip="Edit Max's memory"
+ />
+
+
+
+
+ )
+}
diff --git a/frontend/src/scenes/max/QuestionSuggestions.tsx b/frontend/src/scenes/max/QuestionSuggestions.tsx
deleted file mode 100644
index 3cc4e1e815d7..000000000000
--- a/frontend/src/scenes/max/QuestionSuggestions.tsx
+++ /dev/null
@@ -1,94 +0,0 @@
-import { IconArrowUpRight, IconGear, IconShuffle } from '@posthog/icons'
-import { LemonButton, LemonSkeleton } from '@posthog/lemon-ui'
-import { useActions, useValues } from 'kea'
-import { maxSettingsLogic } from 'scenes/settings/environment/maxSettingsLogic'
-
-import { sidePanelSettingsLogic } from '~/layout/navigation-3000/sidepanel/panels/sidePanelSettingsLogic'
-
-import { maxLogic } from './maxLogic'
-import { maxThreadLogic } from './maxThreadLogic'
-
-export function QuestionSuggestions(): JSX.Element {
- const { visibleSuggestions, allSuggestionsLoading, dataProcessingAccepted, tools } = useValues(maxLogic)
- const { shuffleVisibleSuggestions } = useActions(maxLogic)
- const { askMax } = useActions(maxThreadLogic)
- const { coreMemory, coreMemoryLoading } = useValues(maxSettingsLogic)
- const { openSettingsPanel } = useActions(sidePanelSettingsLogic)
-
- if (tools.length > 0) {
- return <>>
- }
-
- if (!coreMemoryLoading && !coreMemory) {
- return (
- askMax('Ready, steady, go!')}
- disabledReason={!dataProcessingAccepted ? 'Please accept OpenAI processing data' : undefined}
- center
- >
- Let's get started with me learning about your project!
-
- )
- }
-
- return (
-
- {
- coreMemoryLoading || allSuggestionsLoading ? (
- Array.from({ length: 3 }).map((_, index) => (
-
-
-
- ))
- ) : visibleSuggestions ? (
- <>
- {visibleSuggestions.map((suggestion, index) => (
-
askMax(suggestion)}
- size="xsmall"
- type="secondary"
- sideIcon={}
- center
- className="shrink"
- disabledReason={
- !dataProcessingAccepted ? 'Please accept OpenAI processing data' : undefined
- }
- >
- {suggestion}
-
- ))}
-
- }
- tooltip="Shuffle suggestions"
- />
-
- openSettingsPanel({ sectionId: 'environment-max', settingId: 'core-memory' })
- }
- size="xsmall"
- type="secondary"
- icon={}
- tooltip="Edit Max's memory"
- />
-
- >
- ) : null /* Some error */
- }
-
- )
-}
diff --git a/frontend/src/scenes/max/maxLogic.test.ts b/frontend/src/scenes/max/maxLogic.test.ts
index e3e80327fc7a..86dcbc528611 100644
--- a/frontend/src/scenes/max/maxLogic.test.ts
+++ b/frontend/src/scenes/max/maxLogic.test.ts
@@ -1,11 +1,11 @@
import { router } from 'kea-router'
-import { expectLogic } from 'kea-test-utils'
+import { expectLogic, partial } from 'kea-test-utils'
import { sidePanelStateLogic } from '~/layout/navigation-3000/sidepanel/sidePanelStateLogic'
import { useMocks } from '~/mocks/jest'
import { initKeaTests } from '~/test/init'
-import { maxLogic } from './maxLogic'
+import { maxLogic, QUESTION_SUGGESTIONS_DATA } from './maxLogic'
import { maxMocks, mockStream } from './testUtils'
describe('maxLogic', () => {
@@ -86,4 +86,39 @@ describe('maxLogic', () => {
})
expect(streamSpy).not.toHaveBeenCalled()
})
+
+ it('manages suggestion group selection correctly', async () => {
+ logic = maxLogic()
+ logic.mount()
+
+ await expectLogic(logic).toMatchValues({
+ activeSuggestionGroup: null,
+ })
+
+ await expectLogic(logic, () => {
+ logic.actions.setActiveGroup(QUESTION_SUGGESTIONS_DATA[1])
+ })
+ .toDispatchActions(['setActiveGroup'])
+ .toMatchValues({
+ activeSuggestionGroup: partial({
+ label: 'SQL',
+ }),
+ })
+
+ // Test setting to null clears the selection
+ logic.actions.setActiveGroup(null)
+
+ await expectLogic(logic).toMatchValues({
+ activeSuggestionGroup: null,
+ })
+
+ // Test setting to a different index
+ logic.actions.setActiveGroup(QUESTION_SUGGESTIONS_DATA[0])
+
+ await expectLogic(logic).toMatchValues({
+ activeSuggestionGroup: partial({
+ label: 'Product analytics',
+ }),
+ })
+ })
})
diff --git a/frontend/src/scenes/max/maxLogic.tsx b/frontend/src/scenes/max/maxLogic.tsx
index b7207026e1d4..2150c4838eb8 100644
--- a/frontend/src/scenes/max/maxLogic.tsx
+++ b/frontend/src/scenes/max/maxLogic.tsx
@@ -1,4 +1,4 @@
-import { shuffle } from 'd3'
+import { IconBook, IconGraph, IconHogQL, IconPlug, IconRewindPlay } from '@posthog/icons'
import { actions, afterMount, connect, defaults, kea, listeners, path, reducers, selectors } from 'kea'
import { loaders } from 'kea-loaders'
import { actionToUrl, decodeParams, router, urlToAction } from 'kea-router'
@@ -8,11 +8,12 @@ import { lemonToast } from 'lib/lemon-ui/LemonToast/LemonToast'
import { objectsEqual, uuid } from 'lib/utils'
import { permanentlyMount } from 'lib/utils/kea-logic-builders'
import { maxSettingsLogic } from 'scenes/settings/environment/maxSettingsLogic'
+import { urls } from 'scenes/urls'
import { sidePanelStateLogic } from '~/layout/navigation-3000/sidepanel/sidePanelStateLogic'
import { actionsModel } from '~/models/actionsModel'
+import { productUrls } from '~/products'
import { RootAssistantMessage } from '~/queries/schema/schema-assistant-messages'
-import { NodeKind, RefreshType, SuggestedQuestionsQuery } from '~/queries/schema/schema-general'
import { Conversation, ConversationDetail, ConversationStatus, SidePanelTab } from '~/types'
import { maxGlobalLogic } from './maxGlobalLogic'
@@ -24,6 +25,18 @@ export type ThreadMessage = RootAssistantMessage & {
status: MessageStatus
}
+export interface SuggestionItem {
+ content: string
+}
+
+export interface SuggestionGroup {
+ label: string
+ icon: JSX.Element
+ suggestions: SuggestionItem[]
+ url?: string
+ tooltip?: string
+}
+
const HEADLINES = [
'How can I help you build?',
'What are you curious about?',
@@ -48,8 +61,6 @@ export const maxLogic = kea([
actions({
setQuestion: (question: string) => ({ question }),
- setVisibleSuggestions: (suggestions: string[]) => ({ suggestions }),
- shuffleVisibleSuggestions: true,
scrollThreadToBottom: (behavior?: 'instant' | 'smooth') => ({ behavior }),
setConversationId: (conversationId: string) => ({ conversationId }),
startNewConversation: true,
@@ -66,6 +77,8 @@ export const maxLogic = kea([
}),
goBack: true,
setBackScreen: (screen: 'history') => ({ screen }),
+ focusInput: true,
+ setActiveGroup: (group: SuggestionGroup | null) => ({ group }),
setActiveStreamingThreads: (inc: 1 | -1) => ({ inc }),
setAutoRun: (autoRun: boolean) => ({ autoRun }),
@@ -118,13 +131,6 @@ export const maxLogic = kea([
},
],
- visibleSuggestions: [
- null as string[] | null,
- {
- setVisibleSuggestions: (_, { suggestions }) => suggestions,
- },
- ],
-
conversationHistoryVisible: [
false,
{
@@ -141,6 +147,18 @@ export const maxLogic = kea([
},
],
+ /**
+ * When the focus counter updates, the input component will rerender and refocus the input.
+ */
+ focusCounter: [0, { focusInput: (state) => state + 1 }],
+
+ activeSuggestionGroup: [
+ null as SuggestionGroup | null,
+ {
+ setActiveGroup: (_, { group }) => group,
+ },
+ ],
+
/**
* Identifies the logic ID for each conversation ID.
*/
@@ -161,22 +179,6 @@ export const maxLogic = kea([
}),
loaders({
- // TODO: Move question suggestions to `maxGlobalLogic`, which will make this logic `maxThreadLogic`
- allSuggestions: [
- null as string[] | null,
- {
- loadSuggestions: async ({ refresh }: { refresh: RefreshType }) => {
- const response = await api.query(
- { kind: NodeKind.SuggestedQuestionsQuery },
- undefined,
- undefined,
- refresh
- )
- return response.questions
- },
- },
- ],
-
conversationHistory: [
[] as ConversationDetail[],
{
@@ -208,9 +210,10 @@ export const maxLogic = kea([
description: [
(s) => [s.toolDescriptions],
(toolDescriptions): string => {
- return `I'm Max, here to help you build a successful product. ${
- toolDescriptions.length > 0 ? toolDescriptions[0] : 'Ask me about your product and your users.'
- }`
+ if (toolDescriptions.length > 0) {
+ return `I'm Max. ${toolDescriptions[0]}`
+ }
+ return "I'm Max, here to help you build a successful product."
},
// It's important we use a deep equality check for inputs, because we want to avoid needless re-renders
{ equalityCheck: objectsEqual },
@@ -295,36 +298,6 @@ export const maxLogic = kea([
}),
listeners(({ actions, values }) => ({
- [maxSettingsLogic.actionTypes.updateCoreMemorySuccess]: () => {
- actions.loadSuggestions({ refresh: 'blocking' })
- },
-
- [maxSettingsLogic.actionTypes.loadCoreMemorySuccess]: () => {
- actions.loadSuggestions({ refresh: 'async_except_on_cache_miss' })
- },
-
- loadSuggestionsSuccess: () => {
- actions.shuffleVisibleSuggestions()
- },
-
- shuffleVisibleSuggestions: () => {
- if (!values.allSuggestions) {
- throw new Error('No question suggestions to shuffle')
- }
- const allSuggestionsWithoutCurrentlyVisible = values.allSuggestions.filter(
- (suggestion) => !values.visibleSuggestions?.includes(suggestion)
- )
- if (!process.env.STORYBOOK) {
- // Randomize order, except in Storybook where we want to keep the order consistent for snapshots
- shuffle(allSuggestionsWithoutCurrentlyVisible)
- }
- actions.setVisibleSuggestions(
- // We show 3 suggestions, and put the longest one last, so that the suggestions _as a whole_
- // look pleasant when the 3rd is wrapped to the next line (character count is imperfect but okay)
- allSuggestionsWithoutCurrentlyVisible.slice(0, 3).sort((a, b) => a.length - b.length)
- )
- },
-
scrollThreadToBottom: ({ behavior }) => {
requestAnimationFrame(() => {
// On next frame so that the message has been rendered
@@ -429,12 +402,6 @@ export const maxLogic = kea([
})),
afterMount(({ actions, values }) => {
- // We only load suggestions on mount if core memory is present
- if (values.coreMemory) {
- // In this case we're fine with even really old cached values
- actions.loadSuggestions({ refresh: 'async_except_on_cache_miss' })
- }
-
// If there is a prefill question from side panel state (from opening Max within the app), use it
if (
!values.question &&
@@ -506,6 +473,103 @@ function getScrollableContainer(element?: Element | null): HTMLElement | null {
return scrollableEl
}
+export const QUESTION_SUGGESTIONS_DATA: readonly SuggestionGroup[] = [
+ {
+ label: 'Product analytics',
+ icon: ,
+ suggestions: [
+ {
+ content: 'Create a funnel of the Pirate Metrics (AARRR)',
+ },
+ {
+ content: 'What are the most popular pages or screens?',
+ },
+ {
+ content: 'What is the retention in the last two weeks?',
+ },
+ {
+ content: 'What are the top referring domains?',
+ },
+ {
+ content: 'Calculate a conversion rate for …',
+ },
+ ],
+ tooltip: 'Max can generate insights from natural language and tweak existing ones.',
+ },
+ {
+ label: 'SQL',
+ icon: ,
+ suggestions: [
+ {
+ content: 'Write an SQL query to…',
+ },
+ ],
+ url: urls.sqlEditor(),
+ tooltip: 'Max can generate SQL queries for your PostHog data, both analytics and the data warehouse.',
+ },
+ {
+ label: 'Session replay',
+ icon: ,
+ suggestions: [
+ {
+ content: 'Find recordings for…',
+ },
+ ],
+ url: productUrls.replay(),
+ tooltip: 'Max can find session recordings for you.',
+ },
+ {
+ label: 'SDK setup',
+ icon: ,
+ suggestions: [
+ {
+ content: 'How can I set up the session replay in …',
+ },
+ {
+ content: 'How can I set up the feature flags in…',
+ },
+ {
+ content: 'How can I set up the experiments in…',
+ },
+ {
+ content: 'How can I set up the data warehouse in…',
+ },
+ {
+ content: 'How can I set up the error tracking in…',
+ },
+ {
+ content: 'How can I set up the LLM Observability in…',
+ },
+ {
+ content: 'How can I set up the product analytics in…',
+ },
+ ],
+ tooltip: 'Max can help you set up PostHog SDKs in your stack.',
+ },
+ {
+ label: 'Docs',
+ icon: ,
+ suggestions: [
+ {
+ content: 'How can I create a feature flag?',
+ },
+ {
+ content: 'Where do I watch session replays?',
+ },
+ {
+ content: 'Help me set up an experiment',
+ },
+ {
+ content: 'Explain autocapture',
+ },
+ {
+ content: 'How can I capture an exception?',
+ },
+ ],
+ tooltip: 'Max has access to PostHog docs and can help you get the most out of PostHog.',
+ },
+]
+
/**
* Merges a new conversation into the conversation history.
*/
diff --git a/frontend/src/scenes/max/utils.test.ts b/frontend/src/scenes/max/utils.test.ts
new file mode 100644
index 000000000000..5616cfdde4d3
--- /dev/null
+++ b/frontend/src/scenes/max/utils.test.ts
@@ -0,0 +1,77 @@
+import { checkSuggestionRequiresUserInput, formatSuggestion, stripSuggestionPlaceholders } from './utils'
+
+describe('max/utils', () => {
+ describe('checkSuggestionRequiresUserInput()', () => {
+ it('returns true for suggestions with angle brackets', () => {
+ expect(checkSuggestionRequiresUserInput('Show me over time')).toBe(true)
+ expect(checkSuggestionRequiresUserInput('Compare vs ')).toBe(true)
+ expect(checkSuggestionRequiresUserInput('Filter by ')).toBe(true)
+ })
+
+ it('returns true for suggestions with ellipsis', () => {
+ expect(checkSuggestionRequiresUserInput('Show me trends for…')).toBe(true)
+ expect(checkSuggestionRequiresUserInput('Create a funnel…')).toBe(true)
+ })
+
+ it('returns true for suggestions with mixed placeholders', () => {
+ expect(checkSuggestionRequiresUserInput('Show trends for…')).toBe(true)
+ expect(checkSuggestionRequiresUserInput('Compare over…')).toBe(true)
+ })
+
+ it('returns false for suggestions without placeholders', () => {
+ expect(checkSuggestionRequiresUserInput('Show me page views')).toBe(false)
+ expect(checkSuggestionRequiresUserInput('Create a simple funnel')).toBe(false)
+ expect(checkSuggestionRequiresUserInput('Display user retention')).toBe(false)
+ })
+
+ it('handles empty and edge cases', () => {
+ expect(checkSuggestionRequiresUserInput('')).toBe(false)
+ expect(checkSuggestionRequiresUserInput('No special characters')).toBe(false)
+ expect(checkSuggestionRequiresUserInput('Just some text')).toBe(false)
+ })
+ })
+
+ describe('stripSuggestionPlaceholders()', () => {
+ it('removes angle bracket placeholders', () => {
+ expect(stripSuggestionPlaceholders('Show me over time')).toBe('Show me over time ')
+ expect(stripSuggestionPlaceholders('Filter by ')).toBe('Filter by ')
+ })
+
+ it('handles empty string', () => {
+ expect(stripSuggestionPlaceholders('')).toBe(' ')
+ })
+ })
+
+ describe('formatSuggestion()', () => {
+ it('removes angle brackets but keeps content', () => {
+ expect(formatSuggestion('Show me over time')).toBe('Show me metric over time')
+ expect(formatSuggestion('Compare vs ')).toBe('Compare event1 vs event2')
+ expect(formatSuggestion('Filter by ')).toBe('Filter by property')
+ })
+
+ it('preserves ellipsis at the end', () => {
+ expect(formatSuggestion('Show me trends for…')).toBe('Show me trends for…')
+ expect(formatSuggestion('Create a funnel…')).toBe('Create a funnel…')
+ })
+
+ it('handles mixed placeholders', () => {
+ expect(formatSuggestion('Show trends for…')).toBe('Show metric trends for…')
+ expect(formatSuggestion('Compare over…')).toBe('Compare event over…')
+ })
+
+ it('handles suggestions without placeholders', () => {
+ expect(formatSuggestion('Show me page views')).toBe('Show me page views')
+ expect(formatSuggestion('Create a simple funnel')).toBe('Create a simple funnel')
+ })
+
+ it('trims whitespace', () => {
+ expect(formatSuggestion(' Show me data ')).toBe('Show me data')
+ expect(formatSuggestion(' Show ')).toBe('Show metric')
+ expect(formatSuggestion(' Show data… ')).toBe('Show data…')
+ })
+
+ it('handles empty string', () => {
+ expect(formatSuggestion('')).toBe('')
+ })
+ })
+})
diff --git a/frontend/src/scenes/max/utils.ts b/frontend/src/scenes/max/utils.ts
index d4e075c16160..b49f047e71f8 100644
--- a/frontend/src/scenes/max/utils.ts
+++ b/frontend/src/scenes/max/utils.ts
@@ -112,3 +112,34 @@ export function formatConversationDate(updatedAt: string | null): string {
}
return humanFriendlyDuration(diff, { maxUnits: 1 })
}
+
+/**
+ * Checks if a suggestion requires user input.
+ * @param suggestion - The suggestion to check.
+ * @returns True if the suggestion requires input, false otherwise.
+ */
+export function checkSuggestionRequiresUserInput(suggestion: string): boolean {
+ const matches = suggestion.match(/<|>|…/g)
+ return !!matches && matches.length > 0
+}
+
+/**
+ * Strips the user input placeholder (`<`, `>`, `…`) from a suggestion.
+ * @param suggestion - The suggestion to strip.
+ * @returns The stripped suggestion.
+ */
+export function stripSuggestionPlaceholders(suggestion: string): string {
+ return `${suggestion
+ .replace(/<[^>]*>/g, '')
+ .replace(/…$/, '')
+ .trim()} `
+}
+
+/**
+ * Formats a suggestion by stripping the placeholder characters (`<`, `>`) from a suggestion.
+ * @param suggestion - The suggestion to format.
+ * @returns The formatted suggestion.
+ */
+export function formatSuggestion(suggestion: string): string {
+ return `${suggestion.replace(/[<>]/g, '').replace(/…$/, '').trim()}${suggestion.endsWith('…') ? '…' : ''}`
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cf88ed49ad85..4c945d23cc4a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -647,6 +647,9 @@ importers:
'@radix-ui/react-context-menu':
specifier: ^2.2.6
version: 2.2.6(@types/react-dom@18.2.14)(@types/react@17.0.52)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
+ '@radix-ui/react-dismissable-layer':
+ specifier: ^1.1.10
+ version: 1.1.10(@types/react-dom@18.2.14)(@types/react@17.0.52)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-dropdown-menu':
specifier: ^2.1.6
version: 2.1.6(@types/react-dom@18.2.14)(@types/react@17.0.52)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -656,6 +659,9 @@ importers:
'@radix-ui/react-slot':
specifier: ^1.1.2
version: 1.1.2(@types/react@17.0.52)(react@18.2.0)
+ '@radix-ui/react-toggle-group':
+ specifier: ^1.1.10
+ version: 1.1.10(@types/react-dom@18.2.14)(@types/react@17.0.52)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@react-hook/size':
specifier: ^2.1.2
version: 2.1.2(react@18.2.0)
@@ -4970,6 +4976,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-collection@1.1.7':
+ resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-compose-refs@1.0.1':
resolution: {integrity: sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==}
peerDependencies:
@@ -5055,6 +5074,15 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-direction@1.1.1':
+ resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
'@radix-ui/react-dismissable-layer@1.0.4':
resolution: {integrity: sha512-7UpBa/RKMoHJYjie1gkF1DlK8l1fdU/VKDpoS3rCCo8YBJR294GwcEHyxHw72yvphJ7ld0AXEcSLAzY2F/WyCg==}
peerDependencies:
@@ -5395,6 +5423,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-roving-focus@1.1.10':
+ resolution: {integrity: sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-roving-focus@1.1.2':
resolution: {integrity: sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw==}
peerDependencies:
@@ -5483,6 +5524,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-toggle-group@1.1.10':
+ resolution: {integrity: sha512-kiU694Km3WFLTC75DdqgM/3Jauf3rD9wxeS9XtyWFKsBUeZA337lC+6uUazT7I1DhanZ5gyD5Stf8uf2dbQxOQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-toggle@1.0.3':
resolution: {integrity: sha512-Pkqg3+Bc98ftZGsl60CLANXQBBQ4W3mTFS9EJvNxKMZ7magklKV69/id1mlAlOFDDfHvlCms0fx8fA4CMKDJHg==}
peerDependencies:
@@ -5496,6 +5550,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-toggle@1.1.9':
+ resolution: {integrity: sha512-ZoFkBBz9zv9GWer7wIjvdRxmh2wyc2oKWw6C6CseWd6/yq1DK/l5lJ+wnsmFwJZbBYqr02mrf8A2q/CVCuM3ZA==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-toolbar@1.0.4':
resolution: {integrity: sha512-tBgmM/O7a07xbaEkYJWYTXkIdU/1pW4/KZORR43toC/4XWyBCURK0ei9kMUdp+gTPPKBgYLxXmRSH1EVcIDp8Q==}
peerDependencies:
@@ -20742,6 +20809,18 @@ snapshots:
'@types/react': 17.0.52
'@types/react-dom': 18.2.14
+ '@radix-ui/react-collection@1.1.7(@types/react-dom@18.2.14)(@types/react@17.0.52)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@17.0.52)(react@18.2.0)
+ '@radix-ui/react-context': 1.1.2(@types/react@17.0.52)(react@18.2.0)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.2.14)(@types/react@17.0.52)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
+ '@radix-ui/react-slot': 1.2.3(@types/react@17.0.52)(react@18.2.0)
+ react: 18.2.0
+ react-dom: 18.2.0(react@18.2.0)
+ optionalDependencies:
+ '@types/react': 17.0.52
+ '@types/react-dom': 18.2.14
+
'@radix-ui/react-compose-refs@1.0.1(@types/react@17.0.52)(react@18.2.0)':
dependencies:
'@babel/runtime': 7.24.0
@@ -20807,6 +20886,12 @@ snapshots:
optionalDependencies:
'@types/react': 17.0.52
+ '@radix-ui/react-direction@1.1.1(@types/react@17.0.52)(react@18.2.0)':
+ dependencies:
+ react: 18.2.0
+ optionalDependencies:
+ '@types/react': 17.0.52
+
'@radix-ui/react-dismissable-layer@1.0.4(@types/react-dom@18.2.14)(@types/react@17.0.52)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@babel/runtime': 7.24.0
@@ -21146,6 +21231,23 @@ snapshots:
'@types/react': 17.0.52
'@types/react-dom': 18.2.14
+ '@radix-ui/react-roving-focus@1.1.10(@types/react-dom@18.2.14)(@types/react@17.0.52)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.2
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.2.14)(@types/react@17.0.52)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@17.0.52)(react@18.2.0)
+ '@radix-ui/react-context': 1.1.2(@types/react@17.0.52)(react@18.2.0)
+ '@radix-ui/react-direction': 1.1.1(@types/react@17.0.52)(react@18.2.0)
+ '@radix-ui/react-id': 1.1.1(@types/react@17.0.52)(react@18.2.0)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.2.14)(@types/react@17.0.52)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@17.0.52)(react@18.2.0)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@17.0.52)(react@18.2.0)
+ react: 18.2.0
+ react-dom: 18.2.0(react@18.2.0)
+ optionalDependencies:
+ '@types/react': 17.0.52
+ '@types/react-dom': 18.2.14
+
'@radix-ui/react-roving-focus@1.1.2(@types/react-dom@18.2.14)(@types/react@17.0.52)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.1
@@ -21248,6 +21350,21 @@ snapshots:
'@types/react': 17.0.52
'@types/react-dom': 18.2.14
+ '@radix-ui/react-toggle-group@1.1.10(@types/react-dom@18.2.14)(@types/react@17.0.52)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.2
+ '@radix-ui/react-context': 1.1.2(@types/react@17.0.52)(react@18.2.0)
+ '@radix-ui/react-direction': 1.1.1(@types/react@17.0.52)(react@18.2.0)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.2.14)(@types/react@17.0.52)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
+ '@radix-ui/react-roving-focus': 1.1.10(@types/react-dom@18.2.14)(@types/react@17.0.52)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
+ '@radix-ui/react-toggle': 1.1.9(@types/react-dom@18.2.14)(@types/react@17.0.52)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@17.0.52)(react@18.2.0)
+ react: 18.2.0
+ react-dom: 18.2.0(react@18.2.0)
+ optionalDependencies:
+ '@types/react': 17.0.52
+ '@types/react-dom': 18.2.14
+
'@radix-ui/react-toggle@1.0.3(@types/react-dom@18.2.14)(@types/react@17.0.52)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@babel/runtime': 7.24.0
@@ -21260,6 +21377,17 @@ snapshots:
'@types/react': 17.0.52
'@types/react-dom': 18.2.14
+ '@radix-ui/react-toggle@1.1.9(@types/react-dom@18.2.14)(@types/react@17.0.52)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.2
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.2.14)(@types/react@17.0.52)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@17.0.52)(react@18.2.0)
+ react: 18.2.0
+ react-dom: 18.2.0(react@18.2.0)
+ optionalDependencies:
+ '@types/react': 17.0.52
+ '@types/react-dom': 18.2.14
+
'@radix-ui/react-toolbar@1.0.4(@types/react-dom@18.2.14)(@types/react@17.0.52)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@babel/runtime': 7.24.0