Skip to content

Commit d234105

Browse files
committed
feat(web): pre-highlight session code server-side, reject show_code placeholders
1 parent 55fcbd1 commit d234105

File tree

7 files changed

+147
-33
lines changed

7 files changed

+147
-33
lines changed

apps/web/src/components/console/tabbed-code.tsx

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,28 +9,54 @@ export interface CodeTab {
99
lang: string;
1010
/** Code displayed in the block (may be masked) */
1111
code: string;
12+
/** Pre-rendered highlighted HTML. If set, no client highlighting runs. */
13+
html?: string;
1214
/** If set, CopyButton copies this instead of displayed code (for API key masking) */
1315
copyCode?: string;
1416
}
1517

1618
export function TabbedCode({ tabs }: { tabs: CodeTab[] }) {
1719
const [active, setActive] = useState(0);
18-
const [htmlCache, setHtmlCache] = useState<Record<number, string>>({});
1920

20-
const tab = tabs[active];
21+
// Seed cache with any pre-rendered html from props (server-rendered path).
22+
const [htmlCache, setHtmlCache] = useState<Record<number, string>>(() => {
23+
const seed: Record<number, string> = {};
24+
tabs.forEach((t, i) => {
25+
if (t.html) seed[i] = t.html;
26+
});
27+
return seed;
28+
});
2129

30+
// For tabs without pre-rendered html, batch-highlight all of them on mount
31+
// so tab switches are instant cache reads instead of per-click server hits.
32+
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed on tabs identity; htmlCache reads are a one-shot guard
2233
useEffect(() => {
23-
if (htmlCache[active]) return;
34+
const missing = tabs
35+
.map((t, i) => ({ t, i }))
36+
.filter(({ t, i }) => !t.html && htmlCache[i] === undefined);
37+
if (missing.length === 0) return;
38+
2439
let cancelled = false;
25-
highlightCode(tab.code, tab.lang).then((result) => {
26-
if (!cancelled) {
27-
setHtmlCache((prev) => ({ ...prev, [active]: result }));
28-
}
29-
});
40+
Promise.all(missing.map(({ t }) => highlightCode(t.code, t.lang))).then(
41+
(results) => {
42+
if (cancelled) return;
43+
setHtmlCache((prev) => {
44+
const next = { ...prev };
45+
missing.forEach(({ i }, idx) => {
46+
next[i] = results[idx];
47+
});
48+
return next;
49+
});
50+
},
51+
);
3052
return () => {
3153
cancelled = true;
3254
};
33-
}, [active, tab.code, tab.lang, htmlCache]);
55+
// Intentionally depend on tabs identity — re-highlights when tab set changes.
56+
}, [tabs]);
57+
58+
const tab = tabs[active];
59+
const activeHtml = htmlCache[active];
3460

