diff --git a/products/signals/frontend/inbox/components/config/AgentsRoster.tsx b/products/signals/frontend/inbox/components/config/AgentsRoster.tsx index 22e83bdb429c..7063ae4d74de 100644 --- a/products/signals/frontend/inbox/components/config/AgentsRoster.tsx +++ b/products/signals/frontend/inbox/components/config/AgentsRoster.tsx @@ -495,11 +495,30 @@ const AgentRow = memo(function AgentRow({ {agent.watches} - {tag && ( + {toolOff && tool?.enablement ? ( + // A live badge, not a dead one: clicking it turns the tool on, the same action the + // expansion offers one disclosure level below. + + : undefined} + onClick={(e) => { + e.stopPropagation() + if (!enablingTool) { + onEnableTool(tool) + } + }} + > + Turn it on + + + ) : tag ? ( {tag.label} - )} + ) : null} {entities.length > 0 && `${enabledCount} of ${entities.length} ${agent.entityNoun} on`} @@ -585,7 +604,7 @@ export function AgentsRoster(): JSX.Element { isHealthChecksToggling, isCiSignalsToggling, toolStatusBySource, - enablingTool, + enablingTools, } = useValues(signalSourcesLogic) const { toggleConversations, @@ -832,6 +851,7 @@ export function AgentsRoster(): JSX.Element { agent.steerable && state.sourceConfig && !state.sourceConfig.id.startsWith('new_') ? state.sourceConfig : null + const enablement = toolStatusBySource[agent.source]?.enablement return ( setExpandedSource((current) => (current === agent.source ? null : agent.source)) } diff --git a/products/signals/frontend/inbox/components/onboarding/InboxOnboarding.scss b/products/signals/frontend/inbox/components/onboarding/InboxOnboarding.scss index f90954514424..3c5a804d73c8 100644 --- a/products/signals/frontend/inbox/components/onboarding/InboxOnboarding.scss +++ b/products/signals/frontend/inbox/components/onboarding/InboxOnboarding.scss @@ -22,3 +22,28 @@ animation: none; } } + +// A click on an example card scrolls to the setup command and pulses this ring, so the click has a +// visible answer instead of a silent one. +.InboxOnboarding__commandPulse { + animation: InboxOnboarding__commandPulse 0.9s ease-out 2; +} + +@keyframes InboxOnboarding__commandPulse { + 0%, + 100% { + box-shadow: 0 0 0 0 transparent; + } + + 35% { + box-shadow: 0 0 0 4px var(--color-accent); + } +} + +@media (prefers-reduced-motion: reduce) { + // No pulse, but keep the ring as a static highlight so the click still has a visible answer. + .InboxOnboarding__commandPulse { + animation: none; + box-shadow: 0 0 0 4px var(--color-accent); + } +} diff --git a/products/signals/frontend/inbox/components/onboarding/InboxOnboarding.test.tsx b/products/signals/frontend/inbox/components/onboarding/InboxOnboarding.test.tsx new file mode 100644 index 000000000000..7b4986a548cf --- /dev/null +++ b/products/signals/frontend/inbox/components/onboarding/InboxOnboarding.test.tsx @@ -0,0 +1,35 @@ +import '@testing-library/jest-dom' + +import { cleanup, fireEvent, render } from '@testing-library/react' + +import { InboxOnboardingTakeover } from './InboxOnboarding' + +// The manual-setup escape hatch pulls in `inboxOnboardingLogic` (kea); it is unrelated to the card click. +jest.mock('./ManualSetupAction', () => ({ ManualSetupAction: () => null })) +jest.mock('../../inboxAnalytics', () => ({ + captureInboxWelcomeViewed: jest.fn(), + captureInboxWelcomeCommandCopied: jest.fn(), +})) +jest.mock('./meep', () => ({ playMeep: jest.fn() })) +jest.mock('lib/components/TZLabel', () => ({ TZLabel: ({ time }: { time: string }) => {time} })) + +describe('InboxOnboardingTakeover example cards', () => { + beforeAll(() => { + // jsdom does not implement scrollIntoView; stub it so the click handler can call it. + Element.prototype.scrollIntoView = jest.fn() + }) + afterEach(cleanup) + + it('answers a click on an example card by scrolling to the setup command and pulsing it', () => { + const { container, getAllByLabelText } = render() + // A dead click leaves the DOM unchanged; the pulse ring must be absent before the click. + expect(container.querySelector('.InboxOnboarding__commandPulse')).toBeNull() + + const overlays = getAllByLabelText(/Jump to the setup command/i) + expect(overlays.length).toBeGreaterThan(0) + fireEvent.click(overlays[0]) + + expect(Element.prototype.scrollIntoView).toHaveBeenCalled() + expect(container.querySelector('.InboxOnboarding__commandPulse')).not.toBeNull() + }) +}) diff --git a/products/signals/frontend/inbox/components/onboarding/InboxOnboarding.tsx b/products/signals/frontend/inbox/components/onboarding/InboxOnboarding.tsx index 1db47cc71b69..93e6cb8b131c 100644 --- a/products/signals/frontend/inbox/components/onboarding/InboxOnboarding.tsx +++ b/products/signals/frontend/inbox/components/onboarding/InboxOnboarding.tsx @@ -1,7 +1,7 @@ import './InboxOnboarding.scss' import { useActions } from 'kea' -import { useEffect } from 'react' +import { useCallback, useEffect, useRef } from 'react' import { IconBolt, IconGithub, IconInfo, IconNotebook, IconPause, IconX } from '@posthog/icons' import { LemonButton, Tooltip } from '@posthog/lemon-ui' @@ -32,7 +32,7 @@ interface Beat { label: string description: JSX.Element subtext: string | JSX.Element - preview: JSX.Element + Preview: (props: { onExampleClick: () => void }) => JSX.Element } /** @@ -74,7 +74,7 @@ const BEATS: Beat[] = [ Your first 3 PRs each month are free, then it's $15 per PR after that. ), - preview: , + Preview: PullRequestPreview, }, { label: 'Reports, when it needs your call.', @@ -85,7 +85,7 @@ const BEATS: Beat[] = [ ), subtext: 'Reports without PRs are free.', - preview: , + Preview: ReportPreview, }, ] @@ -97,25 +97,30 @@ const BEATS: Beat[] = [ function SelfDrivingCommand({ size = 'md', surface, + containerRef, }: { size?: 'sm' | 'md' surface: InboxWelcomeCopySurface + // The example cards scroll to this ref and pulse it, so a click on an example has a visible answer. + containerRef?: React.RefObject }): JSX.Element { return ( - - captureInboxWelcomeCommandCopied({ variant: surface === 'takeover' ? 'control' : null, surface }) - } - // rounded-md sits one step inside the rounded-lg card/banner it nests in. - className="!m-0 rounded-md border border-primary bg-surface-secondary hover:border-accent" - /> +
+ + captureInboxWelcomeCommandCopied({ variant: surface === 'takeover' ? 'control' : null, surface }) + } + // rounded-md sits one step inside the rounded-lg card/banner it nests in. + className="!m-0 rounded-md border border-primary bg-surface-secondary hover:border-accent" + /> +
) } @@ -137,7 +142,7 @@ function Hero(): JSX.Element { ) } -function CommandCard(): JSX.Element { +function CommandCard({ commandRef }: { commandRef?: React.RefObject }): JSX.Element { return (
@@ -146,7 +151,7 @@ function CommandCard(): JSX.Element { Run it in your project's repo, or set it up yourself below.

- +
    {WIZARD_SETS_UP.map((item) => (
  • @@ -162,7 +167,15 @@ function CommandCard(): JSX.Element { ) } -function BeatRow({ beat, index }: { beat: Beat; index: number }): JSX.Element { +function BeatRow({ + beat, + index, + onExampleClick, +}: { + beat: Beat + index: number + onExampleClick: () => void +}): JSX.Element { return (
    @@ -174,7 +187,9 @@ function BeatRow({ beat, index }: { beat: Beat; index: number }): JSX.Element {
    {/* Real inbox cards, marked as examples and kept inert by the preview wrapper. Full-width on mobile; indented to align under the beat text from sm up. */} -
    {beat.preview}
    +
    + +
    {beat.subtext ? ( {beat.subtext} ) : null} @@ -189,17 +204,33 @@ function BeatRow({ beat, index }: { beat: Beat; index: number }): JSX.Element { * report list – a plain centered column (not itself a card) that eases in with a subtle scale + fade. */ export function InboxOnboardingTakeover(): JSX.Element { + const commandRef = useRef(null) + useEffect(() => { captureInboxWelcomeViewed({ variant: 'control' }) }, []) + const highlightCommand = useCallback(() => { + const el = commandRef.current + if (!el) { + return + } + // Jump instead of animating the scroll when the user asked for reduced motion. + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches + el.scrollIntoView({ behavior: reduceMotion ? 'auto' : 'smooth', block: 'center' }) + // Restart the pulse on repeat clicks: drop the class, force a reflow, then add it back. + el.classList.remove('InboxOnboarding__commandPulse') + void el.offsetWidth + el.classList.add('InboxOnboarding__commandPulse') + }, []) + return (
    - +
    {BEATS.map((beat, index) => ( - + ))}
    diff --git a/products/signals/frontend/inbox/components/onboarding/InboxOnboardingPreviews.tsx b/products/signals/frontend/inbox/components/onboarding/InboxOnboardingPreviews.tsx index 337fd0a1b541..9b69c855ddc4 100644 --- a/products/signals/frontend/inbox/components/onboarding/InboxOnboardingPreviews.tsx +++ b/products/signals/frontend/inbox/components/onboarding/InboxOnboardingPreviews.tsx @@ -11,9 +11,9 @@ import { playMeep } from './meep' * requests / Reports tabs use), fed mock data – so they read as the genuine article rather than a * lookalike. Because they look real, they're marked plainly as examples: an "Example" tag sits on * each card, the card itself is made non-interactive (so its Review/Archive buttons don't offer - * live hover states or misleading tooltips), and a single click surface explains that the real work - * arrives once you run the setup command. The meep stays as flair, but it's no longer the only sign - * a click did anything. + * live hover states or misleading tooltips), and a single click surface sits on top. A click on that + * surface scrolls to the setup command and pulses it, so the click has a visible answer. The meep + * stays as flair on top of that. * * The sample work is a wink at Silicon Valley (the show): Pied Piper's middle-out compression and * the ever-looming Hooli. @@ -62,10 +62,19 @@ const REPORT_SAMPLE: Omit = { * Wraps a real `ReportCard` and makes it legibly a sample. The card is rendered non-interactive * (`pointer-events-none`, `aria-hidden`) so its Review/Archive buttons and links no longer offer * live hover states or misleading tooltips ("Archive this report") that invite dead clicks. An - * "Example" tag labels it at a glance, and a single click surface on top plays the meep flair while - * a tooltip explains that real work lands here once the setup command runs. + * "Example" tag labels it at a glance, and a single click surface on top scrolls to the setup + * command and pulses it (via `onExampleClick`), so a click gets a visible answer rather than only a + * sound. */ -function PreviewCard({ report, tabKey }: { report: SignalReport; tabKey: 'pulls' | 'reports' }): JSX.Element { +function PreviewCard({ + report, + tabKey, + onExampleClick, +}: { + report: SignalReport + tabKey: 'pulls' | 'reports' + onExampleClick: () => void +}): JSX.Element { return ( // `@container` so ReportCard's `@lg:` row layout resolves against the preview width (it has no // inbox-list container here). `role="presentation"` – the whole thing is decorative. @@ -83,26 +92,41 @@ function PreviewCard({ report, tabKey }: { report: SignalReport; tabKey: 'pulls' Example - {/* One interactive surface over the whole card: a click plays the meep flair, and the - tooltip is the real signal – it says this is a preview and how to get the real thing. */} - + {/* One interactive surface over the whole card. A click scrolls to the setup command and + pulses it, so the click has a visible answer, then plays the meep flair on top. */} +
    ) } -export function PullRequestPreview(): JSX.Element { +export function PullRequestPreview({ onExampleClick }: { onExampleClick: () => void }): JSX.Element { const landed = landedHoursAgo(2) - return + return ( + + ) } -export function ReportPreview(): JSX.Element { +export function ReportPreview({ onExampleClick }: { onExampleClick: () => void }): JSX.Element { const landed = landedHoursAgo(4) - return + return ( + + ) } diff --git a/products/signals/frontend/inbox/signalSourcesLogic.test.ts b/products/signals/frontend/inbox/signalSourcesLogic.test.ts index 2387ebfac7f5..14952124a96a 100644 --- a/products/signals/frontend/inbox/signalSourcesLogic.test.ts +++ b/products/signals/frontend/inbox/signalSourcesLogic.test.ts @@ -315,11 +315,11 @@ describe('signalSourcesLogic', () => { }) logic.actions.enableSourceTool('error_tracking') - expect(logic.values.enablingTool).toBe('error_tracking') + expect(logic.values.enablingTools.has('error_tracking')).toBe(true) enablementResponse.resolve([200, { results: { error_tracking: 'enabled' } }]) await teamRequestStarted.promise - expect(logic.values.enablingTool).toBe('error_tracking') + expect(logic.values.enablingTools.has('error_tracking')).toBe(true) teamResponse.resolve([ 200, @@ -330,7 +330,26 @@ describe('signalSourcesLogic', () => { ]) await expectLogic(logic).toFinishAllListeners() - expect(logic.values.enablingTool).toBeNull() + expect(logic.values.enablingTools.has('error_tracking')).toBe(false) expect(logic.values.toolStatusBySource.error_tracking?.enabled).toBe(true) }) + + it('tracks concurrent tool enables independently, so one finishing keeps the other loading', () => { + // Hang the enable request so the listener stays in flight; drive completion by hand to + // reproduce two badges enabled before either resolves. + const enableHangs = deferred<[number, { results: Record }]>() + useMocks({ + post: { + '/api/projects/:team_id/product_enablement/': () => enableHangs.promise, + }, + }) + + logic.actions.enableSourceTool('error_tracking') + logic.actions.enableSourceTool('conversations') + expect(logic.values.enablingTools).toEqual(new Set(['error_tracking', 'conversations'])) + + // The first request completing must clear only its own tool, not the other in-flight one. + logic.actions.enableSourceToolComplete('error_tracking') + expect(logic.values.enablingTools).toEqual(new Set(['conversations'])) + }) }) diff --git a/products/signals/frontend/inbox/signalSourcesLogic.ts b/products/signals/frontend/inbox/signalSourcesLogic.ts index 290378cca08b..74c808af49d3 100644 --- a/products/signals/frontend/inbox/signalSourcesLogic.ts +++ b/products/signals/frontend/inbox/signalSourcesLogic.ts @@ -211,7 +211,7 @@ export interface signalSourcesLogicValues { conversationsConfig: SignalSourceConfig | null dataSourceSetupSource: WarehouseBackedSource | null enabledSourcesCount: number - enablingTool: SourceToolEnablement | null + enablingTools: Set errorTrackingIsFullyEnabled: boolean errorTrackingTypeStates: { enabled: boolean @@ -265,8 +265,8 @@ export interface signalSourcesLogicActions { enableSourceTool: (enablement: SourceToolEnablement) => { enablement: SourceToolEnablement } - enableSourceToolComplete: () => { - value: true + enableSourceToolComplete: (enablement: SourceToolEnablement) => { + enablement: SourceToolEnablement } initiateDataWarehouseSourceToggle: (source: WarehouseBackedSource) => { source: WarehouseBackedSource @@ -517,7 +517,7 @@ export const signalSourcesLogic = kea([ toggleConversations: true, toggleAnomalyInvestigation: true, enableSourceTool: (enablement: SourceToolEnablement) => ({ enablement }), - enableSourceToolComplete: true, + enableSourceToolComplete: (enablement: SourceToolEnablement) => ({ enablement }), }), loaders(({ values }) => ({ @@ -626,11 +626,17 @@ export const signalSourcesLogic = kea([ closeSourcesModal: () => null, }, ], - enablingTool: [ - null as SourceToolEnablement | null, + // A set, not a single slot: several sources can have their tool off and be enabled at once, + // so each in-flight enablement must keep its own loading state. + enablingTools: [ + new Set(), { - enableSourceTool: (_, { enablement }) => enablement, - enableSourceToolComplete: () => null, + enableSourceTool: (state, { enablement }) => new Set(state).add(enablement), + enableSourceToolComplete: (state, { enablement }) => { + const next = new Set(state) + next.delete(enablement) + return next + }, }, ], toolDataEventsFailed: [ @@ -1315,7 +1321,7 @@ export const signalSourcesLogic = kea([ } catch (error: any) { lemonToast.error(error?.detail || error?.message || "Couldn't turn this on. Please try again.") } finally { - actions.enableSourceToolComplete() + actions.enableSourceToolComplete(enablement) } }, setDataWarehouseSourceEnabled: ({ source, enabled }) => {