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
3 changes: 3 additions & 0 deletions bun.lock
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"undici": "^7.16.0",
"write-file-atomic": "^6.0.0",
"ws": "^8.18.3",
"xxhash-wasm": "^1.1.0",
"zod": "^4.1.11",
"zod-to-json-schema": "^3.24.6",
},
Expand Down Expand Up @@ -3620,6 +3621,8 @@

"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],

"xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="],

"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],

"yaku": ["yaku@0.16.7", "", {}, "sha512-Syu3IB3rZvKvYk7yTiyl1bo/jiEFaaStrgv1V2TIJTqYPStSMQVO8EQjg/z+DRzLq/4LIIharNT3iH1hylEIRw=="],
Expand Down
3 changes: 2 additions & 1 deletion docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ Avoid mock-heavy tests that verify implementation details rather than behavior.

### Storybook

- Prefer full-app stories (`App.stories.tsx`) to isolated components.
- Prefer full-app stories (`App.stories.tsx`) to isolated component stories. This tests components in their real context with proper providers, state management, and styling.
- Use play functions with `@storybook/test` utilities (`within`, `userEvent`, `waitFor`) to interact with the UI and set up the desired visual state. Do not add props to production components solely for storybook convenience.

### TDD Expectations

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@
"undici": "^7.16.0",
"write-file-atomic": "^6.0.0",
"ws": "^8.18.3",
"xxhash-wasm": "^1.1.0",
"zod": "^4.1.11",
"zod-to-json-schema": "^3.24.6"
},
Expand Down
16 changes: 8 additions & 8 deletions src/browser/components/tools/GenericToolCall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,8 @@ import {
DetailContent,
LoadingDots,
} from "./shared/ToolPrimitives";
import {
useToolExpansion,
getStatusDisplay,
formatValue,
type ToolStatus,
} from "./shared/toolUtils";
import { useToolExpansion, getStatusDisplay, type ToolStatus } from "./shared/toolUtils";
import { JsonHighlight } from "./shared/HighlightedCode";

