From 47e77f2cab204af91fe673e0c55b5c9bf177b268 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:13:25 +0000 Subject: [PATCH 1/4] fix(signals): make inbox example cards and tool-off badge answer clicks The self-driving onboarding example cards and the "Tool off" badge in the signal sources roster both took a click and changed nothing visible, so both read as dead clicks. Example cards now scroll to the setup command and pulse it on click, instead of only playing a sound. The "Tool off" badge is now a clickable warning tag that turns the tool on, the same action the row expansion offers one disclosure level below. Generated-By: PostHog Desktop Task-Id: d840838a-874a-4643-b2ef-0a9e06346966 --- .../inbox/components/config/AgentsRoster.tsx | 23 +++++- .../onboarding/InboxOnboarding.scss | 23 ++++++ .../onboarding/InboxOnboarding.test.tsx | 35 +++++++++ .../components/onboarding/InboxOnboarding.tsx | 77 +++++++++++++------ .../onboarding/InboxOnboardingPreviews.tsx | 54 +++++++++---- 5 files changed, 171 insertions(+), 41 deletions(-) create mode 100644 products/signals/frontend/inbox/components/onboarding/InboxOnboarding.test.tsx diff --git a/products/signals/frontend/inbox/components/config/AgentsRoster.tsx b/products/signals/frontend/inbox/components/config/AgentsRoster.tsx index 22e83bdb429c..e291a73f1c89 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`} diff --git a/products/signals/frontend/inbox/components/onboarding/InboxOnboarding.scss b/products/signals/frontend/inbox/components/onboarding/InboxOnboarding.scss index f90954514424..00bfdecf6dba 100644 --- a/products/signals/frontend/inbox/components/onboarding/InboxOnboarding.scss +++ b/products/signals/frontend/inbox/components/onboarding/InboxOnboarding.scss @@ -22,3 +22,26 @@ 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) { + .InboxOnboarding__commandPulse { + animation: none; + } +} 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..e9486c428446 --- /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, getAllByRole } = 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 = getAllByRole('button', { name: /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..873a3ad9aebc 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,31 @@ 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 + } + el.scrollIntoView({ behavior: '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 ( + + ) } From 4724afb7c71bf65de177ed01a95c944595ddba72 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:29:45 +0000 Subject: [PATCH 2/4] test(signals): query example overlay by label, not role+name The `jest-no-byrole-name-queries` semgrep rule blocks role+name queries because they are slow in jsdom. The overlay button carries an aria-label, so `getAllByLabelText` is the preferred query. Generated-By: PostHog Desktop Task-Id: d840838a-874a-4643-b2ef-0a9e06346966 --- .../inbox/components/onboarding/InboxOnboarding.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/products/signals/frontend/inbox/components/onboarding/InboxOnboarding.test.tsx b/products/signals/frontend/inbox/components/onboarding/InboxOnboarding.test.tsx index e9486c428446..7b4986a548cf 100644 --- a/products/signals/frontend/inbox/components/onboarding/InboxOnboarding.test.tsx +++ b/products/signals/frontend/inbox/components/onboarding/InboxOnboarding.test.tsx @@ -21,11 +21,11 @@ describe('InboxOnboardingTakeover example cards', () => { afterEach(cleanup) it('answers a click on an example card by scrolling to the setup command and pulsing it', () => { - const { container, getAllByRole } = render() + 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 = getAllByRole('button', { name: /Jump to the setup command/i }) + const overlays = getAllByLabelText(/Jump to the setup command/i) expect(overlays.length).toBeGreaterThan(0) fireEvent.click(overlays[0]) From 437744af73dde7888e2769978027cddf4ca7ac09 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:36:53 +0000 Subject: [PATCH 3/4] fix(signals): keep a static command ring under reduced motion The example-card click relied on a pulse animation and a smooth scroll to answer the click. Under prefers-reduced-motion the pulse was disabled with no substitute, and the scroll can be a no-op when the command is already in view, so the click had no visible answer for those users. Keep the accent ring as a static box-shadow in the reduced-motion block, and jump the scroll instead of animating it when reduced motion is set. Generated-By: PostHog Desktop Task-Id: 9dce5554-e7e0-477d-b2ca-8db9bde7771b --- .../frontend/inbox/components/onboarding/InboxOnboarding.scss | 2 ++ .../frontend/inbox/components/onboarding/InboxOnboarding.tsx | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/products/signals/frontend/inbox/components/onboarding/InboxOnboarding.scss b/products/signals/frontend/inbox/components/onboarding/InboxOnboarding.scss index 00bfdecf6dba..3c5a804d73c8 100644 --- a/products/signals/frontend/inbox/components/onboarding/InboxOnboarding.scss +++ b/products/signals/frontend/inbox/components/onboarding/InboxOnboarding.scss @@ -41,7 +41,9 @@ } @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.tsx b/products/signals/frontend/inbox/components/onboarding/InboxOnboarding.tsx index 873a3ad9aebc..93e6cb8b131c 100644 --- a/products/signals/frontend/inbox/components/onboarding/InboxOnboarding.tsx +++ b/products/signals/frontend/inbox/components/onboarding/InboxOnboarding.tsx @@ -215,7 +215,9 @@ export function InboxOnboardingTakeover(): JSX.Element { if (!el) { return } - el.scrollIntoView({ behavior: 'smooth', block: 'center' }) + // 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 From 8dbb8ad05a031c2d6a313c0baad76a84168a4774 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:51:02 +0000 Subject: [PATCH 4/4] fix(signals): track concurrent source-tool enables independently The roster now shows a clickable "Turn it on" badge on every collapsed row whose tool is off, so several enable controls can be on screen at once. The in-flight state was a single slot, so a second click overwrote the first: the first badge lost its spinner and reopened its guard while its request was still running, which then accepted a duplicate enable request. Hold the in-flight enablements in a set keyed by enablement, add one on start and remove it on completion, and derive each row's loading state from set membership. Each badge now keeps its own spinner and stays guarded against double-submission until its own request finishes. Adds a logic test for the concurrent case and updates the existing enablement test to the new set-shaped state. Generated-By: PostHog Desktop Task-Id: 9dce5554-e7e0-477d-b2ca-8db9bde7771b --- .../inbox/components/config/AgentsRoster.tsx | 7 +++--- .../frontend/inbox/signalSourcesLogic.test.ts | 25 ++++++++++++++++--- .../frontend/inbox/signalSourcesLogic.ts | 24 +++++++++++------- 3 files changed, 40 insertions(+), 16 deletions(-) diff --git a/products/signals/frontend/inbox/components/config/AgentsRoster.tsx b/products/signals/frontend/inbox/components/config/AgentsRoster.tsx index e291a73f1c89..7063ae4d74de 100644 --- a/products/signals/frontend/inbox/components/config/AgentsRoster.tsx +++ b/products/signals/frontend/inbox/components/config/AgentsRoster.tsx @@ -604,7 +604,7 @@ export function AgentsRoster(): JSX.Element { isHealthChecksToggling, isCiSignalsToggling, toolStatusBySource, - enablingTool, + enablingTools, } = useValues(signalSourcesLogic) const { toggleConversations, @@ -851,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/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 }) => {