From 18e48f0d3af51eb218f522e512032017e592a7da Mon Sep 17 00:00:00 2001 From: Juraj Majerik Date: Thu, 17 Sep 2026 17:03:27 +0200 Subject: [PATCH 1/7] feat(prompts): insert, display, and preview prompt references in the editor --- .../prompts/InsertPromptReferenceButton.tsx | 116 ++++++++++++++++++ .../prompts/PromptReferenceTags.stories.tsx | 23 ++++ .../frontend/prompts/PromptReferenceTags.tsx | 28 +++++ .../prompts/insertPromptReferenceLogic.ts | 56 +++++++++ .../frontend/prompts/llmPromptLogic.ts | 58 ++++++++- .../frontend/prompts/promptReferences.ts | 34 +++++ .../prompts/promptSceneComponents.tsx | 73 +++++++++-- 7 files changed, 380 insertions(+), 8 deletions(-) create mode 100644 products/ai_observability/frontend/prompts/InsertPromptReferenceButton.tsx create mode 100644 products/ai_observability/frontend/prompts/PromptReferenceTags.stories.tsx create mode 100644 products/ai_observability/frontend/prompts/PromptReferenceTags.tsx create mode 100644 products/ai_observability/frontend/prompts/insertPromptReferenceLogic.ts create mode 100644 products/ai_observability/frontend/prompts/promptReferences.ts diff --git a/products/ai_observability/frontend/prompts/InsertPromptReferenceButton.tsx b/products/ai_observability/frontend/prompts/InsertPromptReferenceButton.tsx new file mode 100644 index 000000000000..b6589da69a72 --- /dev/null +++ b/products/ai_observability/frontend/prompts/InsertPromptReferenceButton.tsx @@ -0,0 +1,116 @@ +import { useActions, useValues } from 'kea' +import { useState } from 'react' + +import { IconPlusSmall } from '@posthog/icons' +import { LemonButton, LemonModal } from '@posthog/lemon-ui' + +import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from 'lib/ui/quill' + +import { insertPromptReferenceLogic } from './insertPromptReferenceLogic' +import { buildPromptReferenceTag } from './promptReferences' + +interface ReferenceOption { + tag: string + promptName: string + selectorLabel: string +} + +export function InsertPromptReferenceButton({ + currentPromptName, + onInsert, +}: { + currentPromptName: string + onInsert: (tag: string) => void +}): JSX.Element { + const { promptOptions, promptOptionsLoading } = useValues(insertPromptReferenceLogic) + const { loadPromptOptions } = useActions(insertPromptReferenceLogic) + const [isOpen, setIsOpen] = useState(false) + const [inputValue, setInputValue] = useState('') + + // Keyed by a human-readable string so the combobox's text filtering + // matches what the item displays. + const optionsByKey = new Map() + for (const prompt of promptOptions) { + if (prompt.name === currentPromptName) { + continue + } + for (const label of prompt.all_labels) { + optionsByKey.set(`${prompt.name} @ ${label.name}`, { + tag: buildPromptReferenceTag(prompt.name, { label: label.name }), + promptName: prompt.name, + selectorLabel: `@ ${label.name} (follows the label)`, + }) + } + optionsByKey.set(`${prompt.name} @ v${prompt.latest_version}`, { + tag: buildPromptReferenceTag(prompt.name, { version: prompt.latest_version }), + promptName: prompt.name, + selectorLabel: `@ v${prompt.latest_version} (pinned)`, + }) + } + const items = [...optionsByKey.keys()] + + return ( + <> + } + tooltip="Insert a reference to another prompt. Its content replaces the tag when the prompt is fetched." + data-attr="llma-prompt-insert-reference-button" + onClick={(e) => { + e.preventDefault() + setIsOpen(true) + setInputValue('') + loadPromptOptions() + }} + > + Insert reference + + setIsOpen(false)} + title="Insert prompt reference" + description="Reference a label to pick up its future moves, or pin a version to freeze the content." + width={480} + > + { + const option = next ? optionsByKey.get(next) : undefined + if (option) { + onInsert(option.tag) + setIsOpen(false) + } + }} + inputValue={inputValue} + onInputValueChange={(value: string) => setInputValue(value)} + defaultOpen + > + + + + {promptOptionsLoading ? 'Loading prompts...' : 'No matching prompts'} + + + {(item: string) => { + const option = optionsByKey.get(item) + return ( + + {option?.promptName} + {option?.selectorLabel} + + ) + }} + + + + + + ) +} diff --git a/products/ai_observability/frontend/prompts/PromptReferenceTags.stories.tsx b/products/ai_observability/frontend/prompts/PromptReferenceTags.stories.tsx new file mode 100644 index 000000000000..1b6e0fd46452 --- /dev/null +++ b/products/ai_observability/frontend/prompts/PromptReferenceTags.stories.tsx @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { PromptReferenceTags } from './PromptReferenceTags' + +const meta: Meta = { + title: 'Scenes-App/AI Observability/PromptReferenceTags', + component: PromptReferenceTags, +} +export default meta + +type Story = StoryObj + +export const LabelAndVersionReferences: Story = { + args: { + text: 'Intro.\n@@@prompt:name=guardrails|label=production@@@\nMore text.\n@@@prompt:name=tone|version=3@@@', + }, +} + +export const NoReferences: Story = { + args: { + text: 'Plain prompt with {{variables}} only.', + }, +} diff --git a/products/ai_observability/frontend/prompts/PromptReferenceTags.tsx b/products/ai_observability/frontend/prompts/PromptReferenceTags.tsx new file mode 100644 index 000000000000..e0f61825a7b3 --- /dev/null +++ b/products/ai_observability/frontend/prompts/PromptReferenceTags.tsx @@ -0,0 +1,28 @@ +import { LemonTag, Link } from '@posthog/lemon-ui' + +import { urls } from 'scenes/urls' + +import { extractPromptReferences } from './promptReferences' + +export function PromptReferenceTags({ text }: { text: string }): JSX.Element | null { + const references = extractPromptReferences(text) + if (references.length === 0) { + return null + } + return ( +
+ References: + {references.map((reference) => ( + + + {reference.name} @ {reference.label ?? `v${reference.version}`} + + + ))} +
+ ) +} diff --git a/products/ai_observability/frontend/prompts/insertPromptReferenceLogic.ts b/products/ai_observability/frontend/prompts/insertPromptReferenceLogic.ts new file mode 100644 index 000000000000..81fc52e1b585 --- /dev/null +++ b/products/ai_observability/frontend/prompts/insertPromptReferenceLogic.ts @@ -0,0 +1,56 @@ +import { MakeLogicType, kea, path } from 'kea' +import { loaders } from 'kea-loaders' + +import { ApiConfig } from '~/lib/api' + +import { llmPromptsList } from '../generated/api' +import type { LLMPromptListApi } from '../generated/api.schemas' + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface insertPromptReferenceLogicValues { + promptOptions: LLMPromptListApi[] + promptOptionsLoading: boolean +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface insertPromptReferenceLogicActions { + loadPromptOptions: () => any + loadPromptOptionsFailure: ( + error: string, + errorObject?: any + ) => { + error: string + errorObject?: any + } + loadPromptOptionsSuccess: ( + promptOptions: LLMPromptListApi[], + payload?: any + ) => { + promptOptions: LLMPromptListApi[] + payload?: any + } +} + +export type insertPromptReferenceLogicType = MakeLogicType< + insertPromptReferenceLogicValues, + insertPromptReferenceLogicActions +> + +export const insertPromptReferenceLogic = kea([ + path(['scenes', 'ai-observability', 'insertPromptReferenceLogic']), + loaders({ + promptOptions: [ + [] as LLMPromptListApi[], + { + loadPromptOptions: async () => { + const response = await llmPromptsList(String(ApiConfig.getCurrentTeamId()), { + content: 'none', + order_by: 'name', + limit: 100, + }) + return response.results + }, + }, + ], + }), +]) diff --git a/products/ai_observability/frontend/prompts/llmPromptLogic.ts b/products/ai_observability/frontend/prompts/llmPromptLogic.ts index 08db0f43a06a..7be3b5237d1c 100644 --- a/products/ai_observability/frontend/prompts/llmPromptLogic.ts +++ b/products/ai_observability/frontend/prompts/llmPromptLogic.ts @@ -41,9 +41,10 @@ import { llmPromptsNameLabelsDestroy, llmPromptsNameLabelsUpdate, llmPromptsNamePartialUpdate, + llmPromptsNameRetrieve, llmPromptsResolveNameRetrieve, } from '../generated/api' -import type { LLMPromptLabelApi, LLMPromptResolveResponseApi } from '../generated/api.schemas' +import type { LLMPromptLabelApi, LLMPromptPublicApi, LLMPromptResolveResponseApi } from '../generated/api.schemas' import { llmPromptsLogic } from './llmPromptsLogic' import { LLM_PROMPTS_FORCE_RELOAD_PARAM } from './llmPromptsLogic' import { LLMPrompt, LLMPromptVersionSummary } from './types' @@ -232,6 +233,7 @@ export interface llmPromptLogicValues { isPromptMissing: boolean isPublishReviewOpen: boolean isRenderingMarkdown: boolean + isShowingResolvedPreview: boolean isViewMode: boolean labelPickerVersion: number | null labelsByVersion: Record @@ -256,6 +258,8 @@ export interface llmPromptLogicValues { publishConflict: PublishConflict | null relatedTracesQuery: DataTableNode | null relatedTracesQueryOverride: DataTableNode | null + resolvedPreview: LLMPromptPublicApi | null + resolvedPreviewLoading: boolean shouldDisplaySkeleton: boolean showPromptFormErrors: boolean snippetLanguage: PromptSnippetLanguage @@ -321,6 +325,21 @@ export interface llmPromptLogicActions { prompt: ResolvedLLMPrompt payload?: any } + loadResolvedPreview: () => any + loadResolvedPreviewFailure: ( + error: string, + errorObject?: any + ) => { + error: string + errorObject?: any + } + loadResolvedPreviewSuccess: ( + resolvedPreview: LLMPromptPublicApi | null, + payload?: any + ) => { + resolvedPreview: LLMPromptPublicApi | null + payload?: any + } openLabelPicker: (version: number) => { version: number } @@ -421,6 +440,9 @@ export interface llmPromptLogicActions { toggleOutlineExpanded: () => { value: true } + toggleResolvedPreview: () => { + value: true + } touchPromptFormField: (key: string) => { key: string } @@ -522,6 +544,7 @@ export const llmPromptLogic = kea([ setAnalyticsScope: (analyticsScope: PromptAnalyticsScope) => ({ analyticsScope }), setRelatedTracesQuery: (query: DataTableNode) => ({ query }), toggleMarkdownRendering: true, + toggleResolvedPreview: true, setCompareVersion: (compareVersion: number | null) => ({ compareVersion }), toggleOutlineExpanded: true, showConfigEditor: true, @@ -575,6 +598,14 @@ export const llmPromptLogic = kea([ setRelatedTracesQuery: (_, { query }) => query, }, ], + isShowingResolvedPreview: [ + false, + { + toggleResolvedPreview: (state: boolean) => !state, + setMode: () => false, + loadPromptSuccess: () => false, + }, + ], isRenderingMarkdown: [ props.promptName === 'new' ? false : (props.mode ?? PromptMode.View) !== PromptMode.Edit, { @@ -678,6 +709,26 @@ export const llmPromptLogic = kea([ }, })), + loaders(({ props, values }) => ({ + resolvedPreview: { + __default: null as LLMPromptPublicApi | null, + loadResolvedPreview: async () => { + try { + const viewedVersion = + values.prompt && isPrompt(values.prompt) ? { version: values.prompt.version } : undefined + return await llmPromptsNameRetrieve( + String(ApiConfig.getCurrentTeamId()), + props.promptName, + viewedVersion + ) + } catch (error) { + lemonToast.error(getApiErrorDetail(error) ?? 'Could not resolve this prompt. Try again.') + return null + } + }, + }, + })), + forms(({ actions, props, values }) => ({ promptForm: { defaults: DEFAULT_PROMPT_FORM_VALUES, @@ -1191,6 +1242,11 @@ export const llmPromptLogic = kea([ }), listeners(({ actions, asyncActions, props, values }) => ({ + toggleResolvedPreview: () => { + if (values.isShowingResolvedPreview) { + actions.loadResolvedPreview() + } + }, requestSetLabel: ({ labelName, version }) => { const existing = values.promptLabels.find((label) => label.name === labelName) if (existing?.version === version) { diff --git a/products/ai_observability/frontend/prompts/promptReferences.ts b/products/ai_observability/frontend/prompts/promptReferences.ts new file mode 100644 index 000000000000..5c31e4493cc9 --- /dev/null +++ b/products/ai_observability/frontend/prompts/promptReferences.ts @@ -0,0 +1,34 @@ +// Mirrors the tag grammar in products/ai_observability/backend/prompt_references.py; +// the backend is authoritative, keep the two in sync. +export const PROMPT_REFERENCE_REGEX = + /@@@prompt:name=([A-Za-z0-9_-]{1,255})\|(?:version=([1-9][0-9]{0,8})|label=([a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?))@@@/g + +export interface PromptReferenceTag { + raw: string + name: string + version: number | null + label: string | null +} + +export function extractPromptReferences(text: string): PromptReferenceTag[] { + const seen = new Set() + const references: PromptReferenceTag[] = [] + for (const match of text.matchAll(PROMPT_REFERENCE_REGEX)) { + if (seen.has(match[0])) { + continue + } + seen.add(match[0]) + references.push({ + raw: match[0], + name: match[1], + version: match[2] ? parseInt(match[2], 10) : null, + label: match[3] ?? null, + }) + } + return references +} + +export function buildPromptReferenceTag(name: string, selector: { version: number } | { label: string }): string { + const selectorPart = 'version' in selector ? `version=${selector.version}` : `label=${selector.label}` + return `@@@prompt:name=${name}|${selectorPart}@@@` +} diff --git a/products/ai_observability/frontend/prompts/promptSceneComponents.tsx b/products/ai_observability/frontend/prompts/promptSceneComponents.tsx index 88bd912be322..4fffb6cc35bd 100644 --- a/products/ai_observability/frontend/prompts/promptSceneComponents.tsx +++ b/products/ai_observability/frontend/prompts/promptSceneComponents.tsx @@ -38,10 +38,13 @@ import { useTracesQueryContext } from '../AIObservabilityTracesScene' import { MarkdownOutline } from '../components/MarkdownOutline' import { CreatePromptExperimentModal } from './CreatePromptExperimentModal' import { createPromptExperimentModalLogic } from './createPromptExperimentModalLogic' +import { InsertPromptReferenceButton } from './InsertPromptReferenceButton' import { PromptAnalyticsScope, formatPromptConfig, isPrompt, llmPromptLogic } from './llmPromptLogic' import { promptExperimentsLogic } from './promptExperimentsLogic' import { PromptLabelChip } from './PromptLabelChip' import { PromptLabelPicker } from './PromptLabelPicker' +import { extractPromptReferences } from './promptReferences' +import { PromptReferenceTags } from './PromptReferenceTags' import { LLMPrompt, LLMPromptVersionSummary } from './types' import { PROMPT_NAME_MAX_LENGTH } from './utils' @@ -74,9 +77,17 @@ function PromptOutline({ } export function PromptViewDetails(): JSX.Element { - const { prompt, isRenderingMarkdown, isDiffVisible, canCompareVersions, compareVersionOptions } = - useValues(llmPromptLogic) - const { toggleMarkdownRendering, setCompareVersion } = useActions(llmPromptLogic) + const { + prompt, + isRenderingMarkdown, + isDiffVisible, + canCompareVersions, + compareVersionOptions, + isShowingResolvedPreview, + resolvedPreview, + resolvedPreviewLoading, + } = useValues(llmPromptLogic) + const { toggleMarkdownRendering, setCompareVersion, toggleResolvedPreview } = useActions(llmPromptLogic) const markdownContainerRef = useRef(null) if (!prompt || !isPrompt(prompt)) { @@ -86,6 +97,10 @@ export function PromptViewDetails(): JSX.Element { const configJson = prompt.config != null ? formatPromptConfig(prompt.config) : null const promptText = prompt.prompt + const references = extractPromptReferences(promptText) + const resolvedText = + isShowingResolvedPreview && typeof resolvedPreview?.prompt === 'string' ? resolvedPreview.prompt : null + const displayText = resolvedText ?? promptText const variableMatches = promptText.match(/\{\{([^}]+)\}\}/g) const variables = variableMatches ? [...new Set(variableMatches.map((match: string) => match.slice(2, -2).trim()))] @@ -97,6 +112,18 @@ export function PromptViewDetails(): JSX.Element {

Prompt

+ {!isDiffVisible && references.length > 0 && ( + + Show resolved + + )} {!isDiffVisible && ( ) : isRenderingMarkdown ? ( <> - +
- {promptText} + {displayText}
) : ( -
{prompt.prompt}
+
{displayText}
)}
+ {resolvedText !== null && (resolvedPreview?.resolved_references?.length ?? 0) > 0 && ( +
+ Includes: + {resolvedPreview?.resolved_references?.map((reference) => ( + + {reference.name} v{reference.version} + {reference.label ? ` (via ${reference.label})` : ''} + + ))} +
+ )} + + {!isShowingResolvedPreview && } + {variables.length > 0 && (
Variables: @@ -905,7 +946,16 @@ export function PromptEditForm({ selectedVersion: number | null }): JSX.Element { const { promptVariables, isNewPrompt, isRenderingMarkdown, promptForm, publishConflict } = useValues(llmPromptLogic) - const { toggleMarkdownRendering } = useActions(llmPromptLogic) + const { toggleMarkdownRendering, setPromptFormValue } = useActions(llmPromptLogic) + const promptTextAreaRef = useRef(null) + + const insertReferenceTag = (tag: string): void => { + const textarea = promptTextAreaRef.current + const current = promptForm.prompt || '' + const start = textarea?.selectionStart ?? current.length + const end = textarea?.selectionEnd ?? current.length + setPromptFormValue('prompt', current.slice(0, start) + tag + current.slice(end)) + } return (
@@ -957,6 +1007,12 @@ export function PromptEditForm({ toggleMarkdownRendering() }} /> + {!isRenderingMarkdown && ( + + )}
} help="Use {{variable_name}} to define variables that will be replaced when fetching the prompt from your backend." @@ -967,6 +1023,7 @@ export function PromptEditForm({ ) : ( )} + +
) From 551cbe763efd6873faad9ab0a22d1f1472c3f3ca Mon Sep 17 00:00:00 2001 From: Juraj Majerik Date: Thu, 17 Sep 2026 17:14:08 +0200 Subject: [PATCH 2/7] fix(prompts): search the reference picker server-side and harden preview states --- .../prompts/InsertPromptReferenceButton.tsx | 85 +++--------------- .../prompts/PromptReferencePicker.tsx | 89 +++++++++++++++++++ .../prompts/insertPromptReferenceLogic.ts | 13 ++- .../frontend/prompts/llmPromptLogic.ts | 28 +++--- 4 files changed, 125 insertions(+), 90 deletions(-) create mode 100644 products/ai_observability/frontend/prompts/PromptReferencePicker.tsx diff --git a/products/ai_observability/frontend/prompts/InsertPromptReferenceButton.tsx b/products/ai_observability/frontend/prompts/InsertPromptReferenceButton.tsx index b6589da69a72..0f9eea92815b 100644 --- a/products/ai_observability/frontend/prompts/InsertPromptReferenceButton.tsx +++ b/products/ai_observability/frontend/prompts/InsertPromptReferenceButton.tsx @@ -4,16 +4,8 @@ import { useState } from 'react' import { IconPlusSmall } from '@posthog/icons' import { LemonButton, LemonModal } from '@posthog/lemon-ui' -import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from 'lib/ui/quill' - import { insertPromptReferenceLogic } from './insertPromptReferenceLogic' -import { buildPromptReferenceTag } from './promptReferences' - -interface ReferenceOption { - tag: string - promptName: string - selectorLabel: string -} +import { PromptReferencePicker } from './PromptReferencePicker' export function InsertPromptReferenceButton({ currentPromptName, @@ -22,45 +14,25 @@ export function InsertPromptReferenceButton({ currentPromptName: string onInsert: (tag: string) => void }): JSX.Element { - const { promptOptions, promptOptionsLoading } = useValues(insertPromptReferenceLogic) + const { promptOptionsLoading } = useValues(insertPromptReferenceLogic) const { loadPromptOptions } = useActions(insertPromptReferenceLogic) const [isOpen, setIsOpen] = useState(false) - const [inputValue, setInputValue] = useState('') - - // Keyed by a human-readable string so the combobox's text filtering - // matches what the item displays. - const optionsByKey = new Map() - for (const prompt of promptOptions) { - if (prompt.name === currentPromptName) { - continue - } - for (const label of prompt.all_labels) { - optionsByKey.set(`${prompt.name} @ ${label.name}`, { - tag: buildPromptReferenceTag(prompt.name, { label: label.name }), - promptName: prompt.name, - selectorLabel: `@ ${label.name} (follows the label)`, - }) - } - optionsByKey.set(`${prompt.name} @ v${prompt.latest_version}`, { - tag: buildPromptReferenceTag(prompt.name, { version: prompt.latest_version }), - promptName: prompt.name, - selectorLabel: `@ v${prompt.latest_version} (pinned)`, - }) - } - const items = [...optionsByKey.keys()] return ( <> } + loading={!isOpen && promptOptionsLoading} tooltip="Insert a reference to another prompt. Its content replaces the tag when the prompt is fetched." data-attr="llma-prompt-insert-reference-button" onClick={(e) => { e.preventDefault() + if (promptOptionsLoading) { + return + } setIsOpen(true) - setInputValue('') - loadPromptOptions() + loadPromptOptions('') }} > Insert reference @@ -72,44 +44,13 @@ export function InsertPromptReferenceButton({ description="Reference a label to pick up its future moves, or pin a version to freeze the content." width={480} > - { - const option = next ? optionsByKey.get(next) : undefined - if (option) { - onInsert(option.tag) - setIsOpen(false) - } + { + onInsert(tag) + setIsOpen(false) }} - inputValue={inputValue} - onInputValueChange={(value: string) => setInputValue(value)} - defaultOpen - > - - - - {promptOptionsLoading ? 'Loading prompts...' : 'No matching prompts'} - - - {(item: string) => { - const option = optionsByKey.get(item) - return ( - - {option?.promptName} - {option?.selectorLabel} - - ) - }} - - - + /> ) diff --git a/products/ai_observability/frontend/prompts/PromptReferencePicker.tsx b/products/ai_observability/frontend/prompts/PromptReferencePicker.tsx new file mode 100644 index 000000000000..af588ef35525 --- /dev/null +++ b/products/ai_observability/frontend/prompts/PromptReferencePicker.tsx @@ -0,0 +1,89 @@ +import { useActions, useValues } from 'kea' +import { useState } from 'react' + +import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from 'lib/ui/quill' + +import { insertPromptReferenceLogic } from './insertPromptReferenceLogic' +import { buildPromptReferenceTag } from './promptReferences' + +interface ReferenceOption { + tag: string + promptName: string + selectorLabel: string +} + +export function PromptReferencePicker({ + currentPromptName, + onSelect, +}: { + currentPromptName: string + onSelect: (tag: string) => void +}): JSX.Element { + const { promptOptions, promptOptionsLoading } = useValues(insertPromptReferenceLogic) + const { loadPromptOptions } = useActions(insertPromptReferenceLogic) + const [inputValue, setInputValue] = useState('') + + // Keys double as display strings so the combobox's text filtering matches + // what the item shows. The "(pinned)" suffix cannot collide with a label + // row: label names allow no spaces or parentheses. + const optionsByKey = new Map() + for (const prompt of promptOptions) { + if (prompt.name === currentPromptName) { + continue + } + for (const label of prompt.all_labels) { + optionsByKey.set(`${prompt.name} @ ${label.name}`, { + tag: buildPromptReferenceTag(prompt.name, { label: label.name }), + promptName: prompt.name, + selectorLabel: `@ ${label.name} (follows the label)`, + }) + } + optionsByKey.set(`${prompt.name} @ v${prompt.latest_version} (pinned)`, { + tag: buildPromptReferenceTag(prompt.name, { version: prompt.latest_version }), + promptName: prompt.name, + selectorLabel: `@ v${prompt.latest_version} (pinned)`, + }) + } + const items = [...optionsByKey.keys()] + + return ( + { + const option = next ? optionsByKey.get(next) : undefined + if (option) { + onSelect(option.tag) + } + }} + inputValue={inputValue} + onInputValueChange={(value: string) => { + setInputValue(value) + loadPromptOptions(value) + }} + defaultOpen + > + + + {promptOptionsLoading ? 'Loading prompts...' : 'No matching prompts'} + + {(item: string) => { + const option = optionsByKey.get(item) + return ( + + {option?.promptName} + {option?.selectorLabel} + + ) + }} + + + + ) +} diff --git a/products/ai_observability/frontend/prompts/insertPromptReferenceLogic.ts b/products/ai_observability/frontend/prompts/insertPromptReferenceLogic.ts index 81fc52e1b585..fc14ea048462 100644 --- a/products/ai_observability/frontend/prompts/insertPromptReferenceLogic.ts +++ b/products/ai_observability/frontend/prompts/insertPromptReferenceLogic.ts @@ -14,7 +14,7 @@ export interface insertPromptReferenceLogicValues { // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface insertPromptReferenceLogicActions { - loadPromptOptions: () => any + loadPromptOptions: (search: string) => string loadPromptOptionsFailure: ( error: string, errorObject?: any @@ -24,10 +24,10 @@ export interface insertPromptReferenceLogicActions { } loadPromptOptionsSuccess: ( promptOptions: LLMPromptListApi[], - payload?: any + payload?: string ) => { promptOptions: LLMPromptListApi[] - payload?: any + payload?: string } } @@ -42,12 +42,17 @@ export const insertPromptReferenceLogic = kea([ promptOptions: [ [] as LLMPromptListApi[], { - loadPromptOptions: async () => { + loadPromptOptions: async (search: string, breakpoint) => { + if (search) { + await breakpoint(250) + } const response = await llmPromptsList(String(ApiConfig.getCurrentTeamId()), { content: 'none', order_by: 'name', limit: 100, + ...(search ? { search } : {}), }) + breakpoint() return response.results }, }, diff --git a/products/ai_observability/frontend/prompts/llmPromptLogic.ts b/products/ai_observability/frontend/prompts/llmPromptLogic.ts index 7be3b5237d1c..1bb93970ed38 100644 --- a/products/ai_observability/frontend/prompts/llmPromptLogic.ts +++ b/products/ai_observability/frontend/prompts/llmPromptLogic.ts @@ -334,10 +334,10 @@ export interface llmPromptLogicActions { errorObject?: any } loadResolvedPreviewSuccess: ( - resolvedPreview: LLMPromptPublicApi | null, + resolvedPreview: LLMPromptPublicApi, payload?: any ) => { - resolvedPreview: LLMPromptPublicApi | null + resolvedPreview: LLMPromptPublicApi payload?: any } openLabelPicker: (version: number) => { @@ -604,6 +604,8 @@ export const llmPromptLogic = kea([ toggleResolvedPreview: (state: boolean) => !state, setMode: () => false, loadPromptSuccess: () => false, + // A failed resolution must not present the raw source as resolved content. + loadResolvedPreviewFailure: () => false, }, ], isRenderingMarkdown: [ @@ -713,18 +715,13 @@ export const llmPromptLogic = kea([ resolvedPreview: { __default: null as LLMPromptPublicApi | null, loadResolvedPreview: async () => { - try { - const viewedVersion = - values.prompt && isPrompt(values.prompt) ? { version: values.prompt.version } : undefined - return await llmPromptsNameRetrieve( - String(ApiConfig.getCurrentTeamId()), - props.promptName, - viewedVersion - ) - } catch (error) { - lemonToast.error(getApiErrorDetail(error) ?? 'Could not resolve this prompt. Try again.') - return null - } + const viewedVersion = + values.prompt && isPrompt(values.prompt) ? { version: values.prompt.version } : undefined + return await llmPromptsNameRetrieve( + String(ApiConfig.getCurrentTeamId()), + props.promptName, + viewedVersion + ) }, }, })), @@ -1247,6 +1244,9 @@ export const llmPromptLogic = kea([ actions.loadResolvedPreview() } }, + loadResolvedPreviewFailure: ({ errorObject }) => { + lemonToast.error(getApiErrorDetail(errorObject) ?? 'Could not resolve this prompt. Try again.') + }, requestSetLabel: ({ labelName, version }) => { const existing = values.promptLabels.find((label) => label.name === labelName) if (existing?.version === version) { From 18339f4eac32bb3bd3a098ae9b00037c14a7f5d0 Mon Sep 17 00:00:00 2001 From: Juraj Majerik Date: Thu, 17 Sep 2026 17:58:12 +0200 Subject: [PATCH 3/7] fix(prompts): resolve preview against the url version and clear it on navigation --- .../frontend/prompts/llmPromptLogic.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/products/ai_observability/frontend/prompts/llmPromptLogic.ts b/products/ai_observability/frontend/prompts/llmPromptLogic.ts index 1bb93970ed38..c46d8a4b4113 100644 --- a/products/ai_observability/frontend/prompts/llmPromptLogic.ts +++ b/products/ai_observability/frontend/prompts/llmPromptLogic.ts @@ -598,6 +598,12 @@ export const llmPromptLogic = kea([ setRelatedTracesQuery: (_, { query }) => query, }, ], + // Cleared on navigation so a stale resolution is never shown for the + // newly selected version while its own load is in flight. + resolvedPreview: { + loadPromptSuccess: () => null, + setMode: () => null, + }, isShowingResolvedPreview: [ false, { @@ -711,16 +717,17 @@ export const llmPromptLogic = kea([ }, })), - loaders(({ props, values }) => ({ + loaders(({ props }) => ({ resolvedPreview: { __default: null as LLMPromptPublicApi | null, loadResolvedPreview: async () => { - const viewedVersion = - values.prompt && isPrompt(values.prompt) ? { version: values.prompt.version } : undefined + // Version from the router, like loadPrompt: values.prompt still holds + // the previous version while a back/forward navigation is loading. + const urlVersion = getSelectedVersionFromUrl() return await llmPromptsNameRetrieve( String(ApiConfig.getCurrentTeamId()), props.promptName, - viewedVersion + urlVersion !== undefined ? { version: urlVersion } : undefined ) }, }, From d19d87f3e8315f50ede057c9afb420e30c1a38d7 Mon Sep 17 00:00:00 2001 From: Juraj Majerik Date: Thu, 17 Sep 2026 17:59:36 +0200 Subject: [PATCH 4/7] fix(prompts): drop the empty-render story the smoke test cannot screenshot --- .../frontend/prompts/PromptReferenceTags.stories.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/products/ai_observability/frontend/prompts/PromptReferenceTags.stories.tsx b/products/ai_observability/frontend/prompts/PromptReferenceTags.stories.tsx index 1b6e0fd46452..31adafc4ba87 100644 --- a/products/ai_observability/frontend/prompts/PromptReferenceTags.stories.tsx +++ b/products/ai_observability/frontend/prompts/PromptReferenceTags.stories.tsx @@ -15,9 +15,3 @@ export const LabelAndVersionReferences: Story = { text: 'Intro.\n@@@prompt:name=guardrails|label=production@@@\nMore text.\n@@@prompt:name=tone|version=3@@@', }, } - -export const NoReferences: Story = { - args: { - text: 'Plain prompt with {{variables}} only.', - }, -} From 648a782c3e6877969c26daf99207e4337b21c26a Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 05:06:34 +0000 Subject: [PATCH 5/7] chore(visual): update storybook baselines 2 updated Run: 66e0662c-c307-41ea-8902-9234a2dadce1 Co-authored-by: jurajmajerik <22996112+jurajmajerik@users.noreply.github.com> --- frontend/snapshots.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/snapshots.yml b/frontend/snapshots.yml index 7e80e882780e..2b39b50eea33 100644 --- a/frontend/snapshots.yml +++ b/frontend/snapshots.yml @@ -6796,6 +6796,10 @@ snapshots: hash: v1.k794b7964.6f9f209d5daaf8573020fb50615421d426055c8090d0ee73a2629487d30cbf29.stsWopH6BI50ASXjhDxCWn-Xa46oqy_cH4v0xAO4VIQ scenes-app-ai-observability-instrumentation-checklist-card--warnings-with-a-dismissal--light: hash: v1.k794b7964.b0e06944ee7afe4af8cb39363280135111e71ba215aab56a3e410c79de62238b.WtW8suUOWanaTrvjxhV2Z_ANVDRmEW3bqHpTrTe5GoI + scenes-app-ai-observability-promptreferencetags--label-and-version-references--dark: + hash: v1.k794b7964.4cc699d7489063a64cf4ec1f930d11fc062eb0d9c9e46150f939503945bb8841.jKmH2ZmjZex98fImM00HE_xBBayalWKHtqXbVlhE_VM + scenes-app-ai-observability-promptreferencetags--label-and-version-references--light: + hash: v1.k794b7964.b406b4c5f2cc872222ab4156f8179c50db3a873b1b75d332e826ea9dc37eaa12.BHAFV67Jt-fG7F3xlWVwu0Th-yvY_DepLdasKhNQA30 scenes-app-ai-observability-prompts--empty-state--dark: hash: v1.k794b7964.3881018cfbf85cf1beed0dd610d747267571381277a983c7b8239acc5d372b86.yIUKSXpWrhD6XV_uZ1Sk-kb3tJbbBLfdcYML7kWkw40 scenes-app-ai-observability-prompts--empty-state--light: From 5e46a9f131b0283524cc2821d3978d1241d7cfd3 Mon Sep 17 00:00:00 2001 From: Juraj Majerik Date: Fri, 18 Sep 2026 07:31:51 +0200 Subject: [PATCH 6/7] fix(prompts): extract variables from the displayed text --- .../ai_observability/frontend/prompts/promptSceneComponents.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/products/ai_observability/frontend/prompts/promptSceneComponents.tsx b/products/ai_observability/frontend/prompts/promptSceneComponents.tsx index 4fffb6cc35bd..8aaf1e59c924 100644 --- a/products/ai_observability/frontend/prompts/promptSceneComponents.tsx +++ b/products/ai_observability/frontend/prompts/promptSceneComponents.tsx @@ -101,7 +101,7 @@ export function PromptViewDetails(): JSX.Element { const resolvedText = isShowingResolvedPreview && typeof resolvedPreview?.prompt === 'string' ? resolvedPreview.prompt : null const displayText = resolvedText ?? promptText - const variableMatches = promptText.match(/\{\{([^}]+)\}\}/g) + const variableMatches = displayText.match(/\{\{([^}]+)\}\}/g) const variables = variableMatches ? [...new Set(variableMatches.map((match: string) => match.slice(2, -2).trim()))] : [] From 7ddacf8ed32042cfc4b63ac9d075e382dd38ed1b Mon Sep 17 00:00:00 2001 From: Juraj Majerik Date: Fri, 18 Sep 2026 10:00:17 +0200 Subject: [PATCH 7/7] fix(prompts): clear resolved preview when any prompt load starts --- .../frontend/prompts/llmPromptLogic.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/products/ai_observability/frontend/prompts/llmPromptLogic.ts b/products/ai_observability/frontend/prompts/llmPromptLogic.ts index c46d8a4b4113..221fc2dc23f4 100644 --- a/products/ai_observability/frontend/prompts/llmPromptLogic.ts +++ b/products/ai_observability/frontend/prompts/llmPromptLogic.ts @@ -598,18 +598,20 @@ export const llmPromptLogic = kea([ setRelatedTracesQuery: (_, { query }) => query, }, ], - // Cleared on navigation so a stale resolution is never shown for the - // newly selected version while its own load is in flight. + // Cleared the moment a prompt load starts: every path to different + // content (navigation, version switch, refresh, failure) begins with + // loadPrompt, so clearing on the trigger closes the whole class of + // stale-preview states instead of patching individual outcomes. resolvedPreview: { - loadPromptSuccess: () => null, + loadPrompt: () => null, setMode: () => null, }, isShowingResolvedPreview: [ false, { toggleResolvedPreview: (state: boolean) => !state, + loadPrompt: () => false, setMode: () => false, - loadPromptSuccess: () => false, // A failed resolution must not present the raw source as resolved content. loadResolvedPreviewFailure: () => false, },