diff --git a/bun.lock b/bun.lock index 44688b3b17..1117a5d002 100644 --- a/bun.lock +++ b/bun.lock @@ -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", }, @@ -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=="], diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 4d25fba4d7..7bd91d7672 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -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 diff --git a/package.json b/package.json index b12c4d1e22..143256e1d7 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/browser/components/tools/GenericToolCall.tsx b/src/browser/components/tools/GenericToolCall.tsx index bf38319302..9357cf627d 100644 --- a/src/browser/components/tools/GenericToolCall.tsx +++ b/src/browser/components/tools/GenericToolCall.tsx @@ -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; @@ -48,14 +44,18 @@ export const GenericToolCall: React.FC = ({ {args !== undefined && ( Arguments - {formatValue(args)} + + + )} {result !== undefined && ( Result - {formatValue(result)} + + + )} diff --git a/src/browser/components/tools/shared/HighlightedCode.tsx b/src/browser/components/tools/shared/HighlightedCode.tsx new file mode 100644 index 0000000000..b9a601aa77 --- /dev/null +++ b/src/browser/components/tools/shared/HighlightedCode.tsx @@ -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 = ({ + code, + language, + className, + showLineNumbers = false, +}) => { + const [highlightedLines, setHighlightedLines] = useState(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 ( +
+ {lines.map((content, idx) => ( + +
{idx + 1}
+ {/* SECURITY AUDIT: dangerouslySetInnerHTML - Shiki escapes all content */} +
+ + ))} +
+ ); + } + + if (highlightedLines) { + return ( +
+ ); + } + return
{code}
; +}; + +interface JsonHighlightProps { + value: unknown; + className?: string; +} + +/** Renders a value as syntax-highlighted JSON with line numbers */ +export const JsonHighlight: React.FC = ({ 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 ( + + ); +}; diff --git a/src/browser/components/tools/shared/ToolPrimitives.tsx b/src/browser/components/tools/shared/ToolPrimitives.tsx index 22368bd00e..2ef3cef86f 100644 --- a/src/browser/components/tools/shared/ToolPrimitives.tsx +++ b/src/browser/components/tools/shared/ToolPrimitives.tsx @@ -122,7 +122,7 @@ export const DetailContent: React.FC> = ({ }) => (
   ),
 };
+
+/** Generic tool call with JSON-highlighted arguments and results */
+export const GenericTool: AppStory = {
+  render: () => (
+    
+        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);
+    });
+  },
+};
diff --git a/src/browser/stories/mockFactory.ts b/src/browser/stories/mockFactory.ts
index e111b5db64..1a99141144 100644
--- a/src/browser/stories/mockFactory.ts
+++ b/src/browser/stories/mockFactory.ts
@@ -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
 // ═══════════════════════════════════════════════════════════════════════════════
diff --git a/src/browser/utils/highlighting/highlightWorkerClient.ts b/src/browser/utils/highlighting/highlightWorkerClient.ts
index 27e2bd67a6..ca11a79cfe 100644
--- a/src/browser/utils/highlighting/highlightWorkerClient.ts
+++ b/src/browser/utils/highlighting/highlightWorkerClient.ts
@@ -30,12 +30,8 @@ const highlightCache = new LRUCache({
 });
 
 async function getCacheKey(code: string, language: string, theme: string): Promise {
-  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}`);
 }
 
 // ─────────────────────────────────────────────────────────────────────────────
diff --git a/src/common/lib/hashKey.ts b/src/common/lib/hashKey.ts
new file mode 100644
index 0000000000..fe55c040e0
--- /dev/null
+++ b/src/common/lib/hashKey.ts
@@ -0,0 +1,23 @@
+import xxhash from "xxhash-wasm";
+
+let xxhasher: Awaited> | 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 {
+  // 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);
+}