Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions static/app/views/seerExplorer/blockComponents.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {useEffect, useRef, useState} from 'react';
import {useEffect, useMemo, useRef, useState} from 'react';
import styled from '@emotion/styled';
import {AnimatePresence, motion} from 'framer-motion';

Expand All @@ -14,7 +14,7 @@ import useOrganization from 'sentry/utils/useOrganization';
import useProjects from 'sentry/utils/useProjects';

import type {Block} from './types';
import {buildToolLinkUrl, getToolsStringFromBlock} from './utils';
import {buildToolLinkUrl, getToolsStringFromBlock, postProcessLLMMarkdown} from './utils';

interface BlockProps {
block: Block;
Expand Down Expand Up @@ -111,6 +111,11 @@ function BlockComponent({
const hasTools = toolsUsed.length > 0;
const hasContent = hasValidContent(block.message.content);

const processedContent = useMemo(
() => postProcessLLMMarkdown(block.message.content),
[block.message.content]
);

// State to track selected tool link (for navigation)
const [selectedLinkIndex, setSelectedLinkIndex] = useState(0);
const selectedLinkIndexRef = useRef(selectedLinkIndex);
Expand Down Expand Up @@ -261,7 +266,24 @@ function BlockComponent({
hasOnlyTools={!hasContent && hasTools}
/>
<BlockContentWrapper hasOnlyTools={!hasContent && hasTools}>
{hasContent && <BlockContent text={block.message.content} />}
{hasContent && (
<BlockContent
text={processedContent}
onClick={(e: React.MouseEvent<HTMLDivElement>) => {
// Intercept clicks on links to use client-side navigation
const anchor = (e.target as HTMLElement).closest('a');
if (anchor) {
e.preventDefault();
e.stopPropagation();
const href = anchor.getAttribute('href');
if (href?.startsWith('/')) {
navigate(href);
onNavigate?.();
}
}
}}
/>
)}
{hasTools && (
<ToolCallStack gap="md">
{block.message.tool_calls?.map((toolCall, idx) => {
Expand Down
75 changes: 75 additions & 0 deletions static/app/views/seerExplorer/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,81 @@ export function getToolsStringFromBlock(block: Block): string[] {
return tools;
}

/**
* Converts issue short IDs in text to markdown links.
* Examples: INTERNAL-4K, JAVASCRIPT-2SDJ, PROJECT-1
* Excludes IDs that are already inside markdown code blocks, links, or URLs.
*/
function linkifyIssueShortIds(text: string): string {
// Pattern matches: PROJECT_SLUG-SHORT_ID (uppercase only, case-sensitive)
// Requires at least 2 chars before hyphen and 1+ chars after
const shortIdPattern = /\b([A-Z0-9_]{2,}-[A-Z0-9]+)\b/g;

// Track positions that should be excluded (inside code blocks, links, or URLs)
const excludedRanges: Array<{end: number; start: number}> = [];

// Find all markdown code blocks (inline and block)
const codeBlockPattern = /(`+)([^`]+)\1|```[\s\S]*?```/g;
for (const codeMatch of text.matchAll(codeBlockPattern)) {
excludedRanges.push({
end: codeMatch.index + codeMatch[0].length,
start: codeMatch.index,
});
}
// Find all markdown links [text](url)
const markdownLinkPattern = /\[([^\]]+)\]\(([^)]+)\)/g;
for (const linkMatch of text.matchAll(markdownLinkPattern)) {
excludedRanges.push({
end: linkMatch.index + linkMatch[0].length,
start: linkMatch.index,
});
}
// Find all URLs (http://, https://, or starting with /)
const urlPattern = /(https?:\/\/[^\s]+|\/[^\s)]+)/g;
for (const urlMatch of text.matchAll(urlPattern)) {
excludedRanges.push({
end: urlMatch.index + urlMatch[0].length,
start: urlMatch.index,
});
}

// Sort ranges by start position for efficient checking
excludedRanges.sort((a, b) => a.start - b.start);

// Helper function to check if a position is within any excluded range
const isExcluded = (pos: number): boolean => {
return excludedRanges.some(range => pos >= range.start && pos < range.end);
};

// Replace matches, but skip those in excluded ranges
return text.replace(shortIdPattern, (idMatch, _content, offset) => {
if (isExcluded(offset)) {
return idMatch;
}
return `[${idMatch}](/issues/${idMatch}/)`;
});
}

/**
* Post-processes markdown text from LLM responses.
* Applies various transformations to enhance the text with links and formatting.
* Add new processing rules to this function as needed.
*/
export function postProcessLLMMarkdown(text: string | null | undefined): string {
if (!text) {
return '';
}

let processed = text;

// Convert issue short IDs to clickable links
processed = linkifyIssueShortIds(processed);

// Add more processing rules here as needed

return processed;
}

/**
* Build a URL/LocationDescriptor for a tool link based on its kind and params
*/
Expand Down
Loading