mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 13:03:46 +00:00
feat(inspector): markdown support and image update (#181)
This commit is contained in:
parent
e134012955
commit
a897fbcb7c
6 changed files with 314 additions and 10 deletions
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.7 MiB |
|
|
@ -1970,6 +1970,84 @@
|
|||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.markdown-body p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.markdown-body p + p {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.markdown-body h1,
|
||||
.markdown-body h2,
|
||||
.markdown-body h3,
|
||||
.markdown-body h4,
|
||||
.markdown-body h5,
|
||||
.markdown-body h6 {
|
||||
margin: 0 0 8px;
|
||||
line-height: 1.35;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.markdown-body h1 { font-size: 1.35em; }
|
||||
.markdown-body h2 { font-size: 1.22em; }
|
||||
.markdown-body h3 { font-size: 1.12em; }
|
||||
.markdown-body h4,
|
||||
.markdown-body h5,
|
||||
.markdown-body h6 { font-size: 1em; }
|
||||
|
||||
.markdown-body ul,
|
||||
.markdown-body ol {
|
||||
margin: 8px 0 0 18px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.markdown-body li + li {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.markdown-body blockquote {
|
||||
margin: 8px 0 0;
|
||||
padding: 6px 10px;
|
||||
border-left: 3px solid var(--border-2);
|
||||
background: var(--surface-2);
|
||||
color: var(--muted);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.markdown-body code {
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace;
|
||||
font-size: 0.9em;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid var(--border-2);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
|
||||
.message.user .markdown-body code {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
border-color: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.markdown-body .md-pre {
|
||||
margin: 8px 0 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.markdown-body .md-pre code {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.message-error {
|
||||
background: rgba(255, 59, 48, 0.1);
|
||||
border: 1px solid rgba(255, 59, 48, 0.3);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useState } from "react";
|
|||
import { getMessageClass } from "./messageUtils";
|
||||
import type { TimelineEntry } from "./types";
|
||||
import { AlertTriangle, ChevronRight, ChevronDown, Wrench, Brain, Info, ExternalLink, PlayCircle } from "lucide-react";
|
||||
import MarkdownText from "./MarkdownText";
|
||||
|
||||
const ToolItem = ({
|
||||
entry,
|
||||
|
|
@ -253,7 +254,7 @@ const ChatMessages = ({
|
|||
<div key={entry.id} className={`message ${messageClass} no-avatar`}>
|
||||
<div className="message-content">
|
||||
{entry.text ? (
|
||||
<div className="part-body">{entry.text}</div>
|
||||
<MarkdownText text={entry.text} />
|
||||
) : (
|
||||
<span className="thinking-indicator">
|
||||
<span className="thinking-dot" />
|
||||
|
|
|
|||
206
frontend/packages/inspector/src/components/chat/MarkdownText.tsx
Normal file
206
frontend/packages/inspector/src/components/chat/MarkdownText.tsx
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import type { ReactNode } from "react";
|
||||
|
||||
const SAFE_URL_RE = /^(https?:\/\/|mailto:)/i;
|
||||
|
||||
const isSafeUrl = (url: string): boolean => SAFE_URL_RE.test(url.trim());
|
||||
|
||||
const inlineTokenRe = /(`[^`\n]+`|\[[^\]\n]+\]\(([^)\s]+)(?:\s+"[^"]*")?\)|\*\*[^*\n]+\*\*|__[^_\n]+__|\*[^*\n]+\*|_[^_\n]+_|~~[^~\n]+~~)/g;
|
||||
|
||||
const parseInline = (text: string, keyPrefix: string): ReactNode[] => {
|
||||
const out: ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let tokenIndex = 0;
|
||||
|
||||
for (const match of text.matchAll(inlineTokenRe)) {
|
||||
const token = match[0];
|
||||
const idx = match.index ?? 0;
|
||||
|
||||
if (idx > lastIndex) {
|
||||
out.push(text.slice(lastIndex, idx));
|
||||
}
|
||||
|
||||
const key = `${keyPrefix}-t-${tokenIndex++}`;
|
||||
|
||||
if (token.startsWith("`") && token.endsWith("`")) {
|
||||
out.push(<code key={key}>{token.slice(1, -1)}</code>);
|
||||
} else if (token.startsWith("**") && token.endsWith("**")) {
|
||||
out.push(<strong key={key}>{token.slice(2, -2)}</strong>);
|
||||
} else if (token.startsWith("__") && token.endsWith("__")) {
|
||||
out.push(<strong key={key}>{token.slice(2, -2)}</strong>);
|
||||
} else if (token.startsWith("*") && token.endsWith("*")) {
|
||||
out.push(<em key={key}>{token.slice(1, -1)}</em>);
|
||||
} else if (token.startsWith("_") && token.endsWith("_")) {
|
||||
out.push(<em key={key}>{token.slice(1, -1)}</em>);
|
||||
} else if (token.startsWith("~~") && token.endsWith("~~")) {
|
||||
out.push(<del key={key}>{token.slice(2, -2)}</del>);
|
||||
} else if (token.startsWith("[") && token.includes("](") && token.endsWith(")")) {
|
||||
const linkMatch = token.match(/^\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)$/);
|
||||
if (!linkMatch) {
|
||||
out.push(token);
|
||||
} else {
|
||||
const label = linkMatch[1];
|
||||
const href = linkMatch[2];
|
||||
if (isSafeUrl(href)) {
|
||||
out.push(
|
||||
<a key={key} href={href} target="_blank" rel="noreferrer">
|
||||
{label}
|
||||
</a>,
|
||||
);
|
||||
} else {
|
||||
out.push(label);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
out.push(token);
|
||||
}
|
||||
|
||||
lastIndex = idx + token.length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
out.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return out;
|
||||
};
|
||||
|
||||
const renderInlineLines = (text: string, keyPrefix: string): ReactNode[] => {
|
||||
const lines = text.split("\n");
|
||||
const out: ReactNode[] = [];
|
||||
lines.forEach((line, idx) => {
|
||||
if (idx > 0) out.push(<br key={`${keyPrefix}-br-${idx}`} />);
|
||||
out.push(...parseInline(line, `${keyPrefix}-l-${idx}`));
|
||||
});
|
||||
return out;
|
||||
};
|
||||
|
||||
const isUnorderedListItem = (line: string): boolean => /^\s*[-*+]\s+/.test(line);
|
||||
const isOrderedListItem = (line: string): boolean => /^\s*\d+\.\s+/.test(line);
|
||||
|
||||
const MarkdownText = ({ text }: { text: string }) => {
|
||||
const source = text.replace(/\r\n?/g, "\n");
|
||||
const lines = source.split("\n");
|
||||
const nodes: ReactNode[] = [];
|
||||
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("```")) {
|
||||
const lang = trimmed.slice(3).trim();
|
||||
const codeLines: string[] = [];
|
||||
i += 1;
|
||||
while (i < lines.length && !lines[i].trim().startsWith("```")) {
|
||||
codeLines.push(lines[i]);
|
||||
i += 1;
|
||||
}
|
||||
if (i < lines.length && lines[i].trim().startsWith("```")) i += 1;
|
||||
nodes.push(
|
||||
<pre key={`code-${nodes.length}`} className="md-pre">
|
||||
<code className={lang ? `language-${lang}` : undefined}>{codeLines.join("\n")}</code>
|
||||
</pre>,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (headingMatch) {
|
||||
const level = headingMatch[1].length;
|
||||
const content = headingMatch[2];
|
||||
const key = `h-${nodes.length}`;
|
||||
if (level === 1) nodes.push(<h1 key={key}>{renderInlineLines(content, key)}</h1>);
|
||||
else if (level === 2) nodes.push(<h2 key={key}>{renderInlineLines(content, key)}</h2>);
|
||||
else if (level === 3) nodes.push(<h3 key={key}>{renderInlineLines(content, key)}</h3>);
|
||||
else if (level === 4) nodes.push(<h4 key={key}>{renderInlineLines(content, key)}</h4>);
|
||||
else if (level === 5) nodes.push(<h5 key={key}>{renderInlineLines(content, key)}</h5>);
|
||||
else nodes.push(<h6 key={key}>{renderInlineLines(content, key)}</h6>);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith(">")) {
|
||||
const quoteLines: string[] = [];
|
||||
while (i < lines.length && lines[i].trim().startsWith(">")) {
|
||||
quoteLines.push(lines[i].trim().replace(/^>\s?/, ""));
|
||||
i += 1;
|
||||
}
|
||||
const content = quoteLines.join("\n");
|
||||
const key = `q-${nodes.length}`;
|
||||
nodes.push(<blockquote key={key}>{renderInlineLines(content, key)}</blockquote>);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isUnorderedListItem(line) || isOrderedListItem(line)) {
|
||||
const ordered = isOrderedListItem(line);
|
||||
const items: string[] = [];
|
||||
while (i < lines.length) {
|
||||
const candidate = lines[i];
|
||||
if (ordered && isOrderedListItem(candidate)) {
|
||||
items.push(candidate.replace(/^\s*\d+\.\s+/, ""));
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (!ordered && isUnorderedListItem(candidate)) {
|
||||
items.push(candidate.replace(/^\s*[-*+]\s+/, ""));
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (!candidate.trim()) {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
const key = `list-${nodes.length}`;
|
||||
if (ordered) {
|
||||
nodes.push(
|
||||
<ol key={key}>
|
||||
{items.map((item, idx) => (
|
||||
<li key={`${key}-i-${idx}`}>{renderInlineLines(item, `${key}-i-${idx}`)}</li>
|
||||
))}
|
||||
</ol>,
|
||||
);
|
||||
} else {
|
||||
nodes.push(
|
||||
<ul key={key}>
|
||||
{items.map((item, idx) => (
|
||||
<li key={`${key}-i-${idx}`}>{renderInlineLines(item, `${key}-i-${idx}`)}</li>
|
||||
))}
|
||||
</ul>,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const paragraphLines: string[] = [];
|
||||
while (i < lines.length) {
|
||||
const current = lines[i];
|
||||
const currentTrimmed = current.trim();
|
||||
if (!currentTrimmed) break;
|
||||
if (
|
||||
currentTrimmed.startsWith("```") ||
|
||||
currentTrimmed.startsWith(">") ||
|
||||
/^(#{1,6})\s+/.test(currentTrimmed) ||
|
||||
isUnorderedListItem(current) ||
|
||||
isOrderedListItem(current)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
paragraphLines.push(current);
|
||||
i += 1;
|
||||
}
|
||||
const content = paragraphLines.join("\n");
|
||||
const key = `p-${nodes.length}`;
|
||||
nodes.push(<p key={key}>{renderInlineLines(content, key)}</p>);
|
||||
}
|
||||
|
||||
return <div className="markdown-body">{nodes}</div>;
|
||||
};
|
||||
|
||||
export default MarkdownText;
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.7 MiB |
|
|
@ -154,6 +154,7 @@ function UniversalAPIDiagram() {
|
|||
const CopyInstallButton = () => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const installCommand = 'npx skills add rivet-dev/skills -s sandbox-agent';
|
||||
const shortCommand = 'npx skills add rivet-dev/skills';
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
|
|
@ -171,8 +172,9 @@ const CopyInstallButton = () => {
|
|||
onClick={handleCopy}
|
||||
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md border border-white/10 px-4 py-2 text-sm text-zinc-300 transition-colors hover:border-white/20 hover:text-white font-mono"
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4 text-green-400" /> : <Terminal className="h-4 w-4" />}
|
||||
{installCommand}
|
||||
{copied ? <Check className="h-4 w-4 text-green-400" /> : <Terminal className="h-4 w-4 flex-shrink-0" />}
|
||||
<span className="hidden sm:inline">{installCommand}</span>
|
||||
<span className="sm:hidden">{shortCommand}</span>
|
||||
</button>
|
||||
<div className="absolute left-1/2 -translate-x-1/2 top-full mt-3 opacity-0 translate-y-2 group-hover:opacity-100 group-hover:translate-y-0 transition-all duration-200 ease-out text-xs text-zinc-500 whitespace-nowrap pointer-events-none font-mono">
|
||||
Give this to your coding agent
|
||||
|
|
@ -183,32 +185,49 @@ const CopyInstallButton = () => {
|
|||
|
||||
export function Hero() {
|
||||
const [scrollOpacity, setScrollOpacity] = useState(1);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const updateViewportMode = () => {
|
||||
const mobile = window.innerWidth < 1024;
|
||||
setIsMobile(mobile);
|
||||
if (mobile) {
|
||||
setScrollOpacity(1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
if (window.innerWidth < 1024) {
|
||||
setScrollOpacity(1);
|
||||
return;
|
||||
}
|
||||
const scrollY = window.scrollY;
|
||||
const windowHeight = window.innerHeight;
|
||||
const isMobile = window.innerWidth < 1024;
|
||||
|
||||
const fadeStart = windowHeight * (isMobile ? 0.3 : 0.15);
|
||||
const fadeEnd = windowHeight * (isMobile ? 0.7 : 0.5);
|
||||
const fadeStart = windowHeight * 0.15;
|
||||
const fadeEnd = windowHeight * 0.5;
|
||||
const opacity = 1 - Math.min(1, Math.max(0, (scrollY - fadeStart) / (fadeEnd - fadeStart)));
|
||||
setScrollOpacity(opacity);
|
||||
};
|
||||
|
||||
updateViewportMode();
|
||||
handleScroll();
|
||||
window.addEventListener('resize', updateViewportMode);
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateViewportMode);
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="relative flex min-h-screen flex-col overflow-hidden">
|
||||
<section className="relative flex min-h-screen flex-col overflow-hidden pb-24 lg:pb-0">
|
||||
{/* Background gradient */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-zinc-900/20 via-transparent to-transparent pointer-events-none" />
|
||||
|
||||
{/* Main content */}
|
||||
<div
|
||||
className="flex flex-1 flex-col justify-start pt-32 lg:justify-center lg:pt-0 lg:pb-20 px-6"
|
||||
style={{ opacity: scrollOpacity, filter: `blur(${(1 - scrollOpacity) * 8}px)` }}
|
||||
style={isMobile ? undefined : { opacity: scrollOpacity, filter: `blur(${(1 - scrollOpacity) * 8}px)` }}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-7xl">
|
||||
<div className="flex flex-col gap-12 lg:flex-row lg:items-center lg:justify-between lg:gap-16 xl:gap-24">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue