From 41d9ade968272126e4444398ee20ecf4d0075689 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 29 Oct 2025 00:16:29 +0000 Subject: [PATCH 1/5] feat: add line numbers and copy button to markdown code blocks - Add line numbers in left gutter using CSS grid layout - Implement reusable CopyButton component with clipboard fallback - Add CopyIcon SVG component - Extract clipboard logic to src/utils/clipboard.ts - Support syntax highlighting with Shiki - Enable line wrapping without horizontal scroll - Add subtle hover-to-reveal copy button - Include Storybook examples for both components - Fix fmt.mk to check ~/.local/bin/uvx - Increase line-height to 1.6 for better readability --- fmt.mk | 6 +- src/App.stories.tsx | 39 ++- src/components/Messages/CodeBlock.stories.tsx | 247 ++++++++++++++++++ .../Messages/MarkdownComponents.tsx | 83 ++++-- src/components/icons/CopyIcon.tsx | 22 ++ src/components/ui/CopyButton.stories.tsx | 80 ++++++ src/components/ui/CopyButton.tsx | 54 ++++ src/styles/globals.css | 83 +++++- src/utils/clipboard.ts | 23 ++ 9 files changed, 602 insertions(+), 35 deletions(-) create mode 100644 src/components/Messages/CodeBlock.stories.tsx create mode 100644 src/components/icons/CopyIcon.tsx create mode 100644 src/components/ui/CopyButton.stories.tsx create mode 100644 src/components/ui/CopyButton.tsx create mode 100644 src/utils/clipboard.ts diff --git a/fmt.mk b/fmt.mk index 701ffbf86..7f4f719c1 100644 --- a/fmt.mk +++ b/fmt.mk @@ -16,7 +16,7 @@ PRETTIER := bun x prettier # Tool availability checks SHFMT := $(shell command -v shfmt 2>/dev/null) NIX := $(shell command -v nix 2>/dev/null) -UVX := $(shell command -v uvx 2>/dev/null) +UVX := $(shell command -v uvx 2>/dev/null || (test -x $(HOME)/.local/bin/uvx && echo $(HOME)/.local/bin/uvx)) fmt: fmt-prettier fmt-shell fmt-python fmt-nix @echo "==> All formatting complete!" @@ -59,11 +59,11 @@ endif fmt-python: .check-uvx @echo "Formatting Python files..." - @uvx ruff format $(PYTHON_DIRS) + @$(UVX) ruff format $(PYTHON_DIRS) fmt-python-check: .check-uvx @echo "Checking Python formatting..." - @uvx ruff format --check $(PYTHON_DIRS) + @$(UVX) ruff format --check $(PYTHON_DIRS) fmt-nix: ifeq ($(NIX),) diff --git a/src/App.stories.tsx b/src/App.stories.tsx index fe34d49e4..ad680e088 100644 --- a/src/App.stories.tsx +++ b/src/App.stories.tsx @@ -484,20 +484,43 @@ export const ActiveWorkspaceWithChat: Story = { }, }); - // User asking to run tests + // Assistant with code block example callback({ id: "msg-5", + role: "assistant", + parts: [ + { + type: "text", + text: "Perfect! I've added JWT authentication. Here's what the updated endpoint looks like:\n\n```typescript\nimport { verifyToken } from '../auth/jwt';\n\nexport function getUser(req, res) {\n const token = req.headers.authorization?.split(' ')[1];\n if (!token || !verifyToken(token)) {\n return res.status(401).json({ error: 'Unauthorized' });\n }\n const user = db.users.find(req.params.id);\n res.json(user);\n}\n```\n\nThe endpoint now requires a valid JWT token in the Authorization header. Let me run the tests to verify everything works.", + }, + ], + metadata: { + historySequence: 5, + timestamp: STABLE_TIMESTAMP - 260000, + model: "claude-sonnet-4-20250514", + usage: { + inputTokens: 1800, + outputTokens: 520, + totalTokens: 2320, + }, + duration: 3200, + }, + }); + + // User asking to run tests + callback({ + id: "msg-6", role: "user", parts: [{ type: "text", text: "Can you run the tests to make sure it works?" }], metadata: { - historySequence: 5, + historySequence: 6, timestamp: STABLE_TIMESTAMP - 240000, }, }); // Assistant running tests callback({ - id: "msg-6", + id: "msg-7", role: "assistant", parts: [ { @@ -522,7 +545,7 @@ export const ActiveWorkspaceWithChat: Story = { }, ], metadata: { - historySequence: 6, + historySequence: 7, timestamp: STABLE_TIMESTAMP - 230000, model: "claude-sonnet-4-20250514", usage: { @@ -536,7 +559,7 @@ export const ActiveWorkspaceWithChat: Story = { // User follow-up about error handling callback({ - id: "msg-7", + id: "msg-8", role: "user", parts: [ { @@ -545,14 +568,14 @@ export const ActiveWorkspaceWithChat: Story = { }, ], metadata: { - historySequence: 7, + historySequence: 8, timestamp: STABLE_TIMESTAMP - 180000, }, }); // Assistant response with thinking (reasoning) callback({ - id: "msg-8", + id: "msg-9", role: "assistant", parts: [ { @@ -582,7 +605,7 @@ export const ActiveWorkspaceWithChat: Story = { }, ], metadata: { - historySequence: 8, + historySequence: 9, timestamp: STABLE_TIMESTAMP - 170000, model: "claude-sonnet-4-20250514", usage: { diff --git a/src/components/Messages/CodeBlock.stories.tsx b/src/components/Messages/CodeBlock.stories.tsx new file mode 100644 index 000000000..6284e9f39 --- /dev/null +++ b/src/components/Messages/CodeBlock.stories.tsx @@ -0,0 +1,247 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { AssistantMessage } from "./AssistantMessage"; +import type { DisplayedMessage } from "@/types/message"; + +// Stable timestamp for visual testing +const STABLE_TIMESTAMP = new Date("2024-01-24T09:41:00-08:00").getTime(); + +const clipboardWriteText = () => Promise.resolve(); + +const createAssistantMessage = (content: string): DisplayedMessage & { type: "assistant" } => ({ + type: "assistant", + id: "asst-msg-1", + historyId: "hist-1", + content, + historySequence: 1, + isStreaming: false, + isPartial: false, + isCompacted: false, + timestamp: STABLE_TIMESTAMP, + model: "anthropic:claude-sonnet-4-5", +}); + +const meta = { + title: "Messages/CodeBlocks", + component: AssistantMessage, + parameters: { + layout: "padded", + }, + tags: ["autodocs"], + args: { + clipboardWriteText, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const BasicCodeBlock: Story = { + args: { + message: createAssistantMessage( + "Here's a simple TypeScript function:\n\n" + + "```typescript\n" + + "function greet(name: string): string {\n" + + " return `Hello, ${name}!`;\n" + + "}\n" + + "```" + ), + }, +}; + +export const LongLines: Story = { + args: { + message: createAssistantMessage( + "This code has very long lines that will wrap:\n\n" + + "```typescript\n" + + "const veryLongVariableName = 'This is a very long string that should wrap when it exceeds the width of the code block container, demonstrating the line-wrapping behavior';\n" + + "\n" + + "function processDataWithManyParameters(firstName: string, lastName: string, email: string, phoneNumber: string, address: string, city: string, state: string, zipCode: string) {\n" + + " console.log('Processing user data with all the provided information about the user including their contact details and location');\n" + + " return { firstName, lastName, email, phoneNumber, address, city, state, zipCode };\n" + + "}\n" + + "```" + ), + }, +}; + +export const ManyLines: Story = { + args: { + message: createAssistantMessage( + "A longer code example with many lines:\n\n" + + "```typescript\n" + + "import React, { useState, useEffect } from 'react';\n" + + "import axios from 'axios';\n" + + "\n" + + "interface User {\n" + + " id: number;\n" + + " name: string;\n" + + " email: string;\n" + + "}\n" + + "\n" + + "const UserList: React.FC = () => {\n" + + " const [users, setUsers] = useState([]);\n" + + " const [loading, setLoading] = useState(true);\n" + + " const [error, setError] = useState(null);\n" + + "\n" + + " useEffect(() => {\n" + + " async function fetchUsers() {\n" + + " try {\n" + + " const response = await axios.get('/api/users');\n" + + " setUsers(response.data);\n" + + " } catch (err) {\n" + + " setError('Failed to load users');\n" + + " } finally {\n" + + " setLoading(false);\n" + + " }\n" + + " }\n" + + "\n" + + " void fetchUsers();\n" + + " }, []);\n" + + "\n" + + " if (loading) return
Loading...
;\n" + + " if (error) return
Error: {error}
;\n" + + "\n" + + " return (\n" + + "
    \n" + + " {users.map((user) => (\n" + + "
  • \n" + + " {user.name} ({user.email})\n" + + "
  • \n" + + " ))}\n" + + "
\n" + + " );\n" + + "};\n" + + "\n" + + "export default UserList;\n" + + "```" + ), + }, +}; + +export const MultipleLanguages: Story = { + args: { + message: createAssistantMessage( + "Here are examples in different languages:\n\n" + + "**TypeScript:**\n" + + "```typescript\n" + + "const sum = (a: number, b: number): number => a + b;\n" + + "console.log(sum(5, 3));\n" + + "```\n\n" + + "**Python:**\n" + + "```python\n" + + "def sum(a, b):\n" + + " return a + b\n" + + "\n" + + "print(sum(5, 3))\n" + + "```\n\n" + + "**Bash:**\n" + + "```bash\n" + + "#!/bin/bash\n" + + "sum=$((5 + 3))\n" + + "echo $sum\n" + + "```" + ), + }, +}; + +export const SingleLine: Story = { + args: { + message: createAssistantMessage( + "A single-line code block:\n\n" + "```typescript\n" + "const x = 42;\n" + "```" + ), + }, +}; + +export const EmptyLines: Story = { + args: { + message: createAssistantMessage( + "Code with empty lines:\n\n" + + "```typescript\n" + + "function example() {\n" + + " const first = 1;\n" + + "\n" + + " const second = 2;\n" + + "\n" + + "\n" + + " return first + second;\n" + + "}\n" + + "```" + ), + }, +}; + +export const JSXExample: Story = { + args: { + message: createAssistantMessage( + "A React component with JSX:\n\n" + + "```tsx\n" + + "import React from 'react';\n" + + "\n" + + "interface ButtonProps {\n" + + " label: string;\n" + + " onClick: () => void;\n" + + " disabled?: boolean;\n" + + "}\n" + + "\n" + + "export const Button: React.FC = ({ label, onClick, disabled = false }) => {\n" + + " return (\n" + + " \n" + + " {label}\n" + + " \n" + + " );\n" + + "};\n" + + "```" + ), + }, +}; + +export const LongLinesAndManyLines: Story = { + args: { + message: createAssistantMessage( + "Complex code with both many lines and long lines:\n\n" + + "```typescript\n" + + "// Configuration object with detailed documentation\n" + + "const applicationConfiguration = {\n" + + " apiEndpoint: 'https://api.example.com/v1/users/authenticate/verify-credentials-and-return-session-token',\n" + + " timeout: 30000,\n" + + " retryAttempts: 3,\n" + + " headers: {\n" + + " 'Content-Type': 'application/json',\n" + + " 'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ',\n" + + " 'X-Custom-Header': 'This is a very long custom header value that contains multiple pieces of information',\n" + + " },\n" + + " features: {\n" + + " enableAdvancedAnalytics: true,\n" + + " enableRealTimeNotifications: true,\n" + + " enableExperimentalFeatures: false,\n" + + " },\n" + + "};\n" + + "\n" + + "async function makeAuthenticatedRequest(endpoint: string, data: unknown): Promise {\n" + + " const fullUrl = `${applicationConfiguration.apiEndpoint}${endpoint}?timestamp=${Date.now()}&includeMetadata=true&format=json`;\n" + + " \n" + + " try {\n" + + " const response = await fetch(fullUrl, {\n" + + " method: 'POST',\n" + + " headers: applicationConfiguration.headers,\n" + + " body: JSON.stringify(data),\n" + + " });\n" + + " \n" + + " if (!response.ok) {\n" + + " throw new Error(`HTTP error! status: ${response.status}, message: ${response.statusText}`);\n" + + " }\n" + + " \n" + + " return response;\n" + + " } catch (error) {\n" + + " console.error('Failed to make authenticated request with the following error details:', error);\n" + + " throw error;\n" + + " }\n" + + "}\n" + + "```" + ), + }, +}; diff --git a/src/components/Messages/MarkdownComponents.tsx b/src/components/Messages/MarkdownComponents.tsx index 7ec1ce908..eb322b769 100644 --- a/src/components/Messages/MarkdownComponents.tsx +++ b/src/components/Messages/MarkdownComponents.tsx @@ -6,6 +6,7 @@ import { mapToShikiLang, SHIKI_THEME, } from "@/utils/highlighting/shikiHighlighter"; +import { CopyButton } from "@/components/ui/CopyButton"; interface CodeProps { node?: unknown; @@ -37,12 +38,34 @@ interface CodeBlockProps { language: string; } +/** + * Extract line contents from Shiki HTML output + * Shiki wraps code in
...
with ... per line + */ +function extractShikiLines(html: string): string[] { + const codeMatch = /]*>(.*?)<\/code>/s.exec(html); + if (!codeMatch) return []; + + return codeMatch[1].split("\n").map((chunk) => { + const start = chunk.indexOf(''); + if (start === -1) return ""; + + const contentStart = start + ''.length; + const end = chunk.lastIndexOf(""); + + return end > contentStart ? chunk.substring(contentStart, end) : ""; + }); +} + /** * CodeBlock component with async Shiki highlighting - * Reuses shared highlighter instance from diff rendering + * Displays code with line numbers in a CSS grid */ const CodeBlock: React.FC = ({ code, language }) => { - const [html, setHtml] = useState(null); + const [highlightedLines, setHighlightedLines] = useState(null); + + // Split code into lines, removing trailing empty line + const plainLines = code.split("\n").filter((line, idx, arr) => idx < arr.length - 1 || line !== ""); useEffect(() => { let cancelled = false; @@ -52,41 +75,55 @@ const CodeBlock: React.FC = ({ code, language }) => { const highlighter = await getShikiHighlighter(); const shikiLang = mapToShikiLang(language); - // codeToHtml lazy-loads languages automatically - const result = highlighter.codeToHtml(code, { + // Load language on-demand + const loadedLangs = highlighter.getLoadedLanguages(); + if (!loadedLangs.includes(shikiLang)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument + await highlighter.loadLanguage(shikiLang as any); + } + + const html = highlighter.codeToHtml(code, { lang: shikiLang, theme: SHIKI_THEME, }); if (!cancelled) { - setHtml(result); + const lines = extractShikiLines(html); + // Remove trailing empty line if present + const filteredLines = lines.filter((line, idx, arr) => idx < arr.length - 1 || line.trim() !== ""); + setHighlightedLines(filteredLines.length > 0 ? filteredLines : null); } } catch (error) { console.warn(`Failed to highlight code block (${language}):`, error); - if (!cancelled) { - setHtml(null); - } + if (!cancelled) setHighlightedLines(null); } } void highlight(); - - return () => { - cancelled = true; - }; + return () => { cancelled = true; }; }, [code, language]); - // Show loading state or fall back to plain code - if (html === null) { - return ( -
-        {code}
-      
- ); - } - - // Render highlighted HTML - return
; + const lines = highlightedLines || plainLines; + + return ( +
+
+ {lines.map((content, idx) => ( + +
{idx + 1}
+
{content} } + )} + /> + + ))} +
+ +
+ ); }; // Custom components for markdown rendering diff --git a/src/components/icons/CopyIcon.tsx b/src/components/icons/CopyIcon.tsx new file mode 100644 index 000000000..7c4536a26 --- /dev/null +++ b/src/components/icons/CopyIcon.tsx @@ -0,0 +1,22 @@ +import React from "react"; + +interface CopyIconProps { + className?: string; +} + +export const CopyIcon: React.FC = ({ className }) => ( + + + + +); + diff --git a/src/components/ui/CopyButton.stories.tsx b/src/components/ui/CopyButton.stories.tsx new file mode 100644 index 000000000..b093f064c --- /dev/null +++ b/src/components/ui/CopyButton.stories.tsx @@ -0,0 +1,80 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { CopyButton } from "./CopyButton"; + +const meta: Meta = { + title: "UI/CopyButton", + component: CopyButton, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + text: "Hello, world! This text will be copied to your clipboard.", + }, +}; + +export const LongText: Story = { + args: { + text: `function example() { + console.log("This is a longer example"); + return "Copy this entire function"; +}`, + }, +}; + +export const CustomFeedback: Story = { + args: { + text: "Quick copy test", + feedbackDuration: 1000, + }, + parameters: { + docs: { + description: { + story: "The feedback duration can be customized (1 second in this example)", + }, + }, + }, +}; + +export const InContext: Story = { + render: () => ( +
+

