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/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/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/MarkdownComponents.tsx b/src/components/Messages/MarkdownComponents.tsx index 7ec1ce908..8b46c49d6 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,36 @@ 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 +77,67 @@ 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; }; }, [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}
+ {/* 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 + */} +
{content} })} + /> + + ))} +
+ +
+ ); }; // Custom components for markdown rendering 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/components/icons/CopyIcon.tsx b/src/components/icons/CopyIcon.tsx new file mode 100644 index 000000000..040ebb28e --- /dev/null +++ b/src/components/icons/CopyIcon.tsx @@ -0,0 +1,21 @@ +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..9686c79c7 --- /dev/null +++ b/src/components/ui/CopyButton.stories.tsx @@ -0,0 +1,79 @@ +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..44816a56a --- /dev/null +++ b/src/components/ui/CopyButton.tsx @@ -0,0 +1,51 @@ +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 = () => { + void (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/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); 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..2ee86a566 --- /dev/null +++ b/src/utils/clipboard.ts @@ -0,0 +1,22 @@ +/** + * 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?.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); +}