Skip to content
Open
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
360 changes: 348 additions & 12 deletions components/__tests__/markdown.test.tsx

Large diffs are not rendered by default.

85 changes: 85 additions & 0 deletions components/canvas/__tests__/canvas-code-block-newlines.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, it, expect } from 'vitest';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';

import { markdownToHtml } from '@/lib/utils';

// Regression for issue #2109: ProseMirror's DOM parser drops newline-only
// text nodes between inline <span>s inside <pre>, so highlighted HTML merged
// code lines ("Dictfrom", "log_executiondef") once loaded via setContent.

const ARTIFACT_FENCE = [
'```python',
'import json',
'from typing import List, Dict',
'from functools import wraps',
'import logging',
'',
'logger = logging.getLogger(__name__)',
'',
'def log_execution(func):',
' """Decorator to log function execution times and results."""',
' @wraps(func)',
' def wrapper(*args, **kwargs):',
' logger.info(f"Starting {func.__name__}")',
' result = func(*args, **kwargs)',
' logger.info(f"Completed {func.__name__}")',
' return result',
' return wrapper',
'',
'@log_execution',
'def process_data_batch(input_records: List[Dict]) -> List[Dict]:',
' """Process a batch of input records and return transformed results."""',
' processed_results = []',
' for record in input_records:',
' transformed_record = {',
' "id": record.get("id"),',
' "value": record.get("amount", 0) * 1.1',
' }',
' processed_results.append(transformed_record)',
' return processed_results',
'```',
].join('\n');

const createEditor = (content: string) =>
new Editor({
extensions: [
StarterKit.configure({
heading: false,
codeBlock: {
HTMLAttributes: {
class: 'bg-muted rounded-md p-4 font-mono text-sm',
},
},
}),
],
content,
});

describe('canvas code block newline preservation (issue #2109)', () => {
it('keeps every code line separate after markdownToHtml + setContent', () => {
const editor = createEditor(markdownToHtml(ARTIFACT_FENCE));
const text = editor.getText();
editor.destroy();

expect(text).toContain('Dict\nfrom functools');
expect(text).toContain('@log_execution\ndef process_data_batch');
expect(text).toContain('"""\n @wraps(func)');
expect(text).not.toContain('Dictfrom');
expect(text).not.toContain('log_executiondef');
});

it('preserves the full fence body line count end to end', () => {
const editor = createEditor(markdownToHtml(ARTIFACT_FENCE));
const text = editor.getText();
editor.destroy();

const fenceBody = ARTIFACT_FENCE.split('\n').slice(1, -1);
for (const line of fenceBody) {
if (line.trim()) {
expect(text).toContain(line);
}
}
expect(text.split('\n').length).toBeGreaterThanOrEqual(fenceBody.length);
});
});
17 changes: 17 additions & 0 deletions components/canvas/__tests__/canvas-component.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,23 @@ describe('CanvasComponent', () => {
expect(screen.getByText('Untitled Artifact')).toBeInTheDocument();
});

it('renders dark code blocks with a transparent inner code reset (issue #2109)', () => {
render(<CanvasComponent {...defaultProps} />);
const styles = Array.from(document.querySelectorAll('style'))
.map((s) => s.textContent || '')
.join('\n');
expect(styles).toContain('.ProseMirror pre code');
expect(styles).toMatch(
/\.ProseMirror pre code[\s\S]*?background: transparent !important/,
);
expect(styles).toMatch(
/\.ProseMirror pre code[\s\S]*?color: inherit !important/,
);
expect(styles).toMatch(
/\.ProseMirror pre,[\s\S]*?background: #1e1e1e !important/,
);
});