interface GenericToolCallProps {
toolName: string;
Expand Down Expand Up @@ -48,14 +44,18 @@ export const GenericToolCall: React.FC<GenericToolCallProps> = ({
{args !== undefined && (
<DetailSection>
<DetailLabel>Arguments</DetailLabel>
<DetailContent>{formatValue(args)}</DetailContent>
<DetailContent>
<JsonHighlight value={args} />
</DetailContent>
</DetailSection>
)}

{result !== undefined && (
<DetailSection>
<DetailLabel>Result</DetailLabel>
<DetailContent>{formatValue(result)}</DetailContent>
<DetailContent>
<JsonHighlight value={result} />
</DetailContent>
</DetailSection>
)}

Expand Down
112 changes: 112 additions & 0 deletions src/browser/components/tools/shared/HighlightedCode.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import React, { useState, useEffect } from "react";
import { highlightCode } from "@/browser/utils/highlighting/highlightWorkerClient";
import { extractShikiLines } from "@/browser/utils/highlighting/shiki-shared";
import { useTheme } from "@/browser/contexts/ThemeContext";

interface HighlightedCodeProps {
code: string;
language: string;
className?: string;
showLineNumbers?: boolean;
}

/**
* Renders code with syntax highlighting using Shiki (via web worker)
* Falls back to plain text on first render or if highlighting fails
*/
export const HighlightedCode: React.FC<HighlightedCodeProps> = ({
code,
language,
className,
showLineNumbers = false,
}) => {
const [highlightedLines, setHighlightedLines] = useState<string[] | null>(null);
const { theme: themeMode } = useTheme();

const plainLines = code.split("\n").filter((line, i, arr) => i < arr.length - 1 || line !== "");

useEffect(() => {
let cancelled = false;
const theme = themeMode === "light" || themeMode === "solarized-light" ? "light" : "dark";

setHighlightedLines(null);

async function highlight() {
try {
const html = await highlightCode(code, language, theme);
if (!cancelled) {
const lines = extractShikiLines(html);
const filtered = lines.filter((l, i, a) => i < a.length - 1 || l.trim() !== "");
setHighlightedLines(filtered.length > 0 ? filtered : null);
}
} catch (error) {
console.warn(`Failed to highlight ${language}:`, error);
if (!cancelled) setHighlightedLines(null);
}
}

void highlight();
return () => {
cancelled = true;
};
}, [code, language, themeMode]);

const lines = highlightedLines ?? plainLines;

if (showLineNumbers) {
return (
<div className="code-block-container text-[11px]">
{lines.map((content, idx) => (
<React.Fragment key={idx}>
<div className="line-number">{idx + 1}</div>
{/* SECURITY AUDIT: dangerouslySetInnerHTML - Shiki escapes all content */}
<div
className="code-line"
{...(highlightedLines
? { dangerouslySetInnerHTML: { __html: content } }
: { children: content })}
/>
</React.Fragment>
))}
</div>
);
}

if (highlightedLines) {
return (
<div
className={className}
dangerouslySetInnerHTML={{ __html: highlightedLines.join("\n") }}
/>
);
}
return <div className={className}>{code}</div>;
};

interface JsonHighlightProps {
value: unknown;
className?: string;
}

/** Renders a value as syntax-highlighted JSON with line numbers */
export const JsonHighlight: React.FC<JsonHighlightProps> = ({ value, className }) => {
const jsonString = React.useMemo(() => {
if (value === null || value === undefined) return "null";
if (typeof value === "string") {
try {
return JSON.stringify(JSON.parse(value), null, 2);
} catch {
return value;
}
}
try {
return JSON.stringify(value, null, 2);
} catch {
return "[Complex Object]";
}
}, [value]);

return (
<HighlightedCode code={jsonString} language="json" className={className} showLineNumbers />
);
};
2 changes: 1 addition & 1 deletion src/browser/components/tools/shared/ToolPrimitives.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ export const DetailContent: React.FC<React.HTMLAttributes<HTMLPreElement>> = ({
}) => (
<pre
className={cn(
"m-0 px-2 py-1.5 bg-code-bg rounded-sm text-[11px] leading-relaxed whitespace-pre-wrap break-words max-h-[200px] overflow-y-auto",
"m-0 bg-code-bg rounded-sm text-[11px] leading-relaxed whitespace-pre-wrap break-words max-h-[200px] overflow-y-auto",
className
)}
{...props}
Expand Down
61 changes: 61 additions & 0 deletions src/browser/stories/App.chat.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import {
createFileEditTool,
createTerminalTool,
createStatusTool,
createGenericTool,
} from "./mockFactory";
import { setupSimpleChatStory, setupStreamingChatStory } from "./storyHelpers";
import { within, userEvent, waitFor } from "@storybook/test";

export default {
...appMeta,
Expand Down Expand Up @@ -264,3 +266,62 @@ export const Streaming: AppStory = {
/>
),
};

/** Generic tool call with JSON-highlighted arguments and results */
export const GenericTool: AppStory = {
render: () => (
<AppWithMocks
setup={() =>
setupSimpleChatStory({
messages: [
createUserMessage("msg-1", "Fetch a large dataset", {
historySequence: 1,
timestamp: STABLE_TIMESTAMP - 60000,
}),
createAssistantMessage("msg-2", "I'll fetch that data for you.", {
historySequence: 2,
timestamp: STABLE_TIMESTAMP - 55000,
toolCalls: [
createGenericTool(
"call-1",
"fetch_data",
{
endpoint: "/api/users",
params: { limit: 100, offset: 0 },
},
{
success: true,
// Generate 100+ line result to test line number alignment
data: Array.from({ length: 50 }, (_, i) => ({
id: i + 1,
name: `User ${i + 1}`,
email: `user${i + 1}@example.com`,
active: i % 3 !== 0,
})),
total: 500,
page: 1,
}
),
],
}),
],
})
}
/>
),
parameters: {
docs: {
description: {
story: "Generic tool call with JSON syntax highlighting and 100+ lines.",
},
},
},
play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
const canvas = within(canvasElement);

await waitFor(async () => {
const toolHeader = canvas.getByText("fetch_data");
await userEvent.click(toolHeader);
});
},
};
17 changes: 17 additions & 0 deletions src/browser/stories/mockFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,23 @@ export function createPendingTool(toolCallId: string, toolName: string, args: ob
};
}

/** Create a generic tool call with custom name, args, and output - falls back to GenericToolCall */
export function createGenericTool(
toolCallId: string,
toolName: string,
input: object,
output: object
): MuxPart {
return {
type: "dynamic-tool",
toolCallId,
toolName,
state: "output-available",
input,
output,
};
}

// ═══════════════════════════════════════════════════════════════════════════════
// GIT STATUS MOCKS
// ═══════════════════════════════════════════════════════════════════════════════
Expand Down
8 changes: 2 additions & 6 deletions src/browser/utils/highlighting/highlightWorkerClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,8 @@ const highlightCache = new LRUCache<string, string>({
});

async function getCacheKey(code: string, language: string, theme: string): Promise<string> {
const data = new TextEncoder().encode(`${language}:${theme}:${code}`);
const hash = await crypto.subtle.digest("SHA-256", data);
// Take first 8 bytes (64 bits) as hex
return Array.from(new Uint8Array(hash).slice(0, 8))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
const { hashKey } = await import("@/common/lib/hashKey");
return hashKey(`${language}:${theme}:${code}`);
}

// ─────────────────────────────────────────────────────────────────────────────
Expand Down
23 changes: 23 additions & 0 deletions src/common/lib/hashKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import xxhash from "xxhash-wasm";

let xxhasher: Awaited<ReturnType<typeof xxhash>> | null = null;

/**
* Generate a 64-bit hash key for caching/deduplication.
* Prefers SHA-256 (truncated to 64 bits) when crypto.subtle is available,
* falls back to xxhash64 in insecure contexts (e.g., HTTP dev servers).
*/
export async function hashKey(input: string): Promise<string> {
// crypto.subtle is only available in secure contexts (HTTPS/localhost)
if (crypto.subtle) {
const data = new TextEncoder().encode(input);
const hash = await crypto.subtle.digest("SHA-256", data);
// Take first 8 bytes (64 bits) as hex
return Array.from(new Uint8Array(hash).slice(0, 8))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
// Fallback to xxhash64 for dev environments
xxhasher ??= await xxhash();
return xxhasher.h64(input).toString(16);
}