diff --git a/.github/workflows/react-doctor.yml b/.github/workflows/react-doctor.yml index 03583d4b73..c1dab2c468 100644 --- a/.github/workflows/react-doctor.yml +++ b/.github/workflows/react-doctor.yml @@ -43,6 +43,8 @@ jobs: NO_COLOR: "1" REACT_DOCTOR_BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} + # Bypass `min-release-age` from .npmrc for this CI-only pinned utility. + NPM_CONFIG_MIN_RELEASE_AGE: "0" run: | # -e omitted: exit codes are captured and inspected explicitly. set -uo pipefail @@ -59,7 +61,7 @@ jobs: fi REPORT="${RUNNER_TEMP}/react-doctor-report.json" status=0 - npx --yes react-doctor@0.4.2 . --blocking error --changed-files-from "$CHANGED" --json --json-compact --no-telemetry > "$REPORT" || status=$? + npx --yes react-doctor@0.5.1 . --blocking error --changed-files-from "$CHANGED" --json --json-compact --no-telemetry > "$REPORT" || status=$? echo "exit-code=$status" >> "$GITHUB_OUTPUT" echo "report=$REPORT" >> "$GITHUB_OUTPUT" if [ "$status" -ne 0 ]; then diff --git a/packages/ui/src/assets/services/posthog-slack-app.png b/packages/ui/src/assets/services/posthog-slack-app.png new file mode 100644 index 0000000000..3adbbc5d28 Binary files /dev/null and b/packages/ui/src/assets/services/posthog-slack-app.png differ diff --git a/packages/ui/src/features/agents/components/AgentsView.tsx b/packages/ui/src/features/agents/components/AgentsView.tsx index 83deedf8c9..a64908fd72 100644 --- a/packages/ui/src/features/agents/components/AgentsView.tsx +++ b/packages/ui/src/features/agents/components/AgentsView.tsx @@ -1,5 +1,6 @@ import { RobotIcon } from "@phosphor-icons/react"; import { ConfigureAgentsSection } from "@posthog/ui/features/inbox/components/ConfigureAgentsSection"; +import { InboxOnboardingCallout } from "@posthog/ui/features/inbox/components/onboarding/InboxOnboardingCallout"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; import { Flex, Text } from "@radix-ui/themes"; import { useMemo } from "react"; @@ -27,7 +28,7 @@ export function AgentsView() { Agents @@ -38,6 +39,7 @@ export function AgentsView() { +
diff --git a/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx b/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx index 4f1f12f403..5378916021 100644 --- a/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx +++ b/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx @@ -91,7 +91,7 @@ export function ConfigureAgentsSection() { userAutonomyConfig?.autostart_priority ?? NEVER_AUTOSTART_VALUE; return ( - + {showSetupTask ? : null} diff --git a/packages/ui/src/features/inbox/components/InboxView.tsx b/packages/ui/src/features/inbox/components/InboxView.tsx index 37a6b7af31..5c58c0c9b6 100644 --- a/packages/ui/src/features/inbox/components/InboxView.tsx +++ b/packages/ui/src/features/inbox/components/InboxView.tsx @@ -1,11 +1,19 @@ import { EnvelopeSimpleIcon } from "@phosphor-icons/react"; import { isInboxDetailPath } from "@posthog/core/inbox/reportMembership"; import { InboxPageHeader } from "@posthog/ui/features/inbox/components/InboxPageHeader"; +import { + InboxOnboardingHeader, + InboxOnboardingPane, +} from "@posthog/ui/features/inbox/components/onboarding/InboxOnboardingPane"; +import { + useInboxOnboardingSessionStore, + useInboxOnboardingState, +} from "@posthog/ui/features/inbox/components/onboarding/useInboxOnboardingState"; import { useInboxAllReports } from "@posthog/ui/features/inbox/hooks/useInboxAllReports"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; import { Flex, Text } from "@radix-ui/themes"; import { Outlet, useRouterState } from "@tanstack/react-router"; -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; /** * Inbox shell. Owns the in-page header (title + RFC subtitle + tab bar) and @@ -33,12 +41,35 @@ export function InboxView() { const { counts } = useInboxAllReports(); const pathname = useRouterState({ select: (s) => s.location.pathname }); const isDetailView = isInboxDetailPath(pathname); + const onboarding = useInboxOnboardingState(); + const active = useInboxOnboardingSessionStore((s) => s.active); + const finished = useInboxOnboardingSessionStore((s) => s.finished); + const setActive = useInboxOnboardingSessionStore((s) => s.setActive); + + // Latch onboarding visibility once per session from `isComplete`. After that + // the user only leaves by finishing the Activate step, so completing the last + // step doesn't unmount the pane mid-flow. + useEffect(() => { + if (!onboarding.isLoading && active === null) { + setActive(!onboarding.isComplete); + } + }, [onboarding.isLoading, onboarding.isComplete, active, setActive]); + + const showOnboarding = + !isDetailView && + !onboarding.isLoading && + !finished && + (active === true || !onboarding.isComplete); return ( - {!isDetailView && } + {showOnboarding ? ( + + ) : ( + !isDetailView && + )}
- + {showOnboarding ? : }
); diff --git a/packages/ui/src/features/inbox/components/PullRequestCard.tsx b/packages/ui/src/features/inbox/components/PullRequestCard.tsx index e0efc16b9e..f1c8e1b90a 100644 --- a/packages/ui/src/features/inbox/components/PullRequestCard.tsx +++ b/packages/ui/src/features/inbox/components/PullRequestCard.tsx @@ -1,4 +1,3 @@ -import { ThumbsDownIcon } from "@phosphor-icons/react"; import { extractRepoSelectionRepository } from "@posthog/core/inbox/artefacts"; import { deriveHeadline, @@ -6,18 +5,12 @@ import { parseConventionalCommitTitle, parsePrUrl, } from "@posthog/core/inbox/reportPresentation"; -import { Button, cn } from "@posthog/quill"; import type { SignalReport } from "@posthog/shared/types"; -import { ConventionalCommitScopeTag } from "@posthog/ui/features/inbox/components/ConventionalCommitScopeTag"; -import { InboxCardSourceMeta } from "@posthog/ui/features/inbox/components/InboxCardSourceMeta"; -import { InboxCardTitle } from "@posthog/ui/features/inbox/components/InboxCardTitle"; import { PrDiffStats } from "@posthog/ui/features/inbox/components/PrDiffStats"; -import { PriorityMonogram } from "@posthog/ui/features/inbox/components/PriorityMonogram"; +import { PullRequestCardView } from "@posthog/ui/features/inbox/components/PullRequestCardView"; import { SuggestedReviewerAvatarStack } from "@posthog/ui/features/inbox/components/SuggestedReviewerAvatarStack"; import { useInboxReportDetailPrefetch } from "@posthog/ui/features/inbox/hooks/useInboxReportDetailPrefetch"; import { useInboxReportArtefacts } from "@posthog/ui/features/inbox/hooks/useInboxReports"; -import { Button as UiButton } from "@posthog/ui/primitives/Button"; -import { Flex, Text } from "@radix-ui/themes"; import { Link, useNavigate } from "@tanstack/react-router"; import type { MouseEvent } from "react"; @@ -64,105 +57,50 @@ export function PullRequestCard({ ); return ( -
+ ) : null + } + reviewersSlot={ + + } + onReview={() => { + prefetch(); + navigate(detailRoute); + }} + onDismiss={onDismiss} + dismissDisabledReason={dismissDisabledReason} + isDismissPending={isDismissPending} + renderSummary={(summary, className) => ( + { + onRowClick?.(event); + if (event.metaKey || event.ctrlKey || event.shiftKey) { + event.preventDefault(); + return; + } + prefetch(); + }} + className={className} + > + {summary} + )} - {...pointerHandlers} - > - { - onRowClick?.(event); - if (event.metaKey || event.ctrlKey || event.shiftKey) { - event.preventDefault(); - return; - } - prefetch(); - }} - className="flex min-w-0 flex-1 items-start gap-3 text-left text-inherit no-underline focus-visible:outline-none" - > - - - - - {conventionalTitle && ( - - )} - {cardTitle} - - - {(() => { - const headline = deriveHeadline(report.summary); - return headline ? ( - - {headline} - - ) : null; - })()} - - - - - - - - {report.implementation_pr_url && ( - - )} - - - - { - event.stopPropagation(); - onDismiss(); - }} - > - - - - - -
+ /> ); } diff --git a/packages/ui/src/features/inbox/components/PullRequestCardView.tsx b/packages/ui/src/features/inbox/components/PullRequestCardView.tsx new file mode 100644 index 0000000000..81432e9d6e --- /dev/null +++ b/packages/ui/src/features/inbox/components/PullRequestCardView.tsx @@ -0,0 +1,152 @@ +import { ThumbsDownIcon } from "@phosphor-icons/react"; +import { Button, cn } from "@posthog/quill"; +import type { SignalReportPriority } from "@posthog/shared/types"; +import { ConventionalCommitScopeTag } from "@posthog/ui/features/inbox/components/ConventionalCommitScopeTag"; +import { InboxCardSourceMeta } from "@posthog/ui/features/inbox/components/InboxCardSourceMeta"; +import { InboxCardTitle } from "@posthog/ui/features/inbox/components/InboxCardTitle"; +import { PriorityMonogram } from "@posthog/ui/features/inbox/components/PriorityMonogram"; +import { Button as UiButton } from "@posthog/ui/primitives/Button"; +import { Flex, Text } from "@radix-ui/themes"; +import type { MouseEvent, ReactNode } from "react"; + +/** Layout class for the clickable left region; shared so the real card's `` matches. */ +export const PULL_REQUEST_CARD_ROW_CLASS = + "flex min-w-0 flex-1 items-start gap-3 text-left text-inherit no-underline focus-visible:outline-none"; + +interface PullRequestCardViewProps { + priority: SignalReportPriority | null | undefined; + conventionalTitle: { type: string; scope: string | null } | null; + title: string; + headline?: string | null; + repoSlug?: string | null; + sourceProducts?: string[] | null; + isSelected?: boolean; + /** Diff adornment (`` in the real card, a static `` in previews). */ + diffSlot?: ReactNode; + /** Suggested-reviewer avatars; omitted in previews. */ + reviewersSlot?: ReactNode; + reviewLabel?: string; + onReview?: (event: MouseEvent) => void; + /** Omit to hide the dismiss affordance entirely (e.g. previews). */ + onDismiss?: () => void; + dismissDisabledReason?: string | null; + isDismissPending?: boolean; + pointerHandlers?: { onPointerDown?: () => void }; + /** + * Wraps the summary region. The real card passes a TanStack `` so the row + * navigates and preloads; previews leave it undefined to render a static `
`. + */ + renderSummary?: (summary: ReactNode, className: string) => ReactNode; +} + +/** + * Pure, presentational pull-request card. Holds no router or query dependencies so it + * can render identically in the live inbox list and in mocked onboarding previews. + * `PullRequestCard` is the data-resolving wrapper around this view. + */ +export function PullRequestCardView({ + priority, + conventionalTitle, + title, + headline = null, + repoSlug = null, + sourceProducts = null, + isSelected = false, + diffSlot, + reviewersSlot, + reviewLabel = "Review", + onReview, + onDismiss, + dismissDisabledReason = null, + isDismissPending = false, + pointerHandlers, + renderSummary, +}: PullRequestCardViewProps) { + const summary = ( + <> + + + + {conventionalTitle && ( + + )} + {title} + + + {headline ? ( + + {headline} + + ) : null} + + + + + ); + + return ( +
+ {renderSummary ? ( + renderSummary(summary, PULL_REQUEST_CARD_ROW_CLASS) + ) : ( +
{summary}
+ )} + + + + {diffSlot} + {reviewersSlot} + + + {onDismiss && ( + { + event.stopPropagation(); + onDismiss(); + }} + > + + + )} + + + +
+ ); +} diff --git a/packages/ui/src/features/inbox/components/onboarding/FakeSlack.tsx b/packages/ui/src/features/inbox/components/onboarding/FakeSlack.tsx new file mode 100644 index 0000000000..35f0cb1866 --- /dev/null +++ b/packages/ui/src/features/inbox/components/onboarding/FakeSlack.tsx @@ -0,0 +1,233 @@ +import { cn } from "@posthog/quill"; +import slackAppLogo from "@posthog/ui/assets/services/posthog-slack-app.png"; +import { playCompletionSound } from "@posthog/ui/utils/sounds"; +import { Flex, Text } from "@radix-ui/themes"; +import type { ReactNode } from "react"; +import { useState } from "react"; + +/** + * High-fidelity, non-interactive Slack stand-ins used in the inbox onboarding + * welcome scene. These mimic Slack's chrome — channel header, message gutter, + * square avatars, bold name + timestamp, mention pills, and Block Kit-style + * message bodies — closely enough that the demo reads as the real thing without + * pulling in any live Slack data. Theme tokens are used so it sits naturally in + * the app's light or dark surface rather than a hard white Slack panel. + */ + +function SlackSurface({ + channel, + children, +}: { + channel: string; + children: ReactNode; +}) { + return ( +
+
+ + # + + + {channel} + +
+
{children}
+
+ ); +} + +function SlackAvatar({ variant }: { variant: "richard" | "posthog" }) { + if (variant === "posthog") { + return ( + + ); + } + return ( + + R + + ); +} + +function SlackMessageRow({ + author, + avatar, + badge, + timestamp, + children, +}: { + author: string; + avatar: "richard" | "posthog"; + badge?: string; + timestamp: string; + children: ReactNode; +}) { + return ( + + + + + + {author} + + {badge && ( + + {badge} + + )} + + {timestamp} + + +
+ {children} +
+
+
+ ); +} + +export function SlackMention({ name }: { name: string }) { + return ( + + @{name} + + ); +} + +function SlackButton({ + children, + primary = false, + onClick, +}: { + children: ReactNode; + primary?: boolean; + onClick?: () => void; +}) { + return ( + + ); +} + +/** + * Beat 2 preview: the Block Kit report notification PostHog posts to the + * dedicated `#posthog-inbox` channel. Mirrors the real backend block layout in + * `slack_inbox_notifications.py`: header → meta line → summary → context line → + * action buttons. + */ +export function SlackReportNotificationPreview() { + return ( + + + + {/* header block */} + + Resume playback from the saved position, not the start + + {/* section block */} + + + ❗ P1 · Session replay · PostHog/hogflix + + + 8.4% of “Continue watching” resumes restart the title from 0:00 — + affected sessions run 14% shorter and resume churn is up 3×. + + + {/* context block */} + + 3 signals  ·  👤 Suggested reviewers:{" "} + + + {/* actions block */} + + playCompletionSound("meep")}> + Review PR + + playCompletionSound("meep-smol")}> + Open in PostHog Code + + + + + + ); +} + +// The answer keeps the first sentence and the value-punch question, collapsing +// the analysis in between behind an inline "[…]" toggle — enough to tease the +// depth without dumping a wall of text into the preview. +const ANSWER_FIRST = + "found it — dashboard p75 load time jumped from 0.8s to 3.2s last Tuesday, right when we shipped PostHog/hogflix#4821 (“cache homepage rows per-user”)."; + +const ANSWER_MIDDLE = + "Session replays show the rows skeleton hanging 3–4s on first paint for ~22% of views, and Error tracking has a matching spike in HomeRowsTimeout. The cause is the new per-user cache key — it dropped the shared-row hit rate from 91% to 12%, so nearly every visit recomputes the homepage. I traced it to getHomeRowsCacheKey() in src/server/cache.ts, where #4821 appends the viewer's user id to every key. The fix keeps the shared key and only scopes the user id to the “Because you watched” row; tested against the last week, p75 drops back to ~0.85s."; + +const ANSWER_LAST = "Do you want me to ship this fix as a pull request?"; + +/** + * Beat 3 preview: a teammate asks PostHog a one-off in a normal channel, and + * PostHog answers — grounded in analytics, error tracking, replay, and the + * codebase — then offers to ship the fix. The analysis collapses behind an + * inline "[…]" toggle, ending on the value punch: a PR on request. + */ +export function SlackAskPostHogPreview() { + const [expanded, setExpanded] = useState(false); + + return ( + + + can you look into the dashboard latency + complaints? Something regressed in the last week. + + + {ANSWER_FIRST}{" "} + {" "} + {expanded && <>{ANSWER_MIDDLE} } + {ANSWER_LAST} + + + ); +} diff --git a/packages/ui/src/features/inbox/components/onboarding/InboxOnboardingCallout.tsx b/packages/ui/src/features/inbox/components/onboarding/InboxOnboardingCallout.tsx new file mode 100644 index 0000000000..c114e55f99 --- /dev/null +++ b/packages/ui/src/features/inbox/components/onboarding/InboxOnboardingCallout.tsx @@ -0,0 +1,49 @@ +import { ArrowRightIcon } from "@phosphor-icons/react"; +import { + inboxOnboardingProgress, + useInboxOnboardingState, +} from "@posthog/ui/features/inbox/components/onboarding/useInboxOnboardingState"; +import { Flex, Text } from "@radix-ui/themes"; +import { Link } from "@tanstack/react-router"; + +/** + * Slim sticky strip shown above the Agents view's config when the inbox + * is still being onboarded. Points back to the Inbox takeover. + */ +export function InboxOnboardingCallout() { + const state = useInboxOnboardingState(); + if (state.isLoading || state.isComplete) return null; + const progress = inboxOnboardingProgress(state); + + return ( + + + + Finish setting up your inbox + + + {progress.doneCount} of {progress.totalCount} done + + + + Continue + + + + + ); +} diff --git a/packages/ui/src/features/inbox/components/onboarding/InboxOnboardingPane.tsx b/packages/ui/src/features/inbox/components/onboarding/InboxOnboardingPane.tsx new file mode 100644 index 0000000000..11957445f6 --- /dev/null +++ b/packages/ui/src/features/inbox/components/onboarding/InboxOnboardingPane.tsx @@ -0,0 +1,414 @@ +import { + ArrowLeftIcon, + ArrowRightIcon, + CheckIcon, + SlackLogoIcon, + WarningIcon, +} from "@phosphor-icons/react"; +import { Button, cn } from "@posthog/quill"; +import { formatRelativeTimeLong } from "@posthog/shared"; +import { InboxWelcomeContent } from "@posthog/ui/features/inbox/components/onboarding/InboxOnboardingWelcome"; +import { + type InboxOnboardingStep, + type InboxOnboardingStepInfo, + useInboxOnboardingSessionStore, + useInboxOnboardingState, +} from "@posthog/ui/features/inbox/components/onboarding/useInboxOnboardingState"; +import { + type Integration, + useIntegrationSelectors, +} from "@posthog/ui/features/integrations/store"; +import { useIntegrations } from "@posthog/ui/features/integrations/useIntegrations"; +import { useSlackConnect } from "@posthog/ui/features/integrations/useSlackConnect"; +import { GitHubIntegrationSection } from "@posthog/ui/features/settings/sections/GitHubIntegrationSection"; +import { SignalDefaultChannelSettings } from "@posthog/ui/features/settings/sections/SignalDefaultChannelSettings"; +import { SignalSourcesSettings } from "@posthog/ui/features/settings/sections/SignalSourcesSettings"; +import { Flex, Spinner, Text } from "@radix-ui/themes"; + +const STEP_LABEL: Record = { + welcome: "Welcome", + github: "GitHub", + slack: "Slack", + activate: "Agents", +}; + +const STEP_META: Record< + Exclude, + { title: string; subtitle: string } +> = { + github: { + title: "Connect GitHub", + subtitle: + "Point your agents at the code they'll open pull requests against. Connect your org and pick the repo to target by default.", + }, + slack: { + title: "Connect Slack", + subtitle: + "Slack is where your agents deliver reports and take requests. Connect your workspace so everything lands where your team already works.", + }, + activate: { + title: "Set up agents", + subtitle: + "Choose what your agents watch and where reports land. Flip these on and self-driving starts working.", + }, +}; + +/** + * Full-screen onboarding takeover shown in place of the inbox tabs until setup + * is done. A linear-but-navigable stepper: Welcome → GitHub → Slack → Activate. + * The cursor lives in the session store so the user can move backward as well + * as forward; Continue is gated on the current step being satisfied. + */ +export function InboxOnboardingPane() { + const state = useInboxOnboardingState(); + const goNext = useInboxOnboardingSessionStore((s) => s.goNext); + const goBack = useInboxOnboardingSessionStore((s) => s.goBack); + const goToStep = useInboxOnboardingSessionStore((s) => s.goToStep); + const skipSlack = useInboxOnboardingSessionStore((s) => s.skipSlack); + const finish = useInboxOnboardingSessionStore((s) => s.finish); + const { slackIntegrations, hasSlackIntegration } = useIntegrationSelectors(); + const slackIntegrationId = slackIntegrations[0]?.id ?? null; + + if (state.isLoading) return null; + + const { currentStep, currentIndex, currentStepDone, isLastStep, steps } = + state; + const doneByStep = Object.fromEntries( + steps.map((s) => [s.step, s.done]), + ) as Record; + const isWelcome = currentStep === "welcome"; + const showSkipSlack = currentStep === "slack" && !hasSlackIntegration; + + const handleSkipSlack = () => { + skipSlack(); + goNext(); + }; + const handleContinue = () => { + if (isLastStep) finish(); + else goNext(); + }; + + return ( +
+ + + + {isWelcome ? ( + + ) : ( + + + + {STEP_META[currentStep].title} + + + {STEP_META[currentStep].subtitle} + + + +
+ {currentStep === "github" && ( + + )} + {currentStep === "slack" && } + {currentStep === "activate" && ( + + )} +
+
+ )} + + +
+ {currentIndex > 0 && ( + + )} +
+ + {showSkipSlack && ( + + )} + + +
+
+
+ ); +} + +function Stepper({ + steps, + currentIndex, + currentStepDone, + onSelect, +}: { + steps: InboxOnboardingStepInfo[]; + currentIndex: number; + currentStepDone: boolean; + onSelect: (index: number) => void; +}) { + return ( + + {steps.map((info, idx) => { + const isCurrent = idx === currentIndex; + // Back to anything already visited; forward only one step, once the + // current step is satisfied. + const reachable = + idx <= currentIndex || (idx === currentIndex + 1 && currentStepDone); + return ( + + {idx > 0 && ( + + )} + + + ); + })} + + ); +} + +function StepBadge({ + index, + isCurrent, + isDone, +}: { + index: number; + isCurrent: boolean; + isDone: boolean; +}) { + const base = + "flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[11px] font-semibold"; + if (isCurrent) { + return ( + + {index} + + ); + } + if (isDone) { + return ( + + + + ); + } + return ( + + {index} + + ); +} + +/** + * Activate step: pick the signal sources the agents watch and, when Slack is + * connected, the default channel reports post to. Toggling these is what makes + * the step "done" and lights up the Activate button. + */ +function ActivateStepBody({ + slackIntegrationId, + slackChannelApplicable, +}: { + slackIntegrationId: number | null; + slackChannelApplicable: boolean; +}) { + return ( + + + {slackChannelApplicable && ( +
+ +
+ )} +
+ ); +} + +/** + * Onboarding-shaped Slack widget: just the connect handshake and the connected + * state. The "I don't use Slack" escape lives in the pane footer, and the + * notification channel choice belongs to the Activate step. + */ +function SlackStepBody() { + const { isLoading } = useIntegrations(); + const { slackIntegrations, hasSlackIntegration } = useIntegrationSelectors(); + const { connect, isConnecting, isTimedOut, hasError, error } = + useSlackConnect(); + + if (isLoading) { + return ( + + + Loading… + + ); + } + + if (hasSlackIntegration) { + return ; + } + + return ( + + + {isTimedOut && ( + + + Didn't hear back from Slack. Try again. + + )} + {hasError && error && ( + + + {error.message} + + )} + + ); +} + +function SlackConnectedRow({ integration }: { integration: Integration }) { + const rawDisplayName = integration.display_name; + const workspaceName = + (typeof rawDisplayName === "string" && rawDisplayName.trim()) || + "Slack workspace"; + const createdAt = + typeof integration.created_at === "string" ? integration.created_at : null; + + return ( + + + + + + + Connected to {workspaceName} + + {createdAt && ( + + Linked {formatRelativeTimeLong(createdAt)} + + )} + + + ); +} + +/** + * Header rendered above the takeover pane so the Inbox view chrome still + * reads as "this is the inbox" even while it's gated. Matches the regular + * `InboxPageHeader` / Agents header shape so the surface stays unified. + */ +export function InboxOnboardingHeader() { + return ( + + + Inbox + + + A few connections, then your agents start shipping pull requests, + reports, and live runs here. + + + ); +} diff --git a/packages/ui/src/features/inbox/components/onboarding/InboxOnboardingWelcome.tsx b/packages/ui/src/features/inbox/components/onboarding/InboxOnboardingWelcome.tsx new file mode 100644 index 0000000000..8cb81bd4da --- /dev/null +++ b/packages/ui/src/features/inbox/components/onboarding/InboxOnboardingWelcome.tsx @@ -0,0 +1,127 @@ +import { + SlackAskPostHogPreview, + SlackMention, + SlackReportNotificationPreview, +} from "@posthog/ui/features/inbox/components/onboarding/FakeSlack"; +import { PrDiffIndicator } from "@posthog/ui/features/inbox/components/PrDiffIndicator"; +import { PullRequestCardView } from "@posthog/ui/features/inbox/components/PullRequestCardView"; +import { Flex, Text } from "@radix-ui/themes"; +import { motion } from "framer-motion"; + +/** + * The welcome scene — now the first onboarding step. Sells self-driving as the + * product (agents ship pull requests, deliver them to Slack, respond when + * asked). The surrounding pane owns the stepper chrome and Back/Continue, so + * this renders content only. + */ +export function InboxWelcomeContent() { + return ( + + + + + } + delay={0.05} + /> + } + delay={0.1} + /> + } + delay={0.15} + /> + + + ); +} + +function Hero() { + return ( + + + + Welcome to self-driving for your product + + + PostHog Scouts & Responders monitor your users' experience and your + systems for issues. They hand you the fix as a pull request, dropped + into Slack so you never context-switch. You can ask{" "} + any time to do work for you - like a + teammate. + + + + ); +} + +function Beat({ + index, + label, + description, + preview, + delay, +}: { + index: number; + label: string; + description: string; + preview: React.ReactNode; + delay: number; +}) { + return ( + + + + + 0{index} + + + + {label} + + + {description} + + + +
{preview}
+
+
+ ); +} + +/** + * Beat 1 preview: the exact production `PullRequestCardView`, fed mocked data + * and no handlers, so it reads pixel-identical to the live pull requests list. + */ +function PullRequestCardPreview() { + return ( + } + /> + ); +} diff --git a/packages/ui/src/features/inbox/components/onboarding/useInboxOnboardingState.ts b/packages/ui/src/features/inbox/components/onboarding/useInboxOnboardingState.ts new file mode 100644 index 0000000000..9200c7ffdb --- /dev/null +++ b/packages/ui/src/features/inbox/components/onboarding/useInboxOnboardingState.ts @@ -0,0 +1,148 @@ +import { useSignalSourceToggles } from "@posthog/ui/features/inbox/hooks/useSignalSourceToggles"; +import { useSignalTeamConfig } from "@posthog/ui/features/inbox/hooks/useSignalTeamConfig"; +import { useIntegrationSelectors } from "@posthog/ui/features/integrations/store"; +import { useRepositoryIntegration } from "@posthog/ui/features/integrations/useIntegrations"; +import { create } from "zustand"; + +export type InboxOnboardingStep = "welcome" | "github" | "slack" | "activate"; + +export const STEP_ORDER: InboxOnboardingStep[] = [ + "welcome", + "github", + "slack", + "activate", +]; + +function clampIndex(index: number): number { + return Math.max(0, Math.min(STEP_ORDER.length - 1, index)); +} + +interface OnboardingSessionStore { + /** + * Cursor into `STEP_ORDER`. Unlike the old derived-step model, the step is + * now explicit so the user can move backward as well as forward. Session + * scoped: a fresh session starts at the welcome step. + */ + stepIndex: number; + /** + * Slack skip is session-scoped: if the user finishes onboarding without + * Slack we just never re-show the takeover, which naturally means no nag. + * If they abandon mid-flow the skip evaporates on next open. + */ + slackSkipped: boolean; + /** + * Latches whether the takeover is showing this session. Decided once (from + * `isComplete`) when onboarding first loads, then held so completing the + * final step doesn't yank the pane out from under the user mid-flow — they + * leave by clicking "Activate agents" (`finish`). + */ + active: boolean | null; + /** Set once the user explicitly finishes on the Activate step. */ + finished: boolean; + goToStep: (index: number) => void; + goNext: () => void; + goBack: () => void; + skipSlack: () => void; + setActive: (active: boolean) => void; + finish: () => void; + reset: () => void; +} + +export const useInboxOnboardingSessionStore = create( + (set) => ({ + stepIndex: 0, + slackSkipped: false, + active: null, + finished: false, + goToStep: (index) => set({ stepIndex: clampIndex(index) }), + goNext: () => set((s) => ({ stepIndex: clampIndex(s.stepIndex + 1) })), + goBack: () => set((s) => ({ stepIndex: clampIndex(s.stepIndex - 1) })), + skipSlack: () => set({ slackSkipped: true }), + setActive: (active) => set({ active }), + finish: () => set({ finished: true }), + reset: () => + set({ + stepIndex: 0, + slackSkipped: false, + active: null, + finished: false, + }), + }), +); + +export interface InboxOnboardingStepInfo { + step: InboxOnboardingStep; + done: boolean; +} + +export interface InboxOnboardingState { + /** All steps in order, each with its completion flag. Always length 4. */ + steps: InboxOnboardingStepInfo[]; + currentStep: InboxOnboardingStep; + currentIndex: number; + /** Whether the current step's requirement is satisfied (gates Continue). */ + currentStepDone: boolean; + isLastStep: boolean; + /** Slack is connected and not skipped, so a default channel can be chosen. */ + slackChannelApplicable: boolean; + isComplete: boolean; + isLoading: boolean; +} + +export function useInboxOnboardingState(): InboxOnboardingState { + const { hasSlackIntegration } = useIntegrationSelectors(); + // `useRepositoryIntegration` is the same signal the Agents view uses to + // surface "Connected and active (N repos)" — gating on this keeps the + // onboarding consistent with what the user sees over there. + const { hasGithubIntegration, repositories } = useRepositoryIntegration(); + const { data: teamConfig, isLoading: teamConfigLoading } = + useSignalTeamConfig(); + const { displayValues, isLoading: sourcesLoading } = useSignalSourceToggles(); + const stepIndex = useInboxOnboardingSessionStore((s) => s.stepIndex); + const slackSkipped = useInboxOnboardingSessionStore((s) => s.slackSkipped); + + const githubDone = hasGithubIntegration && repositories.length > 0; + const slackDone = hasSlackIntegration || slackSkipped; + const sourcesDone = Object.values(displayValues).some(Boolean); + const slackChannelApplicable = hasSlackIntegration && !slackSkipped; + const channelDone = + !slackChannelApplicable || !!teamConfig?.default_slack_notification_channel; + // The Activate step bundles source selection and the Slack channel choice. + const activateDone = sourcesDone && channelDone; + + const doneByStep: Record = { + welcome: true, + github: githubDone, + slack: slackDone, + activate: activateDone, + }; + + const currentIndex = clampIndex(stepIndex); + const currentStep = STEP_ORDER[currentIndex]; + + return { + steps: STEP_ORDER.map((step) => ({ step, done: doneByStep[step] })), + currentStep, + currentIndex, + currentStepDone: doneByStep[currentStep], + isLastStep: currentIndex === STEP_ORDER.length - 1, + slackChannelApplicable, + isComplete: githubDone && slackDone && activateDone, + isLoading: teamConfigLoading || sourcesLoading, + }; +} + +/** + * Progress across the actionable steps (everything but the informational + * welcome). Used by the Agents-view callout to nudge "N of M done". + */ +export function inboxOnboardingProgress(state: InboxOnboardingState): { + doneCount: number; + totalCount: number; +} { + const actionable = state.steps.filter((s) => s.step !== "welcome"); + return { + doneCount: actionable.filter((s) => s.done).length, + totalCount: actionable.length, + }; +} diff --git a/packages/ui/src/features/settings/sections/GitHubIntegrationSection.tsx b/packages/ui/src/features/settings/sections/GitHubIntegrationSection.tsx index 929d2fbb64..437e2eceac 100644 --- a/packages/ui/src/features/settings/sections/GitHubIntegrationSection.tsx +++ b/packages/ui/src/features/settings/sections/GitHubIntegrationSection.tsx @@ -130,7 +130,7 @@ export function GitHubIntegrationSection({ ? describeGithubConnectError(connectError) : timedOut ? "We didn't hear back from GitHub. Try again." - : "Required for the Inbox pipeline to work"} + : "Required for PostHog agents to work."} )}