+ Some content that can be copied... +
+ Multiple lines... +
+ With a copy button! +

+ +
+ ), + parameters: { + docs: { + description: { + story: + "Example showing the copy button positioned in the bottom-right corner (hover to reveal)", + }, + }, + }, +}; + diff --git a/src/components/ui/CopyButton.tsx b/src/components/ui/CopyButton.tsx new file mode 100644 index 000000000..aa0e72184 --- /dev/null +++ b/src/components/ui/CopyButton.tsx @@ -0,0 +1,54 @@ +import React, { useState } from "react"; +import { CopyIcon } from "@/components/icons/CopyIcon"; +import { copyToClipboard } from "@/utils/clipboard"; + +interface CopyButtonProps { + /** + * The text to copy to clipboard + */ + text: string; + /** + * Additional CSS class for styling + */ + className?: string; + /** + * Duration in ms to show "Copied!" feedback (default: 2000) + */ + feedbackDuration?: number; +} + +/** + * Reusable copy button with clipboard functionality and visual feedback + */ +export const CopyButton: React.FC = ({ + text, + className = "", + feedbackDuration = 2000, +}) => { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await copyToClipboard(text); + setCopied(true); + setTimeout(() => setCopied(false), feedbackDuration); + } catch (error) { + console.warn("Failed to copy to clipboard:", error); + } + }; + + return ( + + ); +}; + diff --git a/src/styles/globals.css b/src/styles/globals.css index c3d79d409..fe9226437 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -664,7 +664,88 @@ span.search-highlight { background: var(--color-code-bg) !important; } -/* Markdown code blocks */ +/* Code block wrapper with copy button */ +.code-block-wrapper { + position: relative; + margin: 0.75em 0; +} + +/* Code block with line numbers - each line is a grid row */ +.code-block-container { + display: grid; + grid-template-columns: auto 1fr; + background: var(--color-code-bg); + border-radius: 4px; + padding: 6px 0; + font-family: var(--font-monospace); + font-size: 12px; + line-height: 1.6; +} + +.line-number { + background: rgba(0, 0, 0, 0.2); + padding: 0 8px 0 6px; + text-align: right; + color: rgba(255, 255, 255, 0.4); + user-select: none; + border-right: 1px solid rgba(255, 255, 255, 0.1); +} + +.code-line { + padding: 0 8px; + white-space: pre-wrap; +} + +.code-line code { + background: transparent; + padding: 0; +} + +/* Reusable copy button styles */ +.copy-button { + padding: 6px 8px; + background: rgba(0, 0, 0, 0.6); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + color: rgba(255, 255, 255, 0.6); + cursor: pointer; + transition: color 0.2s, background 0.2s, border-color 0.2s; + font-size: 12px; + display: flex; + align-items: center; + gap: 4px; +} + +.copy-button:hover { + background: rgba(0, 0, 0, 0.8); + color: rgba(255, 255, 255, 0.9); + border-color: rgba(255, 255, 255, 0.2); +} + +.copy-icon { + width: 14px; + height: 14px; +} + +.copy-feedback { + font-size: 11px; + color: rgba(255, 255, 255, 0.9); +} + +/* Code block specific positioning */ +.code-copy-button { + position: absolute; + bottom: 8px; + right: 8px; + opacity: 0; + transition: opacity 0.2s; +} + +.code-block-wrapper:hover .code-copy-button { + opacity: 1; +} + +/* Markdown code blocks (fallback for non-highlighted blocks) */ pre code { display: block; background: var(--color-code-bg); diff --git a/src/utils/clipboard.ts b/src/utils/clipboard.ts new file mode 100644 index 000000000..88887da3f --- /dev/null +++ b/src/utils/clipboard.ts @@ -0,0 +1,23 @@ +/** + * Copy text to clipboard with fallback for environments without Clipboard API + * @param text The text to copy + * @returns Promise that resolves when copy succeeds + */ +export async function copyToClipboard(text: string): Promise { + // Try modern clipboard API first + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + return; + } + + // Fallback for browsers without clipboard API (e.g., Storybook, HTTP contexts) + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); +} + From ce98daf71d448dec14ff787e246a098da784fcf1 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 29 Oct 2025 00:19:40 +0000 Subject: [PATCH 2/5] fix: address lint errors --- .../Messages/MarkdownComponents.tsx | 27 +++++++++++-------- src/components/icons/CopyIcon.tsx | 1 - src/components/ui/CopyButton.stories.tsx | 1 - src/components/ui/CopyButton.tsx | 25 ++++++++--------- src/utils/clipboard.ts | 3 +-- 5 files changed, 28 insertions(+), 29 deletions(-) diff --git a/src/components/Messages/MarkdownComponents.tsx b/src/components/Messages/MarkdownComponents.tsx index eb322b769..561d878b3 100644 --- a/src/components/Messages/MarkdownComponents.tsx +++ b/src/components/Messages/MarkdownComponents.tsx @@ -49,10 +49,10 @@ function extractShikiLines(html: string): string[] { return codeMatch[1].split("\n").map((chunk) => { const start = chunk.indexOf(''); if (start === -1) return ""; - + const contentStart = start + ''.length; const end = chunk.lastIndexOf(""); - + return end > contentStart ? chunk.substring(contentStart, end) : ""; }); } @@ -65,7 +65,9 @@ const CodeBlock: React.FC = ({ code, language }) => { const [highlightedLines, setHighlightedLines] = useState(null); // Split code into lines, removing trailing empty line - const plainLines = code.split("\n").filter((line, idx, arr) => idx < arr.length - 1 || line !== ""); + const plainLines = code + .split("\n") + .filter((line, idx, arr) => idx < arr.length - 1 || line !== ""); useEffect(() => { let cancelled = false; @@ -90,7 +92,9 @@ const CodeBlock: React.FC = ({ code, language }) => { if (!cancelled) { const lines = extractShikiLines(html); // Remove trailing empty line if present - const filteredLines = lines.filter((line, idx, arr) => idx < arr.length - 1 || line.trim() !== ""); + const filteredLines = lines.filter( + (line, idx, arr) => idx < arr.length - 1 || line.trim() !== "" + ); setHighlightedLines(filteredLines.length > 0 ? filteredLines : null); } } catch (error) { @@ -100,10 +104,12 @@ const CodeBlock: React.FC = ({ code, language }) => { } void highlight(); - return () => { cancelled = true; }; + return () => { + cancelled = true; + }; }, [code, language]); - const lines = highlightedLines || plainLines; + const lines = highlightedLines ?? plainLines; return (
@@ -111,12 +117,11 @@ const CodeBlock: React.FC = ({ code, language }) => { {lines.map((content, idx) => (
{idx + 1}
-
{content} } - )} + : { children: {content} })} /> ))} diff --git a/src/components/icons/CopyIcon.tsx b/src/components/icons/CopyIcon.tsx index 7c4536a26..040ebb28e 100644 --- a/src/components/icons/CopyIcon.tsx +++ b/src/components/icons/CopyIcon.tsx @@ -19,4 +19,3 @@ export const CopyIcon: React.FC = ({ className }) => ( ); - diff --git a/src/components/ui/CopyButton.stories.tsx b/src/components/ui/CopyButton.stories.tsx index b093f064c..9686c79c7 100644 --- a/src/components/ui/CopyButton.stories.tsx +++ b/src/components/ui/CopyButton.stories.tsx @@ -77,4 +77,3 @@ With a copy button!" }, }, }; - diff --git a/src/components/ui/CopyButton.tsx b/src/components/ui/CopyButton.tsx index aa0e72184..44816a56a 100644 --- a/src/components/ui/CopyButton.tsx +++ b/src/components/ui/CopyButton.tsx @@ -27,14 +27,16 @@ export const CopyButton: React.FC = ({ }) => { const [copied, setCopied] = useState(false); - const handleCopy = async () => { - try { - await copyToClipboard(text); - setCopied(true); - setTimeout(() => setCopied(false), feedbackDuration); - } catch (error) { - console.warn("Failed to copy to clipboard:", error); - } + const handleCopy = () => { + void (async () => { + try { + await copyToClipboard(text); + setCopied(true); + setTimeout(() => setCopied(false), feedbackDuration); + } catch (error) { + console.warn("Failed to copy to clipboard:", error); + } + })(); }; return ( @@ -43,12 +45,7 @@ export const CopyButton: React.FC = ({ onClick={handleCopy} aria-label="Copy to clipboard" > - {copied ? ( - Copied! - ) : ( - - )} + {copied ? Copied! : } ); }; - diff --git a/src/utils/clipboard.ts b/src/utils/clipboard.ts index 88887da3f..2ee86a566 100644 --- a/src/utils/clipboard.ts +++ b/src/utils/clipboard.ts @@ -5,7 +5,7 @@ */ export async function copyToClipboard(text: string): Promise { // Try modern clipboard API first - if (navigator.clipboard && navigator.clipboard.writeText) { + if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return; } @@ -20,4 +20,3 @@ export async function copyToClipboard(text: string): Promise { document.execCommand("copy"); document.body.removeChild(textarea); } - From 5e1adb2caac34c4e9038e0216f40d982e99b2c01 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 29 Oct 2025 00:38:45 +0000 Subject: [PATCH 3/5] docs: prefer full application stories over component stories --- docs/AGENTS.md | 4 + src/components/Messages/CodeBlock.stories.tsx | 247 ------------------ 2 files changed, 4 insertions(+), 247 deletions(-) delete mode 100644 src/components/Messages/CodeBlock.stories.tsx diff --git a/docs/AGENTS.md b/docs/AGENTS.md index c56bd66f7..fc4c68c35 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -201,6 +201,10 @@ This project uses **Make** as the primary build orchestrator. See `Makefile` for ## Testing +### Storybook + +**Prefer full application stories over component-level stories** - Use `App.stories.tsx` to demonstrate features in realistic contexts rather than creating isolated component stories. + ### Test-Driven Development (TDD) **TDD is the preferred development style for agents.** diff --git a/src/components/Messages/CodeBlock.stories.tsx b/src/components/Messages/CodeBlock.stories.tsx deleted file mode 100644 index 6284e9f39..000000000 --- a/src/components/Messages/CodeBlock.stories.tsx +++ /dev/null @@ -1,247 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { AssistantMessage } from "./AssistantMessage"; -import type { DisplayedMessage } from "@/types/message"; - -// Stable timestamp for visual testing -const STABLE_TIMESTAMP = new Date("2024-01-24T09:41:00-08:00").getTime(); - -const clipboardWriteText = () => Promise.resolve(); - -const createAssistantMessage = (content: string): DisplayedMessage & { type: "assistant" } => ({ - type: "assistant", - id: "asst-msg-1", - historyId: "hist-1", - content, - historySequence: 1, - isStreaming: false, - isPartial: false, - isCompacted: false, - timestamp: STABLE_TIMESTAMP, - model: "anthropic:claude-sonnet-4-5", -}); - -const meta = { - title: "Messages/CodeBlocks", - component: AssistantMessage, - parameters: { - layout: "padded", - }, - tags: ["autodocs"], - args: { - clipboardWriteText, - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const BasicCodeBlock: Story = { - args: { - message: createAssistantMessage( - "Here's a simple TypeScript function:\n\n" + - "```typescript\n" + - "function greet(name: string): string {\n" + - " return `Hello, ${name}!`;\n" + - "}\n" + - "```" - ), - }, -}; - -export const LongLines: Story = { - args: { - message: createAssistantMessage( - "This code has very long lines that will wrap:\n\n" + - "```typescript\n" + - "const veryLongVariableName = 'This is a very long string that should wrap when it exceeds the width of the code block container, demonstrating the line-wrapping behavior';\n" + - "\n" + - "function processDataWithManyParameters(firstName: string, lastName: string, email: string, phoneNumber: string, address: string, city: string, state: string, zipCode: string) {\n" + - " console.log('Processing user data with all the provided information about the user including their contact details and location');\n" + - " return { firstName, lastName, email, phoneNumber, address, city, state, zipCode };\n" + - "}\n" + - "```" - ), - }, -}; - -export const ManyLines: Story = { - args: { - message: createAssistantMessage( - "A longer code example with many lines:\n\n" + - "```typescript\n" + - "import React, { useState, useEffect } from 'react';\n" + - "import axios from 'axios';\n" + - "\n" + - "interface User {\n" + - " id: number;\n" + - " name: string;\n" + - " email: string;\n" + - "}\n" + - "\n" + - "const UserList: React.FC = () => {\n" + - " const [users, setUsers] = useState([]);\n" + - " const [loading, setLoading] = useState(true);\n" + - " const [error, setError] = useState(null);\n" + - "\n" + - " useEffect(() => {\n" + - " async function fetchUsers() {\n" + - " try {\n" + - " const response = await axios.get('/api/users');\n" + - " setUsers(response.data);\n" + - " } catch (err) {\n" + - " setError('Failed to load users');\n" + - " } finally {\n" + - " setLoading(false);\n" + - " }\n" + - " }\n" + - "\n" + - " void fetchUsers();\n" + - " }, []);\n" + - "\n" + - " if (loading) return
Loading...
;\n" + - " if (error) return
Error: {error}
;\n" + - "\n" + - " return (\n" + - "
    \n" + - " {users.map((user) => (\n" + - "
  • \n" + - " {user.name} ({user.email})\n" + - "
  • \n" + - " ))}\n" + - "
\n" + - " );\n" + - "};\n" + - "\n" + - "export default UserList;\n" + - "```" - ), - }, -}; - -export const MultipleLanguages: Story = { - args: { - message: createAssistantMessage( - "Here are examples in different languages:\n\n" + - "**TypeScript:**\n" + - "```typescript\n" + - "const sum = (a: number, b: number): number => a + b;\n" + - "console.log(sum(5, 3));\n" + - "```\n\n" + - "**Python:**\n" + - "```python\n" + - "def sum(a, b):\n" + - " return a + b\n" + - "\n" + - "print(sum(5, 3))\n" + - "```\n\n" + - "**Bash:**\n" + - "```bash\n" + - "#!/bin/bash\n" + - "sum=$((5 + 3))\n" + - "echo $sum\n" + - "```" - ), - }, -}; - -export const SingleLine: Story = { - args: { - message: createAssistantMessage( - "A single-line code block:\n\n" + "```typescript\n" + "const x = 42;\n" + "```" - ), - }, -}; - -export const EmptyLines: Story = { - args: { - message: createAssistantMessage( - "Code with empty lines:\n\n" + - "```typescript\n" + - "function example() {\n" + - " const first = 1;\n" + - "\n" + - " const second = 2;\n" + - "\n" + - "\n" + - " return first + second;\n" + - "}\n" + - "```" - ), - }, -}; - -export const JSXExample: Story = { - args: { - message: createAssistantMessage( - "A React component with JSX:\n\n" + - "```tsx\n" + - "import React from 'react';\n" + - "\n" + - "interface ButtonProps {\n" + - " label: string;\n" + - " onClick: () => void;\n" + - " disabled?: boolean;\n" + - "}\n" + - "\n" + - "export const Button: React.FC = ({ label, onClick, disabled = false }) => {\n" + - " return (\n" + - " \n" + - " {label}\n" + - " \n" + - " );\n" + - "};\n" + - "```" - ), - }, -}; - -export const LongLinesAndManyLines: Story = { - args: { - message: createAssistantMessage( - "Complex code with both many lines and long lines:\n\n" + - "```typescript\n" + - "// Configuration object with detailed documentation\n" + - "const applicationConfiguration = {\n" + - " apiEndpoint: 'https://api.example.com/v1/users/authenticate/verify-credentials-and-return-session-token',\n" + - " timeout: 30000,\n" + - " retryAttempts: 3,\n" + - " headers: {\n" + - " 'Content-Type': 'application/json',\n" + - " 'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ',\n" + - " 'X-Custom-Header': 'This is a very long custom header value that contains multiple pieces of information',\n" + - " },\n" + - " features: {\n" + - " enableAdvancedAnalytics: true,\n" + - " enableRealTimeNotifications: true,\n" + - " enableExperimentalFeatures: false,\n" + - " },\n" + - "};\n" + - "\n" + - "async function makeAuthenticatedRequest(endpoint: string, data: unknown): Promise {\n" + - " const fullUrl = `${applicationConfiguration.apiEndpoint}${endpoint}?timestamp=${Date.now()}&includeMetadata=true&format=json`;\n" + - " \n" + - " try {\n" + - " const response = await fetch(fullUrl, {\n" + - " method: 'POST',\n" + - " headers: applicationConfiguration.headers,\n" + - " body: JSON.stringify(data),\n" + - " });\n" + - " \n" + - " if (!response.ok) {\n" + - " throw new Error(`HTTP error! status: ${response.status}, message: ${response.statusText}`);\n" + - " }\n" + - " \n" + - " return response;\n" + - " } catch (error) {\n" + - " console.error('Failed to make authenticated request with the following error details:', error);\n" + - " throw error;\n" + - " }\n" + - "}\n" + - "```" - ), - }, -}; From a5a8f8c44b0080265d47cef4a8107fd784ed0d7d Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 29 Oct 2025 00:41:17 +0000 Subject: [PATCH 4/5] docs: add security audit comment for dangerouslySetInnerHTML --- src/components/Messages/MarkdownComponents.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/components/Messages/MarkdownComponents.tsx b/src/components/Messages/MarkdownComponents.tsx index 561d878b3..8b46c49d6 100644 --- a/src/components/Messages/MarkdownComponents.tsx +++ b/src/components/Messages/MarkdownComponents.tsx @@ -117,6 +117,15 @@ const CodeBlock: React.FC = ({ code, language }) => { {lines.map((content, idx) => (
{idx + 1}
+ {/* SECURITY AUDIT: dangerouslySetInnerHTML usage + * Source: Shiki syntax highlighter (highlighter.codeToHtml) + * Safety: Shiki escapes all user content before wrapping in tokens + * Data flow: User markdown → react-markdown → code prop → Shiki → extractShikiLines → here + * Verification: Shiki's codeToHtml tokenizes and escapes HTML entities in code content + * Risk: Low - Shiki is a trusted library that properly escapes user input + * Alternative considered: Render Shiki's full block, but per-line rendering + * required for line numbers in CSS grid layout + */}
Date: Wed, 29 Oct 2025 00:43:27 +0000 Subject: [PATCH 5/5] refactor: centralize clipboard operations to copyToClipboard utility - Update useCopyToClipboard hook to use copyToClipboard utility as default - Replace navigator.clipboard.writeText with copyToClipboard in UserMessage - Replace navigator.clipboard.writeText with copyToClipboard in AssistantMessage - Remove redundant defaultClipboardWriteText function from UserMessage - All clipboard operations now use the centralized utility with fallback support --- src/components/Messages/AssistantMessage.tsx | 3 ++- src/components/Messages/UserMessage.tsx | 12 ++---------- src/hooks/useCopyToClipboard.ts | 6 +++--- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/src/components/Messages/AssistantMessage.tsx b/src/components/Messages/AssistantMessage.tsx index f8d0836bc..b60d1a137 100644 --- a/src/components/Messages/AssistantMessage.tsx +++ b/src/components/Messages/AssistantMessage.tsx @@ -11,6 +11,7 @@ import { ModelDisplay } from "./ModelDisplay"; import { CompactingMessageContent } from "./CompactingMessageContent"; import { CompactionBackground } from "./CompactionBackground"; import type { KebabMenuItem } from "@/components/KebabMenu"; +import { copyToClipboard } from "@/utils/clipboard"; interface AssistantMessageProps { message: DisplayedMessage & { type: "assistant" }; @@ -25,7 +26,7 @@ export const AssistantMessage: React.FC = ({ className, workspaceId, isCompacting = false, - clipboardWriteText = (data: string) => navigator.clipboard.writeText(data), + clipboardWriteText = copyToClipboard, }) => { const [showRaw, setShowRaw] = useState(false); diff --git a/src/components/Messages/UserMessage.tsx b/src/components/Messages/UserMessage.tsx index 54edf39f6..c83b28005 100644 --- a/src/components/Messages/UserMessage.tsx +++ b/src/components/Messages/UserMessage.tsx @@ -6,6 +6,7 @@ import { TerminalOutput } from "./TerminalOutput"; import { formatKeybind, KEYBINDS } from "@/utils/ui/keybinds"; import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"; import type { KebabMenuItem } from "@/components/KebabMenu"; +import { copyToClipboard } from "@/utils/clipboard"; interface UserMessageProps { message: DisplayedMessage & { type: "user" }; @@ -15,21 +16,12 @@ interface UserMessageProps { clipboardWriteText?: (data: string) => Promise; } -async function defaultClipboardWriteText(data: string): Promise { - if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(data); - return; - } - - console.warn("Clipboard API is not available; skipping copy action."); -} - export const UserMessage: React.FC = ({ message, className, onEdit, isCompacting, - clipboardWriteText = defaultClipboardWriteText, + clipboardWriteText = copyToClipboard, }) => { const content = message.content; diff --git a/src/hooks/useCopyToClipboard.ts b/src/hooks/useCopyToClipboard.ts index faf8ab4a4..93c77ebfb 100644 --- a/src/hooks/useCopyToClipboard.ts +++ b/src/hooks/useCopyToClipboard.ts @@ -1,17 +1,17 @@ import { useState, useCallback } from "react"; import { COPY_FEEDBACK_DURATION_MS } from "@/constants/ui"; +import { copyToClipboard as copyToClipboardUtil } from "@/utils/clipboard"; /** * Hook for copy-to-clipboard functionality with temporary "copied" feedback state. * - * @param clipboardWriteText - Optional custom clipboard write function (defaults to navigator.clipboard.writeText) + * @param clipboardWriteText - Optional custom clipboard write function (defaults to copyToClipboard utility) * @returns Object with: * - copied: boolean indicating if content was just copied (resets after COPY_FEEDBACK_DURATION_MS) * - copyToClipboard: async function to copy text and trigger feedback */ export function useCopyToClipboard( - clipboardWriteText: (text: string) => Promise = (text: string) => - navigator.clipboard.writeText(text) + clipboardWriteText: (text: string) => Promise = copyToClipboardUtil ) { const [copied, setCopied] = useState(false);