Skip to content

Commit 3762b2c

Browse files
authored
feat(scouts): add per-finding share link on scout detail page (#2646)
1 parent 0cf1712 commit 3762b2c

6 files changed

Lines changed: 117 additions & 10 deletions

File tree

packages/shared/src/analytics-events.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,7 @@ export type ScoutActionType =
648648
| "open_task_run"
649649
| "open_skill_in_posthog"
650650
| "open_helper_skill"
651+
| "copy_finding_link"
651652
| "show_more_emitted_runs"
652653
| "filter_runs"
653654
| "toggle_hide_disabled"

packages/ui/src/features/scouts/components/ScoutDetailView.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,14 @@ const FILTERS: { value: ScoutRunFilter; label: string }[] = [
4343
{ value: "failed", label: "Failed" },
4444
];
4545

46-
export function ScoutDetailView({ skillSlug }: { skillSlug: string }) {
46+
export function ScoutDetailView({
47+
skillSlug,
48+
highlightFindingId,
49+
}: {
50+
skillSlug: string;
51+
/** Emission id from a shared finding link – expanded and scrolled to when present. */
52+
highlightFindingId?: string;
53+
}) {
4754
const skillName = scoutSkillNameFromSlug(skillSlug);
4855
const displayName = prettifyScoutSkillName(skillName);
4956

@@ -178,6 +185,7 @@ export function ScoutDetailView({ skillSlug }: { skillSlug: string }) {
178185
windowLabel={scoutRunsWindowLabel(runsWindow)}
179186
loading={runsLoading}
180187
error={runsError}
188+
highlightFindingId={highlightFindingId}
181189
/>
182190

183191
<Flex direction="column" gap="3">

packages/ui/src/features/scouts/components/ScoutEmissionCard.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { MarkdownRenderer } from "@posthog/ui/features/editor/components/Markdow
55
import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp";
66
import { track } from "@posthog/ui/shell/analytics";
77
import { Box, Flex, Text } from "@radix-ui/themes";
8-
import { type ReactNode, useState } from "react";
8+
import { type ReactNode, useEffect, useRef, useState } from "react";
99
import { SeverityBadge } from "./ScoutBadges";
1010

1111
export function ScoutEmissionCard({
@@ -14,6 +14,7 @@ export function ScoutEmissionCard({
1414
actions,
1515
footerEnd,
1616
defaultExpanded = false,
17+
highlighted = false,
1718
}: {
1819
emission: ScoutEmission;
1920
/** The emitting scout, attached to analytics events when known. */
@@ -23,10 +24,27 @@ export function ScoutEmissionCard({
2324
/** Replaces the default pipeline note at the footer's right edge. */
2425
footerEnd?: ReactNode;
2526
defaultExpanded?: boolean;
27+
/** True when a shared finding link targets this card – scrolls it into view. */
28+
highlighted?: boolean;
2629
}) {
2730
const [expanded, setExpanded] = useState(defaultExpanded);
31+
const cardRef = useRef<HTMLDivElement>(null);
32+
// setExpanded too: when a shared link targets a different finding on an
33+
// already-mounted route, only the search param changes, so the useState
34+
// initializer's defaultExpanded never re-runs.
35+
useEffect(() => {
36+
if (highlighted) {
37+
setExpanded(true);
38+
cardRef.current?.scrollIntoView({ block: "center" });
39+
}
40+
}, [highlighted]);
2841
return (
29-
<Box className="min-w-0 overflow-hidden rounded-(--radius-2) border border-(--gray-6) bg-gray-1 p-3">
42+
<Box
43+
ref={cardRef}
44+
className={`min-w-0 overflow-hidden rounded-(--radius-2) border bg-gray-1 p-3 ${
45+
highlighted ? "border-(--accent-8)" : "border-(--gray-6)"
46+
}`}
47+
>
3048
<button
3149
type="button"
3250
onClick={() => {
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { LinkIcon } from "@phosphor-icons/react";
2+
import type { ScoutEmission } from "@posthog/api-client/posthog-client";
3+
import { scoutSkillSlug } from "@posthog/core/scouts/scoutPresentation";
4+
import { ANALYTICS_EVENTS } from "@posthog/shared";
5+
import { track } from "@posthog/ui/shell/analytics";
6+
import { toast } from "sonner";
7+
8+
/**
9+
* Per-finding "Share" CTA on a scout emission card: copies a link that drops
10+
* a coworker onto this scout's detail page with the finding expanded and
11+
* scrolled into view. Best effort – the link only resolves while the finding
12+
* is still inside the scout's runs window.
13+
*/
14+
export function ScoutFindingShareButton({
15+
emission,
16+
skillName,
17+
}: {
18+
emission: ScoutEmission;
19+
skillName: string;
20+
}) {
21+
const handleCopyLink = () => {
22+
// The app router uses hash history, so the route (and its search params)
23+
// must live after the `#`. Keep the pre-hash part of the current URL so
24+
// host-level query params survive in the copied link.
25+
const base = window.location.href.split("#")[0];
26+
const url = `${base}#/code/agents/scouts/${scoutSkillSlug(
27+
skillName,
28+
)}?finding=${encodeURIComponent(emission.id)}`;
29+
navigator.clipboard
30+
.writeText(url)
31+
.then(() => {
32+
toast.success("Finding link copied");
33+
track(ANALYTICS_EVENTS.SCOUT_ACTION, {
34+
action_type: "copy_finding_link",
35+
surface: "scout_detail",
36+
skill_name: skillName,
37+
severity: emission.severity,
38+
});
39+
})
40+
.catch(() => toast.error("Couldn't copy link"));
41+
};
42+
43+
return (
44+
<button
45+
type="button"
46+
onClick={handleCopyLink}
47+
title="Copy a link to this finding"
48+
className="inline-flex shrink-0 items-center gap-1 text-[11px] text-accent-11 no-underline transition-colors hover:text-accent-12"
49+
>
50+
<LinkIcon size={11} />
51+
Share
52+
</button>
53+
);
54+
}

packages/ui/src/features/scouts/components/ScoutSignalsSection.tsx

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { useState } from "react";
77
import { useScoutRunEmissions } from "../hooks/useScoutRunEmissions";
88
import { ScoutEmissionCard } from "./ScoutEmissionCard";
99
import { ScoutFindingDiscussButton } from "./ScoutFindingDiscussButton";
10+
import { ScoutFindingShareButton } from "./ScoutFindingShareButton";
1011
import { ScoutTaskRunLink } from "./ScoutTaskRunLink";
1112

1213
/**
@@ -26,11 +27,14 @@ export function ScoutSignalsSection({
2627
windowLabel,
2728
loading,
2829
error,
30+
highlightFindingId,
2931
}: {
3032
runs: ScoutRun[];
3133
windowLabel: string;
3234
loading: boolean;
3335
error?: boolean;
36+
/** Emission id from a shared finding link – expanded and scrolled to when present. */
37+
highlightFindingId?: string;
3438
}) {
3539
const [showAll, setShowAll] = useState(false);
3640
const emittedRuns = runs.filter((run) => (run.emitted_count ?? 0) > 0);
@@ -56,7 +60,11 @@ export function ScoutSignalsSection({
5660
) : (
5761
<Flex direction="column" gap="2">
5862
{visibleRuns.map((run) => (
59-
<RunEmissions key={run.run_id} run={run} />
63+
<RunEmissions
64+
key={run.run_id}
65+
run={run}
66+
highlightFindingId={highlightFindingId}
67+
/>
6068
))}
6169
{hiddenCount > 0 ? (
6270
<button
@@ -81,7 +89,13 @@ export function ScoutSignalsSection({
8189
);
8290
}
8391

84-
function RunEmissions({ run }: { run: ScoutRun }) {
92+
function RunEmissions({
93+
run,
94+
highlightFindingId,
95+
}: {
96+
run: ScoutRun;
97+
highlightFindingId?: string;
98+
}) {
8599
const {
86100
data: emissions,
87101
isLoading,
@@ -123,11 +137,19 @@ function RunEmissions({ run }: { run: ScoutRun }) {
123137
key={emission.id}
124138
emission={emission}
125139
skillName={run.skill_name}
140+
defaultExpanded={emission.id === highlightFindingId}
141+
highlighted={emission.id === highlightFindingId}
126142
actions={
127-
<ScoutFindingDiscussButton
128-
emission={emission}
129-
skillName={run.skill_name}
130-
/>
143+
<>
144+
<ScoutFindingDiscussButton
145+
emission={emission}
146+
skillName={run.skill_name}
147+
/>
148+
<ScoutFindingShareButton
149+
emission={emission}
150+
skillName={run.skill_name}
151+
/>
152+
</>
131153
}
132154
footerEnd={
133155
taskRunUrl ? (

packages/ui/src/router/routes/code/agents/scouts.$skillName.index.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@ import { ScoutDetailView } from "@posthog/ui/features/scouts/components/ScoutDet
22
import { createFileRoute } from "@tanstack/react-router";
33

44
export const Route = createFileRoute("/code/agents/scouts/$skillName/")({
5+
validateSearch: (search: Record<string, unknown>): { finding?: string } => ({
6+
finding: typeof search.finding === "string" ? search.finding : undefined,
7+
}),
58
component: ScoutDetailRoute,
69
});
710

811
function ScoutDetailRoute() {
912
const { skillName } = Route.useParams();
10-
return <ScoutDetailView skillSlug={skillName} />;
13+
const { finding } = Route.useSearch();
14+
return <ScoutDetailView skillSlug={skillName} highlightFindingId={finding} />;
1115
}

0 commit comments

Comments
 (0)