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
4 changes: 4 additions & 0 deletions docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**
Expand Down
6 changes: 3 additions & 3 deletions fmt.mk
Original file line number Diff line number Diff line change
Expand Up @@ -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!"
Expand Down Expand Up @@ -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),)
Expand Down
39 changes: 31 additions & 8 deletions src/App.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand All @@ -522,7 +545,7 @@ export const ActiveWorkspaceWithChat: Story = {
},
],
metadata: {
historySequence: 6,
historySequence: 7,
timestamp: STABLE_TIMESTAMP - 230000,
model: "claude-sonnet-4-20250514",
usage: {
Expand All @@ -536,7 +559,7 @@ export const ActiveWorkspaceWithChat: Story = {

// User follow-up about error handling
callback({
id: "msg-7",
id: "msg-8",
role: "user",
parts: [
{
Expand All @@ -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: [
{
Expand Down Expand Up @@ -582,7 +605,7 @@ export const ActiveWorkspaceWithChat: Story = {
},
],
metadata: {
historySequence: 8,
historySequence: 9,
timestamp: STABLE_TIMESTAMP - 170000,
model: "claude-sonnet-4-20250514",
usage: {
Expand Down
3 changes: 2 additions & 1 deletion src/components/Messages/AssistantMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
Expand All @@ -25,7 +26,7 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
className,
workspaceId,
isCompacting = false,
clipboardWriteText = (data: string) => navigator.clipboard.writeText(data),
clipboardWriteText = copyToClipboard,
}) => {
const [showRaw, setShowRaw] = useState(false);

Expand Down
91 changes: 71 additions & 20 deletions src/components/Messages/MarkdownComponents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
mapToShikiLang,
SHIKI_THEME,
} from "@/utils/highlighting/shikiHighlighter";
import { CopyButton } from "@/components/ui/CopyButton";

interface CodeProps {
node?: unknown;
Expand Down Expand Up @@ -37,12 +38,36 @@ interface CodeBlockProps {
language: string;
}

/**
* Extract line contents from Shiki HTML output
* Shiki wraps code in <pre><code>...</code></pre> with <span class="line">...</span> per line
*/
function extractShikiLines(html: string): string[] {
const codeMatch = /<code[^>]*>(.*?)<\/code>/s.exec(html);
if (!codeMatch) return [];

return codeMatch[1].split("\n").map((chunk) => {
const start = chunk.indexOf('<span class="line">');
if (start === -1) return "";

const contentStart = start + '<span class="line">'.length;
const end = chunk.lastIndexOf("</span>");

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<CodeBlockProps> = ({ code, language }) => {
const [html, setHtml] = useState<string | null>(null);
const [highlightedLines, setHighlightedLines] = useState<string[] | null>(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;
Expand All @@ -52,41 +77,67 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ 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 (
<pre>
<code>{code}</code>
</pre>
);
}

// Render highlighted HTML
return <div dangerouslySetInnerHTML={{ __html: html }} />;
const lines = highlightedLines ?? plainLines;

return (
<div className="code-block-wrapper">
<div className="code-block-container">
{lines.map((content, idx) => (
<React.Fragment key={idx}>
<div className="line-number">{idx + 1}</div>
{/* SECURITY AUDIT: dangerouslySetInnerHTML usage
* Source: Shiki syntax highlighter (highlighter.codeToHtml)
* Safety: Shiki escapes all user content before wrapping in <span> 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 <code> block, but per-line rendering
* required for line numbers in CSS grid layout
*/}
<div
className="code-line"
{...(highlightedLines
? { dangerouslySetInnerHTML: { __html: content } }
: { children: <code>{content}</code> })}
/>
</React.Fragment>
))}
</div>
<CopyButton text={code} className="code-copy-button" />
</div>
);
};

// Custom components for markdown rendering
Expand Down
12 changes: 2 additions & 10 deletions src/components/Messages/UserMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
Expand All @@ -15,21 +16,12 @@ interface UserMessageProps {
clipboardWriteText?: (data: string) => Promise<void>;
}

async function defaultClipboardWriteText(data: string): Promise<void> {
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<UserMessageProps> = ({
message,
className,
onEdit,
isCompacting,
clipboardWriteText = defaultClipboardWriteText,
clipboardWriteText = copyToClipboard,
}) => {
const content = message.content;

Expand Down
21 changes: 21 additions & 0 deletions src/components/icons/CopyIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import React from "react";

interface CopyIconProps {
className?: string;
}

export const CopyIcon: React.FC<CopyIconProps> = ({ className }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
);
Loading