it('renders the FileText icon in header', () => {
render(<CanvasComponent {...defaultProps} />);
const svg = document.querySelector('svg.lucide-file-text');
Expand Down
18 changes: 18 additions & 0 deletions components/canvas/__tests__/canvas-rich-text-editor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,24 @@ describe('CanvasRichTextEditor', () => {
expect(result.current).toBeDefined();
});

it('scopes inline-code chip styling away from code blocks (issue #2109)', async () => {
const { useEditor } = await import('@tiptap/react');
(useEditor as any).mockClear();
renderHook(() =>
useCanvasRichTextEditor({
value: 'Test content',
onChange: vi.fn(),
}),
);
const options = (useEditor as any).mock.calls[0][0];
const editorClass = options.editorProps.attributes.class as string;
expect(editorClass).toContain('[&_:not(pre)>code]:bg-muted');
expect(editorClass).toContain('[&_pre_code]:bg-transparent');
expect(editorClass).toContain('[&_pre_code]:p-0');
expect(editorClass).not.toContain('[&_code]:bg-muted');
expect(editorClass).not.toContain('[&_pre]:bg-muted');
});

it('handles markdown value', () => {
const { result } = renderHook(() =>
useCanvasRichTextEditor({
Expand Down
10 changes: 8 additions & 2 deletions components/canvas/canvas-component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,6 @@ import { useCanvasVersionNavigation } from '@/hooks/use-canvas-version-navigatio

// Import KaTeX CSS for math rendering
import 'katex/dist/katex.min.css';
// Import highlight.js theme for code syntax highlighting
import 'highlight.js/styles/github.css';

interface CanvasComponentProps {
title?: string;
Expand Down Expand Up @@ -2497,6 +2495,14 @@ export function CanvasComponent({
[contenteditable] code {
font-family: 'Fira Code', 'Consolas', 'Monaco', monospace !important;
}
.ProseMirror pre code,
[contenteditable] pre code {
background: transparent !important;
color: inherit !important;
padding: 0 !important;
border-radius: 0 !important;
font-size: inherit !important;
}
.ProseMirror :not(pre) > code,
[contenteditable] :not(pre) > code {
background: #e5e7eb !important;
Expand Down
5 changes: 3 additions & 2 deletions components/canvas/canvas-rich-text-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -963,8 +963,9 @@ export function useCanvasRichTextEditor({
'[&_ol_ol_ol]:list-[lower-roman] [&_ol_ol_ol]:pl-4 ' +
'[&_li]:leading-7 [&_li]:pl-1 ' +
// Code
'[&_code]:bg-muted [&_code]:rounded [&_code]:px-1 [&_code]:py-0.5 ' +
'[&_pre]:bg-muted [&_pre]:rounded-md [&_pre]:p-4 [&_pre]:mb-4 ' +
'[&_:not(pre)>code]:bg-muted [&_:not(pre)>code]:rounded [&_:not(pre)>code]:px-1 [&_:not(pre)>code]:py-0.5 ' +
'[&_pre]:rounded-md [&_pre]:p-4 [&_pre]:mb-4 ' +
'[&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-inherit ' +
// Blockquote
'[&_blockquote]:border-l-4 [&_blockquote]:border-primary [&_blockquote]:pl-4 [&_blockquote]:mb-4 [&_blockquote]:italic [&_blockquote]:leading-7 ' +
// Links
Expand Down
3 changes: 2 additions & 1 deletion components/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import ReactMarkdown from 'react-markdown';
import 'katex/dist/katex.min.css';
import { cn } from '@/lib/utils';
import { preprocessLaTeX } from '@/lib/preprocess-latex';
import { normalizeListIndentation } from '@/lib/normalize-list-indentation';
import { components } from './markdown/markdown-components';

type Props = {
Expand Down Expand Up @@ -34,7 +35,7 @@ export default function Markdown({ children, className }: Props) {
return '';
}}
>
{preprocessLaTeX(children ?? '')}
{normalizeListIndentation(preprocessLaTeX(children ?? ''))}
</ReactMarkdown>
</div>
);
Expand Down
24 changes: 24 additions & 0 deletions components/markdown/__tests__/markdown-components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,18 @@ describe('Markdown Components', () => {
expect(ul).toBeTruthy();
expect(ul?.className).toContain('list-disc');
});

it('should tighten its margins when nested inside another list', () => {
const { container } = render(
<Ul node={{} as any}>
<li>Item</li>
</Ul>,
);
const ul = container.querySelector('ul');
expect(ul?.className).toContain('my-6');
expect(ul?.className).toContain('[ul_&]:my-1');
expect(ul?.className).toContain('[ol_&]:my-1');
});
});

describe('ol component', () => {
Expand All @@ -320,6 +332,18 @@ describe('Markdown Components', () => {
expect(ol).toBeTruthy();
expect(ol?.className).toContain('list-decimal');
});

it('should tighten its margins when nested inside another list', () => {
const { container } = render(
<Ol node={{} as any}>
<li>Item</li>
</Ol>,
);
const ol = container.querySelector('ol');
expect(ol?.className).toContain('my-6');
expect(ol?.className).toContain('[ul_&]:my-1');
expect(ol?.className).toContain('[ol_&]:my-1');
});
});

describe('li component', () => {
Expand Down
14 changes: 12 additions & 2 deletions components/markdown/markdown-components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,22 @@ export const components: Components = {
/>
),

// A nested list keeps the top-level my-6 unless overridden, which detaches
// it from its parent item by 24px on each side. The [ul_&]/[ol_&] variants
// win on specificity (.x ul beats .x) and collapse the gap only when the
// list sits inside another list.
ul: ({ node, ...props }) => (
<ul {...props} className="my-6 ml-6 list-disc [&>li]:mt-2" />
<ul
{...props}
className="my-6 ml-6 list-disc [&>li]:mt-2 [ol_&]:my-1 [ul_&]:my-1"
/>
),

ol: ({ node, ...props }) => (
<ol {...props} className="my-6 ml-6 list-decimal [&>li]:mt-2" />
<ol
{...props}
className="my-6 ml-6 list-decimal [&>li]:mt-2 [ol_&]:my-1 [ul_&]:my-1"
/>
),

// The scroll container has to be inside the <li>: a list item that is itself
Expand Down
56 changes: 46 additions & 10 deletions e2e/journeys/13-shareable-links-and-embed-integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,39 @@ function embedUrlFor(mentorUrl: string): string {
return url.toString();
}

/**
* Navigates to a mentor's embed view and resolves once the mentor-settings GET
* that carries `show_catalogue` has landed.
*
* `components/logo.tsx` gates clickability on
* `!embedMode || (mentorSettings?.showCatalogue ?? true)` — the `?? true` means
* the logo renders as a navigable <button> for as long as settings are still in
* flight. Asserting straight after `goto` therefore races the fetch: on a slow
* host (dev-mode compilation, cold API) the un-resolved default is read as
* "catalogue enabled" and the emb-07 assertion fails against a logo that would
* have become non-clickable a moment later.
*
* Waiting on the response removes the race regardless of host speed. The
* authenticated private endpoint (`.../mentors/<id>/settings/`) is the one whose
* value wins in `useMentorSettings` (private ?? public ?? true); `-settings/`
* variants such as `public-settings/` are deliberately excluded by the leading
* slash in the pattern.
*/
async function gotoEmbedWithSettingsLoaded(
page: Page,
mentorUrl: string,
): Promise<void> {
const settingsLoaded = page.waitForResponse(
(res) => /\/settings\/?$/.test(new URL(res.url()).pathname) && res.ok(),
{ timeout: 60_000 },
);
await page.goto(embedUrlFor(mentorUrl), {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await settingsLoaded;
}

/** Configures + persists the embed with a given Show Catalogue value (via UI). */
async function createEmbedWithShowCatalogue(
page: Page,
Expand Down Expand Up @@ -215,6 +248,10 @@ test.describe('Journey 13: Shareable Links & Embed Integration', () => {
editMentorPage,
sidebarPage,
}) => {
// Creates a mentor, saves its visibility, saves the embed, then loads the
// embed view — comfortably past the default per-test budget on a cold host.
test.slow();

const baseMentorUrl = page.url();
await createEmbedWithShowCatalogue(
page,
Expand All @@ -223,14 +260,11 @@ test.describe('Journey 13: Shareable Links & Embed Integration', () => {
false,
);

await page.goto(embedUrlFor(baseMentorUrl), {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await gotoEmbedWithSettingsLoaded(page, baseMentorUrl);
await sidebarPage.ensureExpanded(40_000);
await expect(sidebarPage.logoImage).toBeVisible({ timeout: 15_000 });
// The logo renders but is not wrapped in a navigable button. toHaveCount(0)
// retries past the brief loading window before settings resolve.
// Settings have resolved by now, so the logo must NOT be wrapped in a
// navigable button — this no longer races the optimistic default.
await expect(sidebarPage.logoButton).toHaveCount(0, { timeout: 10_000 });
});

Expand All @@ -241,6 +275,8 @@ test.describe('Journey 13: Shareable Links & Embed Integration', () => {
editMentorPage,
sidebarPage,
}) => {
test.slow();

const baseMentorUrl = page.url();
await createEmbedWithShowCatalogue(
page,
Expand All @@ -249,10 +285,10 @@ test.describe('Journey 13: Shareable Links & Embed Integration', () => {
true,
);

await page.goto(embedUrlFor(baseMentorUrl), {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
// Also waits for settings here: without it this test passes vacuously,
// since the pre-resolution default is exactly the clickable state it
// asserts. Waiting makes the pass mean "show_catalogue: true was read".
await gotoEmbedWithSettingsLoaded(page, baseMentorUrl);
await sidebarPage.ensureExpanded(40_000);
await expect(sidebarPage.logoButton).toBeVisible({ timeout: 15_000 });
});
Expand Down
13 changes: 6 additions & 7 deletions e2e/journeys/61-latex-math-rendering.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,12 @@ test.describe('Journey 61: LaTeX / Math Rendering', () => {
chatPage,
}) => {
// `remark-math` only classifies a `$$...$$` span as BLOCK (display) math
// when the delimiters each sit alone on their own line — confirmed by
// probing this exact build: a single-line `$$3x + 5$$` renders as
// inline `.katex` inside a `<p>` (no `.katex-display`), while the
// "own-line fence" form below renders `<span class="katex-display">`
// with `display="block"` in the MathML. This is also the form LLMs
// naturally emit for standalone equations, so it's the realistic case
// for the "block math" checkpoint.
// when the delimiters each sit alone on their own line. `preprocessLaTeX`
// now expands a whole-line single-line `$$3x + 5$$` into this fenced form
// (issue #2109 fix 9), so the fence below is both what LLMs naturally
// emit for standalone equations and the canonical shape every whole-line
// `$$...$$` is normalized to before rendering — the realistic case for
// the "block math" checkpoint.
const content = [
'$$',
'3x + 5',
Expand Down
11 changes: 10 additions & 1 deletion lib/__tests__/constants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,16 @@ describe('mentor constants', () => {
it('should have all default prompts', () => {
expect(DEFAULT_PROMPTS.DEFAULT_SYSTEM_PROMPT).toBeTruthy();
expect(DEFAULT_PROMPTS.DEFAULT_SYSTEM_PROMPT).toContain(
'helpful instructor',
'helpful assistant',
);
// LaTeX is scoped to math delimiters only. The old prompt's "Always use
// LaTeX formatting for presenting your responses" made models emit
// \begin{itemize}/\textbf document markup that KaTeX cannot render
// (issue #2109) -- it must never come back.
expect(DEFAULT_PROMPTS.DEFAULT_SYSTEM_PROMPT).toContain('$...$');
expect(DEFAULT_PROMPTS.DEFAULT_SYSTEM_PROMPT).toContain('$$...$$');
expect(DEFAULT_PROMPTS.DEFAULT_SYSTEM_PROMPT).not.toMatch(
/LaTeX formatting for presenting/i,
);

expect(DEFAULT_PROMPTS.DEFAULT_MODERATION_PROMPT).toBeTruthy();
Expand Down
Loading
Loading