@@ -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 }) => {