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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,4 @@ coverage
# Git worktrees
.worktrees
.claude/worktrees
.gstack/
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ The UI morphs between two states: a compact spotlight-style input bar → an exp
- **`hooks/useOllama.ts`** — Tauri Channel-based streaming hook; emits `Token`, `Done`, `Error` variants
- **`view/ConversationView.tsx`** — smart auto-scroll (pins to bottom unless user scrolls up)
- **`view/AskBarView.tsx`** — auto-expanding textarea (max 144px), morphs logo size
- **`components/ChatBubble.tsx`** — markdown rendering with DOMPurify sanitization
- **`components/ChatBubble.tsx`** — markdown rendering via Streamdown (rehype-sanitize for XSS protection)

### Backend (`src-tauri/src/`)

Expand Down
292 changes: 288 additions & 4 deletions bun.lock

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@
"framer-motion": "^12.38.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1"
"streamdown": "^2.5.0"
},
"overrides": {
"picomatch": ">=4.0.4"
"picomatch": ">=4.0.4",
"lodash-es": ">=4.18.0"
},
"devDependencies": {
"@eslint-react/eslint-plugin": "3.0.0",
Expand Down
2 changes: 2 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
@import 'tailwindcss';
@source "../node_modules/streamdown/dist";
@import 'streamdown/styles.css';

@theme {
--font-sans:
Expand Down
5 changes: 4 additions & 1 deletion src/components/ChatBubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ interface ChatBubbleProps {
index: number;
/** Selected text from the host app that was quoted alongside this message, if any. */
quotedText?: string;
/** Whether this bubble is actively streaming content from the LLM. */
isStreaming?: boolean;
}

/**
Expand Down Expand Up @@ -51,6 +53,7 @@ export function ChatBubble({
content,
index,
quotedText,
isStreaming = false,
}: ChatBubbleProps) {
const isUser = role === 'user';

Expand Down Expand Up @@ -85,7 +88,7 @@ export function ChatBubble({
<span className="text-white/95 font-medium">{content}</span>
</>
) : (
<MarkdownRenderer content={content} />
<MarkdownRenderer content={content} isStreaming={isStreaming} />
)}
</div>

Expand Down
48 changes: 32 additions & 16 deletions src/components/MarkdownRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,49 @@
import React, { memo } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Streamdown } from 'streamdown';

interface MarkdownRendererProps {
content: string;
className?: string;
/** Whether this content is actively being streamed from the LLM. */
isStreaming?: boolean;
}

/** Remark plugins applied to every render. Stable reference prevents re-initialization. */
const remarkPlugins = [remarkGfm];

/**
* Renders markdown content as React elements using react-markdown.
* Renders markdown content using Streamdown, a streaming-aware markdown
* renderer that handles incomplete syntax and memoizes completed blocks.
*
* During streaming, only the last in-progress block re-renders on each
* token. Completed paragraphs are memoized and never reflow, eliminating
* the bubble-height jitter caused by full markdown re-parsing.
*
* Secure by design: react-markdown converts markdown to a React element tree
* via `createElement`, never using `dangerouslySetInnerHTML`. Raw HTML in
* markdown source is stripped by default, preventing XSS without an external
* sanitizer. Supports GitHub Flavored Markdown (tables, strikethrough, task
* lists, autolinks) via the remark-gfm plugin.
* Security: Streamdown sanitizes rendered output via rehype-sanitize
* (allowlist-based HTML element/attribute filtering) and rehype-harden
* (blocks dangerous URL protocols). Raw HTML in markdown source is parsed
* then sanitized, stripping script tags, event handlers, iframes, and
* javascript: URLs. Link safety is disabled so links render as native
* anchor elements with target="_blank" and rel="noopener noreferrer".
*
* Memoized to skip re-renders when props are unchanged, which matters during
* LLM token streaming where sibling bubbles would otherwise re-render on
* every new token.
* Memoized to skip re-renders when props are unchanged, which matters
* during LLM token streaming where sibling bubbles would otherwise
* re-render on every new token.
*/
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
function MarkdownRenderer({ content, className = '' }) {
function MarkdownRenderer({ content, className = '', isStreaming = false }) {
return (
<span className={`markdown-body ${className}`}>
<ReactMarkdown remarkPlugins={remarkPlugins}>{content}</ReactMarkdown>
<Streamdown
mode={isStreaming ? 'streaming' : 'static'}
/* Disable built-in copy/download controls; the parent ChatBubble
provides its own CopyButton for the full message content. */
controls={false}
/* Disable the link safety interstitial modal so links render as
native <a> elements with href, target="_blank", and noopener.
In a Tauri app the webview opens external links in the system
browser, making the modal unnecessary friction. */
linkSafety={{ enabled: false }}
>
{content}
</Streamdown>
</span>
);
},
Expand Down
8 changes: 5 additions & 3 deletions src/components/__tests__/ChatBubble.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,14 @@ describe('ChatBubble', () => {
});

describe('Assistant messages', () => {
it('renders assistant content via MarkdownRenderer (** becomes <strong>)', () => {
it('renders assistant content via MarkdownRenderer (** becomes bold)', () => {
const { container } = render(
<ChatBubble role="assistant" content="**bold**" index={0} />,
);
expect(container.querySelector('strong')).not.toBeNull();
expect(container.querySelector('strong')!.textContent).toBe('bold');
// Streamdown renders bold as span with data-streamdown="strong"
const bold = container.querySelector('[data-streamdown="strong"]');
expect(bold).not.toBeNull();
expect(bold!.textContent).toBe('bold');
});

it('applies assistant styling (chat-bubble-ai class)', () => {
Expand Down
16 changes: 11 additions & 5 deletions src/components/__tests__/MarkdownRenderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,18 +65,22 @@ describe('MarkdownRenderer', () => {
const { container } = render(
<MarkdownRenderer content="[Visit site](https://example.com)" />,
);
const link = container.querySelector('a');
const link = container.querySelector('a[data-streamdown="link"]');
expect(link).not.toBeNull();
expect(link!.getAttribute('href')).toBe('https://example.com');
expect(link!.getAttribute('href')).toBe('https://example.com/');
expect(link!.getAttribute('rel')).toBe('noopener noreferrer');
expect(link!.getAttribute('target')).toBe('_blank');
expect(link!.textContent).toBe('Visit site');
});

it('renders bold text', () => {
const { container } = render(
<MarkdownRenderer content="This is **bold** text" />,
);
expect(container.querySelector('strong')).not.toBeNull();
expect(container.querySelector('strong')!.textContent).toBe('bold');
// Streamdown renders bold as span with data-streamdown="strong"
const bold = container.querySelector('[data-streamdown="strong"]');
expect(bold).not.toBeNull();
expect(bold!.textContent).toBe('bold');
});

it('renders italic text', () => {
Expand Down Expand Up @@ -169,7 +173,9 @@ describe('MarkdownRenderer', () => {
const { container } = render(
<MarkdownRenderer content="**bold** and *italic* and `code`" />,
);
expect(container.querySelector('strong')).not.toBeNull();
expect(
container.querySelector('[data-streamdown="strong"]'),
).not.toBeNull();
expect(container.querySelector('em')).not.toBeNull();
expect(container.querySelector('code')).not.toBeNull();
});
Expand Down
1 change: 1 addition & 0 deletions src/view/ConversationView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ export function ConversationView({
role="assistant"
content={streamingContent}
index={messages.length}
isStreaming
/>
) : null}

Expand Down
7 changes: 5 additions & 2 deletions src/view/__tests__/ConversationView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ describe('ConversationView', () => {
});

it('renders streaming bubble when streamingContent is non-empty', () => {
render(
const { container } = render(
<ConversationView
messages={[]}
streamingContent="streaming response..."
Expand All @@ -31,7 +31,10 @@ describe('ConversationView', () => {
onClose={vi.fn()}
/>,
);
expect(screen.getByText('streaming response...')).toBeInTheDocument();
// Streamdown splits streaming text into per-word animated spans,
// so exact full-text match won't work. Check for content presence.
expect(container.textContent).toContain('streaming');
expect(container.textContent).toContain('response...');
});

it('shows TypingIndicator when isGenerating with no streaming content', () => {
Expand Down