diff --git a/frontend/snapshots.yml b/frontend/snapshots.yml
index 08e8e37f127e..586db1f84852 100644
--- a/frontend/snapshots.yml
+++ b/frontend/snapshots.yml
@@ -5996,6 +5996,18 @@ snapshots:
hash: v1.k794b7964.e21450a1b2054a36fab2a183fd4485190e7161c07830a04008b734f83ecabb1c.Hj2ygumZlfd2zAIz7tC6m7XoM-G15hY_amDmI_m-cJo
replay-vision-analysis-nudge--default--light:
hash: v1.k794b7964.7eff340ad050dbd7cf07813207e989e33fccb29ef9767e38f89d16b1d567017c.Kr8YaJd8YlDg3tPWTMYWt4vAxTb_HKQuhGUe4IKACDU
+ replay-vision-cited-markdown--hostile-links--dark:
+ hash: v1.k794b7964.d98b1851e8c4c695b87856379b85fd225d554c843086706f8d51fe5c060ad2d9.Nu73wfxltRa0Loo3i2L0DYIY_5NgIZwuP5RMTMyQGts
+ replay-vision-cited-markdown--hostile-links--light:
+ hash: v1.k794b7964.362d39ed28755489dbf7a698e8115a98141f601b8db1364bc44c8558d488ee1c.AIbcwFSazoZiB1_qtsq7cOH1rV6XPryL3zckEZWetEA
+ replay-vision-cited-markdown--plain-prose--dark:
+ hash: v1.k794b7964.c31ceb73ee19d59911337cca2b583f30b7bfdc69349088088811998d357c87f3.y2qdje-cpHJgB9yhNVGjvi8_bsTMVyuR98LWJrnT9Lk
+ replay-vision-cited-markdown--plain-prose--light:
+ hash: v1.k794b7964.1a196e580088555e900e84c0162ae4c523feef638540838b541827654ae507ca.beJnsabMlBBuFJexvb0HtCu-cO0W1Md_3b2ucWGA72I
+ replay-vision-cited-markdown--structured--dark:
+ hash: v1.k794b7964.b8136fc7ff4b2794158921d793938bdd536bccbc0a66eeadf2d988386e27a2f0.5BXQa3xxRK-9zihaQbWZ4rHtM0lmb0uFKZaNcLbKiX8
+ replay-vision-cited-markdown--structured--light:
+ hash: v1.k794b7964.380bf20693c1029dce9ff64afc117eb5fd831449e81d541f4463ade2ec32b1e0.BBhrMSTk8RqH29BEuHYQeMRRlEos0ZQxgB1wPgvSMxQ
scenes-app-ai-observability-clustercard--default--dark:
hash: v1.k794b7964.745c8ac41b251f329b890d56d7b07fb31b4939a440a40eea7932f1081d3de702.ESnQVAOfySBfRJWfJsURYu-prgU3EUvQZCD62MyLlX4
scenes-app-ai-observability-clustercard--default--light:
diff --git a/frontend/src/lib/lemon-ui/LemonMarkdown/LemonMarkdown.tsx b/frontend/src/lib/lemon-ui/LemonMarkdown/LemonMarkdown.tsx
index 5d1ef9f9c9ae..72cddb8c8df9 100644
--- a/frontend/src/lib/lemon-ui/LemonMarkdown/LemonMarkdown.tsx
+++ b/frontend/src/lib/lemon-ui/LemonMarkdown/LemonMarkdown.tsx
@@ -31,6 +31,17 @@ function chartRefId(href: unknown): string | null {
return typeof href === 'string' ? (CHART_REF_TARGET.exec(href)?.[1] ?? null) : null
}
+/** Link target for a moment in a recording, e.g. `[01:32](t:92000)`, the offset in milliseconds. */
+export const TIMESTAMP_REF_PREFIX = 't:'
+
+// Bounded digits, so an overlong target stays an ordinary link and keeps the usual URL sanitizing.
+const TIMESTAMP_REF_TARGET = new RegExp(`^${TIMESTAMP_REF_PREFIX}(\\d{1,12})$`)
+
+function timestampRefMs(href: unknown): number | null {
+ const digits = typeof href === 'string' ? TIMESTAMP_REF_TARGET.exec(href)?.[1] : undefined
+ return digits === undefined ? null : parseInt(digits, 10)
+}
+
/** Whether a paragraph's child node is a chart reference. */
function isChartRefNode(node: any): boolean {
return node?.tagName === 'a' && chartRefId(node.properties?.href) !== null
@@ -70,8 +81,16 @@ export interface LemonMarkdownProps {
* URL — leaking the viewer's IP, acting as a tracking pixel, or probing internal addresses.
* When set, only images served from PostHog (same-origin or a `posthog.com` host) render inline
* as ; every other image is rendered as a plain click-to-open link instead.
+ * `'all'` emits no at all: a same-origin `src` is still a credentialed GET on this host.
*/
- disableImages?: boolean
+ disableImages?: boolean | 'all'
+ /** Whether `@member:` / `@role:` stay plain text instead of chips naming a real colleague. */
+ disableMentions?: boolean
+ /**
+ * Whether every link renders as its label rather than an anchor. Preferred over stripping link syntax
+ * from the source, which has to out-guess the inline, reference, autolink and image-fallback forms.
+ */
+ disableLinks?: boolean
className?: string
wrapCode?: boolean
/**
@@ -97,6 +116,8 @@ export interface LemonMarkdownProps {
* is a scheme no browser can follow.
*/
renderChartRef?: (chartId: string, sourceOffset?: number) => React.ReactNode
+ /** Without it, or on a nullish return, a `t:` target renders as its label. */
+ renderTimestampRef?: (timestampMs: number) => React.ReactNode
}
const HEADING_TAGS = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as const
@@ -132,15 +153,22 @@ const LemonMarkdownRenderer = memo(function LemonMarkdownRenderer({
lowKeyHeadings = false,
disableDocsRedirect = false,
disableImages = false,
+ disableLinks = false,
+ disableMentions = false,
wrapCode = false,
codeMaxLines,
generateHeadingIds = false,
renderMermaid,
renderChartRef,
+ renderTimestampRef,
}: LemonMarkdownProps): JSX.Element {
const components = useMemo(
() => ({
a: ({ href, children, node }: any): JSX.Element => {
+ const timestampMs = timestampRefMs(href)
+ if (timestampMs !== null) {
+ return <>{renderTimestampRef?.(timestampMs) ?? children}>
+ }
const chartId = chartRefId(href)
if (chartId !== null) {
// Falls back to its own label as plain text: for a consumer that can't draw
@@ -149,6 +177,9 @@ const LemonMarkdownRenderer = memo(function LemonMarkdownRenderer({
// instead would point the reader at a scheme no browser can follow.
return <>{renderChartRef?.(chartId, node?.position?.start?.offset) ?? children}>
}
+ if (disableLinks) {
+ return <>{children}>
+ }
return (
{children}
@@ -226,14 +257,19 @@ const LemonMarkdownRenderer = memo(function LemonMarkdownRenderer({
},
...(disableImages
? {
- img: ({ src, alt }: any): JSX.Element =>
- isTrustedPostHogUrl(src) ? (
-
+ img: ({ src, alt }: any): JSX.Element => {
+ if (disableImages !== 'all' && isTrustedPostHogUrl(src)) {
+ return
+ }
+ // The click-to-open fallback is an anchor, so it answers to `disableLinks` too.
+ return disableLinks ? (
+ <>{alt || src}>
) : (
{alt || src}
- ),
+ )
+ },
}
: {}),
li: ({ children, node }: any): JSX.Element => {
@@ -282,39 +318,38 @@ const LemonMarkdownRenderer = memo(function LemonMarkdownRenderer({
[
disableDocsRedirect,
disableImages,
+ disableLinks,
lowKeyHeadings,
wrapCode,
codeMaxLines,
generateHeadingIds,
renderMermaid,
renderChartRef,
+ renderTimestampRef,
]
)
- // `chart:` is not a protocol the default URL sanitizer knows, so it strips the href before the
- // `a` override ever sees it — and an emptied href is what `Link` turns into a focusable
- // target-blank stub that goes nowhere. Let the scheme through whether or not this caller can
- // draw charts, so the `a` override can recognize the reference and render its label as text.
- // Only on `href`: the same transform runs for an image `src`, where nothing downstream reads a
- // chart reference, so `` would carry an unsanitized scheme into a broken image.
+ // The default sanitizer strips both schemes, so let them through, on `href` only.
const urlTransform = useMemo(
() =>
(url: string, key: string): string =>
- key === 'href' && chartRefId(url) !== null ? url : defaultUrlTransform(url),
+ key === 'href' && (chartRefId(url) !== null || timestampRefMs(url) !== null)
+ ? url
+ : defaultUrlTransform(url),
[]
)
// remark-breaks: a single newline becomes a line break, so prose authored without the arcane
// two-trailing-spaces hard-break rule (e.g. agent-written report summaries) renders with the
// line breaks the author intended.
+ const remarkPlugins = useMemo(
+ () => (disableMentions ? [remarkGfm, remarkBreaks] : [remarkGfm, remarkBreaks, remarkMentions]),
+ [disableMentions]
+ )
+
return (
/* eslint-disable-next-line react/forbid-elements */
-
+
{children}
)
@@ -326,11 +361,14 @@ function LemonMarkdownComponent({
lowKeyHeadings = false,
disableDocsRedirect = false,
disableImages = false,
+ disableLinks = false,
+ disableMentions = false,
wrapCode = false,
codeMaxLines,
generateHeadingIds = false,
renderMermaid,
renderChartRef,
+ renderTimestampRef,
className,
}: LemonMarkdownProps): JSX.Element {
return (
@@ -339,11 +377,14 @@ function LemonMarkdownComponent({
lowKeyHeadings={lowKeyHeadings}
disableDocsRedirect={disableDocsRedirect}
disableImages={disableImages}
+ disableLinks={disableLinks}
+ disableMentions={disableMentions}
wrapCode={wrapCode}
codeMaxLines={codeMaxLines}
generateHeadingIds={generateHeadingIds}
renderMermaid={renderMermaid}
renderChartRef={renderChartRef}
+ renderTimestampRef={renderTimestampRef}
>
{children}
diff --git a/frontend/src/lib/lemon-ui/LemonMarkdown/index.ts b/frontend/src/lib/lemon-ui/LemonMarkdown/index.ts
index 04f49fa6afbd..68705c4e8d5d 100644
--- a/frontend/src/lib/lemon-ui/LemonMarkdown/index.ts
+++ b/frontend/src/lib/lemon-ui/LemonMarkdown/index.ts
@@ -1,4 +1,10 @@
-export { CHART_REF_PREFIX, LemonMarkdown, type LemonMarkdownProps, slugifyHeading } from './LemonMarkdown'
+export {
+ CHART_REF_PREFIX,
+ LemonMarkdown,
+ type LemonMarkdownProps,
+ TIMESTAMP_REF_PREFIX,
+ slugifyHeading,
+} from './LemonMarkdown'
// LemonMarkdownWithMermaid is deliberately not re-exported: this barrel is imported by lib
// components shipped in the toolbar bundle, and the mermaid variant must stay out of that graph.
// Import it from 'lib/lemon-ui/LemonMarkdown/LemonMarkdownWithMermaid' directly.
diff --git a/products/posthog_ai/frontend/components/tool/widgets/ReplayVisionScanWidget.tsx b/products/posthog_ai/frontend/components/tool/widgets/ReplayVisionScanWidget.tsx
index eaa97fa98d23..65432b376c13 100644
--- a/products/posthog_ai/frontend/components/tool/widgets/ReplayVisionScanWidget.tsx
+++ b/products/posthog_ai/frontend/components/tool/widgets/ReplayVisionScanWidget.tsx
@@ -5,6 +5,7 @@ import { LemonBanner, Link, Spinner } from '@posthog/lemon-ui'
import { urls } from 'scenes/urls'
import type { ReplayObservationApi } from 'products/replay_vision/frontend/generated/api.schemas'
+import { flattenMarkdownToLine } from 'products/replay_vision/frontend/utils/markdown'
import {
readErrorMessage,
readReasoning,
@@ -98,9 +99,10 @@ function ObservationRow({ observation }: { observation: ReplayObservationApi }):
)
}
- // Summarizers write a title and a body; a monitor scan on the same widget has reasoning instead.
+ // Summarizers write a title and a body; a monitor scan has reasoning, which can carry markdown.
const title = readTitle(observation)
- const body = readSummary(observation) ?? readReasoning(observation)
+ const reasoning = readReasoning(observation)
+ const body = readSummary(observation) ?? (reasoning ? flattenMarkdownToLine(reasoning) : null)
return (
diff --git a/products/replay_vision/backend/observation_formatting.py b/products/replay_vision/backend/observation_formatting.py
index 9e0a23eb67d2..065b780e4483 100644
--- a/products/replay_vision/backend/observation_formatting.py
+++ b/products/replay_vision/backend/observation_formatting.py
@@ -10,6 +10,73 @@
SEARCH_SNIPPET_LIMIT = 600
+_FENCE_RE = re.compile(r"^ {0,3}(?:```|~~~)")
+_RULE_RE = re.compile(r"^ {0,3}(?:[-*_]\s*){3,}$")
+_HEADING_RE = re.compile(r"^ {0,3}#{1,6}\s+")
+_QUOTE_RE = re.compile(r"^ {0,3}>\s?")
+_BULLET_RE = re.compile(r"^\s*(?:[-*+]|\d{1,9}[.)])\s+")
+_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\([^)]*\)")
+_LINK_RE = re.compile(r"\[([^\]]*)\]\([^)]*\)")
+_LINK_DEFINITION_RE = re.compile(r"^ {0,3}\[[^\]]+\]:\s*\S+(?:\s+(?:\"[^\"]*\"|'[^']*'|\([^)]*\)))?\s*$")
+_REFERENCE_RE = re.compile(r"!?\[([^\]]*)\]\[[^\]]*\]")
+_CODE_SPAN_RE = re.compile(r"`+([^`]*)`+")
+_STRONG_RE = re.compile(r"(\*\*|__)(?=\S)(.+?)(?<=\S)\1")
+_STRIKE_RE = re.compile(r"~~(?=\S)(.+?)(?<=\S)~~")
+_EMPHASIS_RE = re.compile(r"(?~|])")
+_SENTENCE_ENDINGS = ".!?:;,"
+
+
+def flatten_markdown(text: str) -> str:
+ """Markdown-bearing model text as plain prose, keeping the line structure.
+
+ Scanner reasoning can carry a bold lead-in, a heading, or bullets. Every consumer outside the UI reads
+ it as prose, where the syntax is noise at best (`**` and `##` inside a search snippet) and a forged list
+ row at worst. Flattening here rather than at each consumer keeps the stored field the one canonical
+ copy. `plain_snippet` is what folds the result onto a single line.
+
+ The regexes above read a line the way a markdown parser does: block markers only at the start of a
+ line and only space-indented, inline markers anywhere. Three carry a reason worth knowing:
+ `_LINK_DEFINITION_RE` has to match a whole line, or it eats a sentence that merely opens the same way
+ ("[Save]: clicked twice before it took"); bare `[text]` is deliberately left alone, being far more
+ often a real bracket in prose than a shortcut reference; and `_EMPHASIS_RE`'s lookarounds keep
+ `snake_case` and `2 * 3 * 4` intact. The frontend's `flattenMarkdownToLine`
+ (products/replay_vision/frontend/utils/markdown.ts) is the same table and has to stay in step, or a
+ snippet flattens differently in a search result and on the seekbar.
+ """
+ lines: list[str] = []
+ for raw in text.split("\n"):
+ if _FENCE_RE.match(raw) or _RULE_RE.match(raw) or _LINK_DEFINITION_RE.match(raw):
+ continue
+ line = _BULLET_RE.sub("", _QUOTE_RE.sub("", _HEADING_RE.sub("", raw)))
+ line = _IMAGE_RE.sub(r"\1", line)
+ line = _LINK_RE.sub(r"\1", line)
+ line = _REFERENCE_RE.sub(r"\1", line)
+ line = _CODE_SPAN_RE.sub(r"\1", line)
+ line = _STRONG_RE.sub(r"\2", line)
+ line = _STRIKE_RE.sub(r"\1", line)
+ line = _EMPHASIS_RE.sub(r"\2", line)
+ lines.append(_ESCAPE_RE.sub(r"\1", line).rstrip())
+ return re.sub(r"\n{3,}", "\n\n", "\n".join(lines)).strip()
+
+
+def plain_snippet(text: str, *, limit: int | None = SEARCH_SNIPPET_LIMIT) -> str:
+ """One line of readable plain text from a model's free-text field.
+
+ The single line is a safety property, not tidiness. These snippets go into markdown someone else reads
+ as structure — the search-result bullet list inside a Max tool's untrusted-data fence — where a newline
+ lets recording-derived text forge a row or a header. Flattening runs first so a markdown bullet cannot
+ survive as a literal `-` at the start of the folded line either. Blocks are joined with a sentence
+ break so a flattened list still reads as prose rather than one run-on clause.
+ """
+ flat = flatten_markdown(EVENT_ID_CITATION_RE.sub("", text))
+ out = ""
+ for block in (b for line in flat.split("\n") if (b := " ".join(line.split()))):
+ if out:
+ out += " " if out[-1] in _SENTENCE_ENDINGS else ". "
+ out += block
+ return out if limit is None else out[:limit]
+
def read_output(obs: "ReplayObservation") -> dict[str, Any] | None:
scanner_result = obs.scanner_result if isinstance(obs.scanner_result, dict) else None
@@ -36,11 +103,12 @@ def describe_output(output: dict[str, Any]) -> str | None:
def explanation_text(output: dict[str, Any]) -> str:
"""The free text a scanner wrote about the session: monitors, scorers and classifiers reason, summarizers
- summarize. Event-id citations are for the UI, so they are stripped here."""
+ summarize. Event-id citations are for the UI, so they are stripped here, and any markdown the model wrote
+ is flattened: every caller reads this as one line of prose."""
explanation = output.get("reasoning") or output.get("summary")
if not isinstance(explanation, str):
return ""
- return re.sub(r"\s+", " ", EVENT_ID_CITATION_RE.sub("", explanation)).strip()
+ return plain_snippet(explanation, limit=None)
def format_line(obs: "ReplayObservation", output: dict[str, Any], *, show_scanner: bool) -> str:
diff --git a/products/replay_vision/backend/temporal/activities/embed_observation.py b/products/replay_vision/backend/temporal/activities/embed_observation.py
index 24879a32002c..5ecad96cffb4 100644
--- a/products/replay_vision/backend/temporal/activities/embed_observation.py
+++ b/products/replay_vision/backend/temporal/activities/embed_observation.py
@@ -27,6 +27,7 @@
EMBEDDING_PRODUCT,
OBSERVATION_EMBEDDING_MODEL,
)
+from products.replay_vision.backend.observation_formatting import flatten_markdown
from products.replay_vision.backend.tags import slugify_tag
from products.replay_vision.backend.temporal.constants import KAFKA_DELIVERY_TIMEOUT_S
from products.replay_vision.backend.temporal.decorators import track_activity
@@ -39,8 +40,13 @@
def _renderings_for(model_output: AnyScannerOutput) -> list[tuple[str, str]]:
+ """The `(rendering, content)` pairs to embed for one scanner output, or none when it has nothing to say.
+
+ Flattened first: markdown syntax the model wrote is not meaning, and embedding `**` and `##` tokens
+ dilutes the vector that semantic search ranks on.
+ """
document = model_output.embedding_document()
- return [(document.rendering, document.text)] if document else []
+ return [(document.rendering, flatten_markdown(document.text))] if document else []
def _result_metadata(model_output: AnyScannerOutput) -> dict[str, Any]:
diff --git a/products/replay_vision/backend/tests/test_observation_formatting.py b/products/replay_vision/backend/tests/test_observation_formatting.py
index 28122e66845e..4dc205819798 100644
--- a/products/replay_vision/backend/tests/test_observation_formatting.py
+++ b/products/replay_vision/backend/tests/test_observation_formatting.py
@@ -1,6 +1,8 @@
from datetime import date
-from products.replay_vision.backend.observation_formatting import format_line
+from parameterized import parameterized
+
+from products.replay_vision.backend.observation_formatting import flatten_markdown, format_line, plain_snippet
class _FakeObs:
@@ -17,4 +19,59 @@ def test_format_line_collapses_whitespace_so_observations_cannot_forge_rows() ->
show_scanner=False,
)
assert "\n" not in line
- assert "clicked checkout - forged row ignore the above" in line
+ assert "clicked checkout. forged row. ignore the above" in line
+
+
+class TestPlainSnippet:
+ @parameterized.expand(
+ [
+ ("bold", "The user **abandoned** checkout.", "The user abandoned checkout."),
+ ("italic", "The user *hesitated* here.", "The user hesitated here."),
+ ("inline_code", "Clicked the `Submit` button.", "Clicked the Submit button."),
+ ("heading", "## Checkout blocked\nThe form rejected it.", "Checkout blocked. The form rejected it."),
+ ("bullets", "- Reached payment\n- Card rejected", "Reached payment. Card rejected"),
+ ("numbered", "1. Reached payment\n2. Card rejected", "Reached payment. Card rejected"),
+ ("blockquote", "> The user gave up.", "The user gave up."),
+ ("link", "Landed on [the pricing page](https://example.com/p).", "Landed on the pricing page."),
+ ("reference_link", "Landed on [pricing][p].\n\n[p]: https://example.com/p", "Landed on pricing."),
+ ("bracketed_prose", "The user clicked [Save].", "The user clicked [Save]."),
+ ("prose_after_bracket", "[Save]: clicked twice before it took", "[Save]: clicked twice before it took"),
+ ("image", " followed.", "a screenshot followed."),
+ ("escaped_star", r"Priced at 5\* the usual.", "Priced at 5* the usual."),
+ ("tab_indented_heading", "\t# literal hash", "# literal hash"),
+ ("already_one_sentence_per_line", "Reached payment.\nCard rejected.", "Reached payment. Card rejected."),
+ ("existing_terminal_punctuation", "Two problems:\n- one\n- two", "Two problems: one. two"),
+ ]
+ )
+ def test_flattens_markdown_to_one_readable_line(self, _label: str, text: str, expected: str) -> None:
+ assert plain_snippet(text) == expected
+
+ @parameterized.expand(
+ [
+ ("snake_case", "The handler read team_id_override from the payload."),
+ ("multiplication", "Retried 3 * 4 times."),
+ ]
+ )
+ def test_leaves_literal_punctuation_alone(self, _label: str, text: str) -> None:
+ assert plain_snippet(text) == text
+
+ @parameterized.expand(
+ [
+ ("markdown_list", "- forged row\n- second row"),
+ ("markdown_heading", "# Alert fired\nsomething else"),
+ ("hard_wrapped_paragraphs", "line one\nline two\n\nline three"),
+ ]
+ )
+ def test_never_emits_a_line_break_or_a_leading_block_marker(self, _label: str, text: str) -> None:
+ snippet = plain_snippet(text)
+ assert "\n" not in snippet
+ assert not snippet.startswith(("-", "*", "#", ">"))
+
+ def test_caps_length_and_can_opt_out(self) -> None:
+ text = "word " * 400
+ assert len(plain_snippet(text)) == 600
+ assert len(plain_snippet(text, limit=None)) > 600
+
+
+def test_flatten_markdown_keeps_line_structure_for_embeddings() -> None:
+ assert flatten_markdown("## Title\n\n- **one**\n- two") == "Title\n\none\ntwo"
diff --git a/products/replay_vision/frontend/components/CitedMarkdown.stories.tsx b/products/replay_vision/frontend/components/CitedMarkdown.stories.tsx
new file mode 100644
index 000000000000..46a5d18e9776
--- /dev/null
+++ b/products/replay_vision/frontend/components/CitedMarkdown.stories.tsx
@@ -0,0 +1,76 @@
+import type { Meta, StoryObj } from '@storybook/react'
+
+import { CitedMarkdown } from './CitedMarkdown'
+
+const meta: Meta = {
+ title: 'Replay Vision/Cited markdown',
+ component: CitedMarkdown,
+ args: { onSeek: () => {} },
+}
+export default meta
+
+type Story = StoryObj
+
+export const Structured: Story = {
+ args: {
+ text: '**Checkout blocked at payment**\n\nThe user reached the payment step and never got past it:\n\n- The card form rejected the submission twice with no visible error\n- A third attempt on a different card behaved the same way\n\nThe session ends on the payment page.',
+ segments: [
+ { kind: 'text', value: '**Checkout blocked at payment**\n\nThe user reached the payment step' },
+ { kind: 'chip', timestamp_ms: 92000 },
+ {
+ kind: 'text',
+ value: ' and never got past it:\n\n- The card form rejected the submission twice with no visible error\n- A third attempt on a different card behaved the same way',
+ },
+ { kind: 'chip', timestamp_ms: 241000 },
+ { kind: 'text', value: '\n\nThe session ends on the payment page.' },
+ ],
+ },
+}
+
+export const PlainProse: Story = {
+ args: {
+ text: 'The user opened the pricing page, scrolled to the comparison table, and left without starting a trial.',
+ segments: [],
+ },
+}
+
+/** Only `test-storybook` checks this: `react-markdown` is ESM-only and mocked out under Jest. */
+export const HostileLinks: Story = {
+ args: {
+ text: [
+ 'Inline [click here](https://evil.example/phish).',
+ 'Reference [click here][ref] and collapsed [click here][].',
+ 'Image by reference ![a banner][img].',
+ 'Same-origin image  fires a credentialed GET.',
+ 'Autolink and bare https://evil.example/bare.',
+ 'Scheme [click here](javascript:alert(1)).',
+ 'Mentions @member:1 and @role:1 name whoever holds those ids.',
+ '',
+ '[ref]: https://evil.example/phish',
+ '[img]: https://evil.example/banner.png',
+ ].join('\n'),
+ segments: [],
+ },
+ // `storybook/test` belongs to the frontend workspace and does not resolve from `products/`, so the
+ // wait and the assertions are by hand. An empty canvas passes the element counts vacuously, so the
+ // prose has to arrive before anything is asserted.
+ play: async ({ canvasElement }) => {
+ let text = ''
+ for (let attempt = 0; attempt < 40; attempt++) {
+ text = canvasElement.textContent ?? ''
+ if (text.includes('click here')) {
+ break
+ }
+ await new Promise((resolve) => setTimeout(resolve, 50))
+ }
+ const check = (ok: boolean, failure: string): void => {
+ if (!ok) {
+ throw new Error(failure)
+ }
+ }
+ check(text.includes('click here'), 'the prose never rendered, or a link label was dropped')
+ check(text.includes('@member:1 and @role:1'), 'a mention resolved into a chip')
+ check(canvasElement.querySelectorAll('a').length === 0, 'a clickable link reached the reader')
+ check(canvasElement.querySelectorAll('img').length === 0, 'an image fired a request')
+ },
+}
diff --git a/products/replay_vision/frontend/components/CitedMarkdown.tsx b/products/replay_vision/frontend/components/CitedMarkdown.tsx
new file mode 100644
index 000000000000..93c4a0066bd1
--- /dev/null
+++ b/products/replay_vision/frontend/components/CitedMarkdown.tsx
@@ -0,0 +1,32 @@
+import { useMemo } from 'react'
+
+import { LemonMarkdown } from 'lib/lemon-ui/LemonMarkdown'
+
+import { citedMarkdown } from '../utils/citations'
+import { TimestampCitation } from './TimestampCitation'
+
+/** Cited scanner output as markdown. The text is hostile, so it renders no request, link or mention. */
+export function CitedMarkdown({
+ text,
+ segments,
+ onSeek,
+}: {
+ text: string
+ segments: unknown
+ onSeek?: (timestampMs: number) => void
+}): JSX.Element {
+ const markdown = useMemo(() => citedMarkdown(text, segments), [text, segments])
+ return (
+ }
+ >
+ {markdown}
+
+ )
+}
diff --git a/products/replay_vision/frontend/components/ObservationCard.tsx b/products/replay_vision/frontend/components/ObservationCard.tsx
index 0602838399bf..c72782431da3 100644
--- a/products/replay_vision/frontend/components/ObservationCard.tsx
+++ b/products/replay_vision/frontend/components/ObservationCard.tsx
@@ -1,8 +1,7 @@
-import { IconCopy, IconRewindPlay, IconSparkles } from '@posthog/icons'
+import { IconCopy, IconSparkles } from '@posthog/icons'
import { LemonButton, LemonTag, Link, Spinner, Tooltip } from '@posthog/lemon-ui'
import { copyToClipboard } from 'lib/utils/copyToClipboard'
-import { colonDelimitedDuration } from 'lib/utils/durations'
import { urls } from 'scenes/urls'
import type { ReplayObservationApi } from '../generated/api.schemas'
@@ -18,10 +17,12 @@ import {
} from '../replay_scanners/types'
import { citedTextToPlainText, parseCitedSegments } from '../utils/citations'
import { readReasoning, scannerLabel } from '../utils/observation'
+import { CitedMarkdown } from './CitedMarkdown'
import { LabeledRow } from './LabeledRow'
import { ObservationProgressBar } from './ObservationProgressBar'
import { ObservationRetryButton } from './ObservationRetryButton'
import { ScannerTypeBadge } from './ScannerTypeBadge'
+import { TimestampCitation } from './TimestampCitation'
export function ObservationStatusTag({
status,
@@ -99,26 +100,13 @@ export function CitedText({
}
return (
<>
- {list.map((segment, i) => {
- if (segment.kind === 'text') {
- return {segment.value}
- }
- const seconds = Math.max(0, Math.floor(segment.timestamp_ms / 1000))
- const label = colonDelimitedDuration(seconds, null)
- if (onSeek) {
- return (
- onSeek(segment.timestamp_ms)} className="ml-0.5">
-
- {label}
-
- )
- }
- return (
-
- {label}
-
+ {list.map((segment, i) =>
+ segment.kind === 'text' ? (
+ {segment.value}
+ ) : (
+
)
- })}
+ )}
>
)
}
@@ -496,9 +484,7 @@ export function ObservationDockCard({
{reasoning && (
-
-
-
+
)}
>
diff --git a/products/replay_vision/frontend/components/TimestampCitation.tsx b/products/replay_vision/frontend/components/TimestampCitation.tsx
new file mode 100644
index 000000000000..7254e0bfffc0
--- /dev/null
+++ b/products/replay_vision/frontend/components/TimestampCitation.tsx
@@ -0,0 +1,24 @@
+import { IconRewindPlay } from '@posthog/icons'
+import { Link } from '@posthog/lemon-ui'
+
+import { colonDelimitedDuration } from 'lib/utils/durations'
+
+/** One cited moment: a seek control into the recording, or a plain timestamp where no player is on screen. */
+export function TimestampCitation({
+ timestampMs,
+ onSeek,
+}: {
+ timestampMs: number
+ onSeek?: (timestampMs: number) => void
+}): JSX.Element {
+ const label = colonDelimitedDuration(Math.max(0, Math.floor(timestampMs / 1000)), null)
+ if (!onSeek) {
+ return {label}
+ }
+ return (
+ onSeek(timestampMs)} className="ml-0.5" data-attr="vision-observation-citation">
+
+ {label}
+
+ )
+}
diff --git a/products/replay_vision/frontend/observations/ReplayObservation.tsx b/products/replay_vision/frontend/observations/ReplayObservation.tsx
index 935fae29ef98..1c1f54abe6c8 100644
--- a/products/replay_vision/frontend/observations/ReplayObservation.tsx
+++ b/products/replay_vision/frontend/observations/ReplayObservation.tsx
@@ -40,9 +40,9 @@ import { ProductKey } from '~/queries/schema/schema-general'
import { BooleanTag } from '../components/BooleanTag'
import { CardHeader } from '../components/CardHeader'
+import { CitedMarkdown } from '../components/CitedMarkdown'
import { LabeledRow } from '../components/LabeledRow'
import {
- CitedText,
ObservationConfidence,
ObservationPrimaryOutput,
ObservationStatusTag,
@@ -493,9 +493,7 @@ export function ReplayObservationSceneComponent(): JSX.Element {
>
} title="Model reasoning" />
{reasoning ? (
-
-
-
+
) : (
{observation.status === 'ineligible'
diff --git a/products/replay_vision/frontend/utils/citations.test.ts b/products/replay_vision/frontend/utils/citations.test.ts
index bc80bbb1e391..a17717091076 100644
--- a/products/replay_vision/frontend/utils/citations.test.ts
+++ b/products/replay_vision/frontend/utils/citations.test.ts
@@ -1,4 +1,4 @@
-import { type Segment, citedTextToPlainText, parseCitedSegments } from './citations'
+import { type Segment, citedMarkdown, citedTextToPlainText, parseCitedSegments } from './citations'
describe('parseCitedSegments', () => {
const text = (value: string): Segment => ({ kind: 'text', value })
@@ -74,3 +74,63 @@ describe('citedTextToPlainText', () => {
expect(citedTextToPlainText(text, segments)).toBe(expected)
})
})
+
+describe('citedMarkdown', () => {
+ it.each<{ name: string; text: string; segments: unknown; expected: string }>([
+ {
+ name: 'writes a chip back as a t: link target',
+ text: 'ignored when segments exist',
+ segments: [
+ { kind: 'text', value: 'Abandoned the cart' },
+ { kind: 'chip', timestamp_ms: 161_000 },
+ { kind: 'text', value: ' and left.' },
+ ],
+ expected: 'Abandoned the cart[02:41](t:161000) and left.',
+ },
+ {
+ name: 'keeps a block that spans a citation in one document',
+ text: '',
+ segments: [
+ { kind: 'text', value: '- **Reached payment**' },
+ { kind: 'chip', timestamp_ms: 92_000 },
+ { kind: 'text', value: '\n- Card rejected' },
+ ],
+ expected: '- **Reached payment**[01:32](t:92000)\n- Card rejected',
+ },
+ {
+ name: 'does not let a trailing exclamation mark turn the citation into an image',
+ text: '',
+ segments: [
+ { kind: 'text', value: 'Something broke!' },
+ { kind: 'chip', timestamp_ms: 5_000 },
+ ],
+ expected: 'Something broke! [00:05](t:5000)',
+ },
+ {
+ name: 'passes the prose through untouched, including anything link-shaped',
+ text: '',
+ segments: [
+ { kind: 'text', value: 'Saw [an offer](https://evil.example)' },
+ { kind: 'chip', timestamp_ms: 5_000 },
+ ],
+ expected: 'Saw [an offer](https://evil.example)[00:05](t:5000)',
+ },
+ {
+ name: 'rounds a fractional offset and clamps a negative one so the target stays parseable',
+ text: '',
+ segments: [
+ { kind: 'chip', timestamp_ms: 92_000.6 },
+ { kind: 'chip', timestamp_ms: -5 },
+ ],
+ expected: '[01:32](t:92001)[00:00](t:0)',
+ },
+ {
+ name: 'keeps text without citations untouched',
+ text: 'No citations here.',
+ segments: [],
+ expected: 'No citations here.',
+ },
+ ])('$name', ({ text, segments, expected }) => {
+ expect(citedMarkdown(text, segments)).toBe(expected)
+ })
+})
diff --git a/products/replay_vision/frontend/utils/citations.ts b/products/replay_vision/frontend/utils/citations.ts
index cf330d850fe0..f7f0941165a2 100644
--- a/products/replay_vision/frontend/utils/citations.ts
+++ b/products/replay_vision/frontend/utils/citations.ts
@@ -1,3 +1,4 @@
+import { TIMESTAMP_REF_PREFIX } from 'lib/lemon-ui/LemonMarkdown'
import { colonDelimitedDuration } from 'lib/utils/durations'
// `uuid` is legacy (only old event-uuid citations carry it). Timestamp citations use `timestamp_ms` alone.
@@ -56,6 +57,30 @@ export function parseCitedSegments(text: string, segments: unknown): Segment[] {
return persisted.flatMap((segment) => (segment.kind === 'text' ? splitLeakedCitations(segment.value) : [segment]))
}
+/**
+ * Markdown source for a cited field, each chip written back as a `t:` target. One string rather than
+ * a segment list, or a bullet spanning a citation parses as two documents. The renderer sanitizes.
+ */
+export function citedMarkdown(text: string, segments: unknown): string {
+ const list = parseCitedSegments(text, segments)
+ if (list.length === 0) {
+ return text
+ }
+ let out = ''
+ for (const segment of list) {
+ if (segment.kind === 'text') {
+ out += segment.value
+ continue
+ }
+ // Glued to the preceding word, except after a `!`, where that would make the pair an image.
+ // Whole non-negative digits only: LemonMarkdown reads nothing else, and would leave a dead label.
+ const timestampMs = Math.max(0, Math.round(segment.timestamp_ms))
+ const label = colonDelimitedDuration(Math.floor(timestampMs / 1000), null)
+ out += `${out.endsWith('!') ? ' ' : ''}[${label}](${TIMESTAMP_REF_PREFIX}${timestampMs})`
+ }
+ return out
+}
+
/** Plain-text rendering of a cited field for the clipboard: citation chips become readable `(mm:ss)` timestamps. */
export function citedTextToPlainText(text: string, segments: unknown): string {
const list = parseCitedSegments(text, segments)
diff --git a/products/replay_vision/frontend/utils/markdown.test.ts b/products/replay_vision/frontend/utils/markdown.test.ts
new file mode 100644
index 000000000000..4120afcd1dd2
--- /dev/null
+++ b/products/replay_vision/frontend/utils/markdown.test.ts
@@ -0,0 +1,58 @@
+import { flattenMarkdownToLine } from './markdown'
+
+describe('flattenMarkdownToLine', () => {
+ it.each<{ name: string; text: string; expected: string }>([
+ { name: 'strips bold', text: 'The user **abandoned** checkout.', expected: 'The user abandoned checkout.' },
+ { name: 'strips italics', text: 'The user *hesitated* here.', expected: 'The user hesitated here.' },
+ { name: 'strips inline code', text: 'Clicked `Submit`.', expected: 'Clicked Submit.' },
+ {
+ name: 'turns a heading and its body into one sentence pair',
+ text: '## Checkout blocked\nThe form rejected it.',
+ expected: 'Checkout blocked. The form rejected it.',
+ },
+ {
+ name: 'leaves a tab-indented hash as text',
+ text: '\t# literal hash',
+ expected: '# literal hash',
+ },
+ {
+ name: 'reads a bullet list as prose',
+ text: '- Reached payment\n- Card rejected',
+ expected: 'Reached payment. Card rejected',
+ },
+ {
+ name: 'does not double up punctuation the model already wrote',
+ text: 'Two problems:\n- one\n- two',
+ expected: 'Two problems: one. two',
+ },
+ {
+ name: 'strips a link to its label',
+ text: 'Landed on [pricing](https://e.com).',
+ expected: 'Landed on pricing.',
+ },
+ {
+ name: 'strips a reference link to its label and drops the definition line',
+ text: 'Landed on [pricing][p].\n\n[p]: https://e.example/pricing',
+ expected: 'Landed on pricing.',
+ },
+ {
+ name: 'leaves bracketed prose alone',
+ text: 'The user clicked [Save].',
+ expected: 'The user clicked [Save].',
+ },
+ {
+ name: 'keeps a sentence that merely opens like a reference definition',
+ text: '[Save]: clicked twice before it took',
+ expected: '[Save]: clicked twice before it took',
+ },
+ { name: 'leaves snake_case alone', text: 'Read team_id_override.', expected: 'Read team_id_override.' },
+ { name: 'leaves multiplication alone', text: 'Retried 3 * 4 times.', expected: 'Retried 3 * 4 times.' },
+ { name: 'renders plain prose unchanged', text: 'The user gave up.', expected: 'The user gave up.' },
+ ])('$name', ({ text, expected }) => {
+ expect(flattenMarkdownToLine(text)).toBe(expected)
+ })
+
+ it('never emits a line break, so a one-line surface stays one line', () => {
+ expect(flattenMarkdownToLine('# Title\n\n- one\n- two\n\nEnd.')).not.toContain('\n')
+ })
+})
diff --git a/products/replay_vision/frontend/utils/markdown.ts b/products/replay_vision/frontend/utils/markdown.ts
new file mode 100644
index 000000000000..2aaff7711460
--- /dev/null
+++ b/products/replay_vision/frontend/utils/markdown.ts
@@ -0,0 +1,47 @@
+// Kept in step with `flatten_markdown` (backend/observation_formatting.py), which documents the table.
+const FENCE_RE = /^ {0,3}(?:```|~~~)/
+const RULE_RE = /^ {0,3}(?:[-*_]\s*){3,}$/
+const HEADING_RE = /^ {0,3}#{1,6}\s+/
+const QUOTE_RE = /^ {0,3}>\s?/
+const BULLET_RE = /^\s*(?:[-*+]|\d{1,9}[.)])\s+/
+const IMAGE_RE = /!\[([^\]]*)\]\([^)]*\)/g
+const LINK_RE = /\[([^\]]*)\]\([^)]*\)/g
+const LINK_DEFINITION_RE = /^ {0,3}\[[^\]]+\]:\s*\S+(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*$/
+const REFERENCE_RE = /!?\[([^\]]*)\]\[[^\]]*\]/g
+const CODE_SPAN_RE = /`+([^`]*)`+/g
+const STRONG_RE = /(\*\*|__)(\S(?:.*?\S)?)\1/g
+const STRIKE_RE = /~~(\S(?:.*?\S)?)~~/g
+// Captured rather than a lookbehind, which breaks chunk parsing.
+const EMPHASIS_RE = /(^|[^\w*_])([*_])(\S(?:.*?\S)?)\2(?![\w*_])/g
+const ESCAPE_RE = /\\([\\`*_{}[\]()#+\-.!>~|])/g
+const SENTENCE_ENDINGS = '.!?:;,'
+
+/** Markdown-bearing model text on one line, for surfaces with room for a fragment, not a rendered block. */
+export function flattenMarkdownToLine(text: string): string {
+ let out = ''
+ for (const raw of text.split('\n')) {
+ if (FENCE_RE.test(raw) || RULE_RE.test(raw) || LINK_DEFINITION_RE.test(raw)) {
+ continue
+ }
+ const block = raw
+ .replace(HEADING_RE, '')
+ .replace(QUOTE_RE, '')
+ .replace(BULLET_RE, '')
+ .replace(IMAGE_RE, '$1')
+ .replace(LINK_RE, '$1')
+ .replace(REFERENCE_RE, '$1')
+ .replace(CODE_SPAN_RE, '$1')
+ .replace(STRONG_RE, '$2')
+ .replace(STRIKE_RE, '$1')
+ .replace(EMPHASIS_RE, '$1$3')
+ .replace(ESCAPE_RE, '$1')
+ .split(/\s+/)
+ .filter(Boolean)
+ .join(' ')
+ if (!block) {
+ continue
+ }
+ out += out ? `${SENTENCE_ENDINGS.includes(out.slice(-1)) ? ' ' : '. '}${block}` : block
+ }
+ return out
+}
diff --git a/products/replay_vision/frontend/utils/observation.ts b/products/replay_vision/frontend/utils/observation.ts
index c2c667c66683..b687d8cbf98b 100644
--- a/products/replay_vision/frontend/utils/observation.ts
+++ b/products/replay_vision/frontend/utils/observation.ts
@@ -2,6 +2,7 @@ import { dayjs } from 'lib/dayjs'
import { type ReplayObservationApi, ScannerOriginEnumApi, ScannerTypeEnumApi } from '../generated/api.schemas'
import { citedTextToPlainText, parseCitedSegments } from './citations'
+import { flattenMarkdownToLine } from './markdown'
/** The dock's built-in prompt and the observations it produces have to answer to one name. */
export const BUILT_IN_SUMMARY_LABEL = 'Quick summary'
@@ -131,16 +132,17 @@ export interface ObservationSeekbarMark {
const SNIPPET_MAX_LENGTH = 160
function lastSentence(text: string): string | null {
- const trimmed = text.trim()
- if (!trimmed) {
+ // Flattened first: a mark has room for one line, where a bullet marker reads as the sentence start.
+ const flat = flattenMarkdownToLine(text)
+ if (!flat) {
return null
}
// No lookbehind regex, it breaks chunk parsing on older browsers.
let start = 0
- for (const match of trimmed.matchAll(/[.!?]+\s+/g)) {
+ for (const match of flat.matchAll(/[.!?]+\s+/g)) {
start = match.index + match[0].length
}
- const last = trimmed
+ const last = flat
.slice(start)
.replace(/[.!?]+$/, '')
.trim()