3561
return (
3662
<div className="tabbed-code">
@@ -49,8 +75,8 @@ export function TabbedCode({ tabs }: { tabs: CodeTab[] }) {
4975
</div>
5076
<div className="tabbed-code-body">
5177
<CopyButton code={tab.copyCode ?? tab.code} />
52-
{htmlCache[active] ? (
53-
<div dangerouslySetInnerHTML={{ __html: htmlCache[active] }} />
78+
{activeHtml ? (
79+
<div dangerouslySetInnerHTML={{ __html: activeHtml }} />
5480
) : (
5581
<pre>
5682
<code>{tab.code}</code>

apps/web/src/components/sessions/message-list.tsx

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"use client";
22

3+
import { highlightCode } from "@/components/command-palette/actions";
34
import {
45
type ChatStatus,
56
type UIDataTypes,
@@ -9,7 +10,7 @@ import {
910
isTextUIPart,
1011
isToolUIPart,
1112
} from "ai";
12-
import { useEffect, useMemo, useRef } from "react";
13+
import { useEffect, useMemo, useRef, useState } from "react";
1314
import { ToolPartRenderer } from "./tool-part-renderer";
1415
import { SessionCodeBlock } from "./tool-parts/session-code-block";
1516
import { StepFlow, type StepInfo } from "./tool-parts/step-flow";
@@ -365,6 +366,37 @@ function parseTextWithCodeBlocks(
365366
function MessageTextContent({ text }: { text: string }) {
366367
const chunks = useMemo(() => parseTextWithCodeBlocks(text), [text]);
367368

369+
// Batch-highlight every code chunk in parallel once chunks stabilize.
370+
// Keyed by chunk index; cleared when chunks identity changes.
371+
const [htmlByIndex, setHtmlByIndex] = useState<Record<number, string>>({});
372+
373+
useEffect(() => {
374+
const codeChunks = chunks
375+
.map((c, i) => ({ c, i }))
376+
.filter(({ c }) => c.type === "code") as Array<{
377+
c: { type: "code"; code: string; lang: string };
378+
i: number;
379+
}>;
380+
if (codeChunks.length === 0) {
381+
setHtmlByIndex({});
382+
return;
383+
}
384+
let cancelled = false;
385+
Promise.all(codeChunks.map(({ c }) => highlightCode(c.code, c.lang))).then(
386+
(results) => {
387+
if (cancelled) return;
388+
const next: Record<number, string> = {};
389+
codeChunks.forEach(({ i }, idx) => {
390+
next[i] = results[idx];
391+
});
392+
setHtmlByIndex(next);
393+
},
394+
);
395+
return () => {
396+
cancelled = true;
397+
};
398+
}, [chunks]);
399+
368400
// No code blocks — fast path
369401
if (chunks.length === 1 && chunks[0].type === "prose") {
370402
return (
@@ -383,6 +415,7 @@ function MessageTextContent({ text }: { text: string }) {
383415
<SessionCodeBlock
384416
key={`code-${i}`}
385417
code={chunk.code}
418+
html={htmlByIndex[i]}
386419
lang={chunk.lang}
387420
/>
388421
);

apps/web/src/components/sessions/tool-part-renderer.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -223,12 +223,12 @@ function renderOutputCard(toolName: string, output: Record<string, unknown>) {
223223

224224
case "scaffold_subgraph": {
225225
if ((output as { error?: boolean }).error) return null;
226-
return (
227-
<CodeCard
228-
code={(output as { code: string }).code}
229-
filename={(output as { filename?: string }).filename}
230-
/>
231-
);
226+
const o = output as {
227+
code: string;
228+
html?: string;
229+
filename?: string;
230+
};
231+
return <CodeCard code={o.code} html={o.html} filename={o.filename} />;
232232
}
233233

234234
case "recall_sessions": {
@@ -266,7 +266,9 @@ function renderOutputCard(toolName: string, output: Record<string, unknown>) {
266266
}
267267

268268
case "show_code": {
269+
if ((output as { error?: boolean }).error) return null;
269270
const tabs = (output.tabs ?? []) as CodeTab[];
271+
if (tabs.length === 0) return null;
270272
return <TabbedCode tabs={tabs} />;
271273
}
272274

apps/web/src/components/sessions/tool-parts/code-card.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,27 +5,31 @@ import { useCallback, useEffect, useState } from "react";
55

66
interface CodeCardProps {
77
code: string;
8+
/** Pre-rendered highlighted HTML. If set, no client highlighting runs. */
9+
html?: string;
810
filename?: string;
911
lang?: string;
1012
}
1113

1214
export function CodeCard({
1315
code,
16+
html: initialHtml,
1417
filename,
1518
lang = "typescript",
1619
}: CodeCardProps) {
1720
const [copied, setCopied] = useState(false);
18-
const [html, setHtml] = useState<string | null>(null);
21+
const [html, setHtml] = useState<string | null>(initialHtml ?? null);
1922

2023
useEffect(() => {
24+
if (initialHtml) return;
2125
let cancelled = false;
2226
highlightCode(code, lang).then((result) => {
2327
if (!cancelled) setHtml(result);
2428
});
2529
return () => {
2630
cancelled = true;
2731
};
28-
}, [code, lang]);
32+
}, [code, lang, initialHtml]);
2933

3034
const handleCopy = useCallback(() => {
3135
navigator.clipboard.writeText(code);

apps/web/src/components/sessions/tool-parts/session-code-block.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,24 @@ import { useCallback, useEffect, useState } from "react";
55

66
interface SessionCodeBlockProps {
77
code: string;
8+
/** Pre-rendered highlighted HTML. If set, no client highlighting runs. */
9+
html?: string;
810
lang?: string;
911
}
1012

11-
export function SessionCodeBlock({ code, lang }: SessionCodeBlockProps) {
12-
const [html, setHtml] = useState<string | null>(null);
13+
export function SessionCodeBlock({
14+
code,
15+
html: initialHtml,
16+
lang,
17+
}: SessionCodeBlockProps) {
18+
const [html, setHtml] = useState<string | null>(initialHtml ?? null);
1319
const [copied, setCopied] = useState(false);
1420

1521
useEffect(() => {
22+
if (initialHtml) {
23+
setHtml(initialHtml);
24+
return;
25+
}
1626
if (!lang) return;
1727
let cancelled = false;
1828
highlightCode(code, lang).then((result) => {
@@ -21,7 +31,7 @@ export function SessionCodeBlock({ code, lang }: SessionCodeBlockProps) {
2131
return () => {
2232
cancelled = true;
2333
};
24-
}, [code, lang]);
34+
}, [code, lang, initialHtml]);
2535

2636
const handleCopy = useCallback(() => {
2737
navigator.clipboard.writeText(code);

apps/web/src/lib/sessions/tools/scaffold-subgraph.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { highlight } from "@/lib/highlight";
12
import { generateSubgraphCode } from "@/lib/scaffold/generate";
23
import { tool } from "ai";
34
import { z } from "zod";
@@ -34,9 +35,7 @@ export function createScaffoldSubgraph() {
3435
}
3536

3637
const abi = await res.json();
37-
const publicFunctions = (
38-
abi.functions ?? []
39-
).filter(
38+
const publicFunctions = (abi.functions ?? []).filter(
4039
(f: Record<string, unknown>) => f.access === "public",
4140
);
4241

@@ -48,8 +47,10 @@ export function createScaffoldSubgraph() {
4847
}
4948

5049
const code = generateSubgraphCode(contractId, publicFunctions);
50+
const html = await highlight(code, "typescript");
5151
return {
5252
code,
53+
html,
5354
contractId,
5455
filename: `subgraphs/${name}.ts`,
5556
functionCount: publicFunctions.length,
Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,61 @@
1+
import { highlight, normalizeLang } from "@/lib/highlight";
12
import { tool } from "ai";
23
import { z } from "zod";
34

5+
// Reject obvious placeholder tokens — forces the model to substitute
6+
// real resource names instead of emitting {table-name} / your-api-key.
7+
// Allowed: real values like sk-sl_7b3719eb, contracts-registry, etc.
8+
const PLACEHOLDER_PATTERNS: Array<{ re: RegExp; label: string }> = [
9+
{ re: /\{[a-z][a-z0-9_-]*\}/i, label: "{placeholder}" },
10+
{ re: /\byour[-_][a-z0-9_-]+\b/i, label: "your-*" },
11+
{ re: /\bYOUR_[A-Z0-9_]+\b/, label: "YOUR_*" },
12+
{ re: /<[a-z][a-z0-9_-]*>/i, label: "<placeholder>" },
13+
];
14+
15+
function findPlaceholder(code: string): string | null {
16+
for (const { re, label } of PLACEHOLDER_PATTERNS) {
17+
if (re.test(code)) return label;
18+
}
19+
return null;
20+
}
21+
422
export const showCode = tool({
523
description:
6-
"Display a tabbed code example card to the user. Use this for multi-language examples with tabs: curl, Node.js, and SDK (@secondlayer/sdk). Do NOT include Python. Each tab gets syntax highlighting and a copy button.",
24+
"Display a tabbed code example card to the user. Use for multi-language examples with tabs: curl, Node.js, SDK (@secondlayer/sdk). Do NOT include Python. Each tab gets syntax highlighting and a copy button. CRITICAL: every tab's code must use concrete resource values from the user's account (real subgraph name, real table name, real API key prefix). Never emit placeholder tokens like {table-name}, your-api-key, or <id> — the tool will reject them.",
725
inputSchema: z.object({
826
tabs: z
927
.array(
1028
z.object({
11-
label: z.string().describe("Tab label (e.g. 'curl', 'JavaScript')"),
29+
label: z
30+
.string()
31+
.describe("Tab label (e.g. 'curl', 'Node.js', 'SDK')"),
1232
lang: z
1333
.string()
14-
.describe(
15-
"Language for syntax highlighting (bash, javascript, typescript, python, json, sql)",
16-
),
17-
code: z.string().describe("Code content for this tab"),
34+
.describe("Language: bash, javascript, typescript, json, sql"),
35+
code: z.string().describe("Code content with real resource values"),
1836
}),
1937
)
2038
.describe("Array of code tabs to display"),
2139
}),
22-
execute: async ({ tabs }) => ({ tabs }),
40+
execute: async ({ tabs }) => {
41+
for (const t of tabs) {
42+
const hit = findPlaceholder(t.code);
43+
if (hit) {
44+
return {
45+
error: true,
46+
message: `Tab "${t.label}" contains placeholder token (${hit}). Rewrite using concrete values from the user's resources — real subgraph name, real table name, real API key prefix. Do not use {braces}, <angles>, or your-* / YOUR_* tokens.`,
47+
};
48+
}
49+
}
50+
51+
const rendered = await Promise.all(
52+
tabs.map(async (t) => {
53+
const lang = normalizeLang(t.lang);
54+
const html = await highlight(t.code, lang);
55+
return { label: t.label, lang, code: t.code, html };
56+
}),
57+
);
58+
59+
return { tabs: rendered };
60+
},
2361
});

0 commit comments

Comments
 (0)