Repository: mito-ds/mito
Pull request #1574: Citations UI
Author: login: aarondr77
Created: 2025-03-14T21:26:21Z | Merged: 2025-03-17T20:40:49Z

# Description

Let the AI cite its work. When the user clicks on a citation, scroll the notebook there and highlight the correct line!

# Testing

Add a few different code cells to your notebook. 

Then, add this code cell 

```
max_value = 1000
min_value = -1
average_value = 30
```

Then, add this input: 

The min value is -1 {"type": "citation", "cell_id": "<fill in cell id>", "line": 1} and the max_value is 1000  {"type": "citation", "cell_id": "<fill in cell id>", "line": 0}

When you click on each of those, it should take you that line in the notebook and highlight it purple for a few seconds. 

Once we merge in the backend PR, the agent will include these in its responses, and we will get conversations that look like this: 

<img width="327" alt="Screenshot 2025-03-16 at 9 39 30 PM" src="https://github.com/user-attachments/assets/41d0996a-b386-4f3a-83bb-9d387f884f3a" />

# Documentation

Yes. 


Diff:
diff --git a/mito-ai/src/Extensions/AiChat/ChatMessage/ChatMessage.tsx b/mito-ai/src/Extensions/AiChat/ChatMessage/ChatMessage.tsx
index a52e6d42e5..950f3b0db4 100644
--- a/mito-ai/src/Extensions/AiChat/ChatMessage/ChatMessage.tsx
+++ b/mito-ai/src/Extensions/AiChat/ChatMessage/ChatMessage.tsx
@@ -206,6 +206,7 @@ const ChatMessage: React.FC<IChatMessageProps> = ({
                                     <MarkdownBlock
                                         markdown={messagePart}
                                         renderMimeRegistry={renderMimeRegistry}
+                                        notebookTracker={notebookTracker}
                                     />
                                 )}
                             </p>
diff --git a/mito-ai/src/Extensions/AiChat/ChatMessage/Citation.tsx b/mito-ai/src/Extensions/AiChat/ChatMessage/Citation.tsx
new file mode 100644
index 0000000000..490da5a582
--- /dev/null
+++ b/mito-ai/src/Extensions/AiChat/ChatMessage/Citation.tsx
@@ -0,0 +1,31 @@
+import React from 'react';
+import { scrollToCell } from '../../../utils/notebook';
+import { INotebookTracker } from '@jupyterlab/notebook';
+import '../../../../style/Citation.css';
+
+// Citation component props interface
+export interface CitationProps {
+  citationIndex: number;
+  cellId: string;
+  line: number;
+  notebookTracker: INotebookTracker;
+}
+
+// Citation button component
+export const Citation: React.FC<CitationProps> = ({ citationIndex, cellId, line, notebookTracker }): JSX.Element => {
+  const handleClick = (): void => {
+    scrollToCell(notebookTracker, cellId, line);
+  };
+
+  return (
+    <span
+      className="citation-button"
+      onClick={handleClick}
+      title={`Line ${line}`}
+    >
+      {citationIndex}
+    </span>
+  );
+};
+
+export default Citation; 
\ No newline at end of file
diff --git a/mito-ai/src/Extensions/AiChat/ChatMessage/MarkdownBlock.tsx b/mito-ai/src/Extensions/AiChat/ChatMessage/MarkdownBlock.tsx
index 91f06604ae..fbcb6bd446 100644
--- a/mito-ai/src/Extensions/AiChat/ChatMessage/MarkdownBlock.tsx
+++ b/mito-ai/src/Extensions/AiChat/ChatMessage/MarkdownBlock.tsx
@@ -1,35 +1,235 @@
-import React, { useEffect, useState } from 'react';
+import React, { useEffect, useState, useRef, useCallback } from 'react';
 import { IRenderMimeRegistry, MimeModel } from '@jupyterlab/rendermime';
+import { createPortal } from 'react-dom';
+import { Citation, CitationProps } from './Citation';
+import { INotebookTracker } from '@jupyterlab/notebook';
 
+/**
+ * React Portals in Markdown Rendering
+ * -----------------------------------
+ * 
+ * This component uses React Portals to integrate interactive React components (Citations) 
+ * into markdown content that's rendered by JupyterLab's renderer (non-React).
+ * 
+ * Why Portals?
+ * ------------
+ * 1. The markdown content is processed by JupyterLab's renderer which outputs regular DOM nodes,
+ *    not React components.
+ * 2. We need to insert interactive React components (Citations) at specific points within this
+ *    rendered content.
+ * 3. React's normal component hierarchy can't directly insert components into arbitrary DOM positions.
+ * 
+ * How Portals are Used Here:
+ * -------------------------
+ * 1. Citation data is extracted from the markdown and replaced with placeholder text ({{citation-n}})
+ * 2. The markdown with placeholders is rendered to HTML using JupyterLab's renderer
+ * 3. The rendered HTML is searched for placeholder text
+ * 4. When placeholders are found, they're replaced with:
+ *    - The surrounding text content
+ *    - Empty <span> elements at the placeholder positions
+ * 5. React Portals are created for each <span>, which render Citation components into those
+ *    specific DOM locations
+ * 
+ * Benefits:
+ * --------
+ * - We can use a non-React renderer while still having interactive React components
+ * - Citations appear exactly where they should in the rendered markdown
+ * - React maintains control of the Citation components (event handling, updates, etc.)
+ * - The approach is clean and maintainable compared to alternatives
+ */
+
+// Citation Portal component to render Citation into DOM
+interface CitationPortalProps extends CitationProps {
+    container: HTMLElement;
+}
+
+const CitationPortal: React.FC<CitationPortalProps> = ({ container, ...props }) => {
+    return createPortal(<Citation {...props} />, container);
+};
 
 interface IMarkdownCodeProps {
     markdown: string;
     renderMimeRegistry: IRenderMimeRegistry;
+    notebookTracker: INotebookTracker;
 }
 
-const MarkdownBlock: React.FC<IMarkdownCodeProps> = ({ markdown, renderMimeRegistry }) => {
-    const [renderedContent, setRenderedContent] = useState<JSX.Element | null>(null);
+interface Citation {
+    id: string;
+    data: {
+        citation_index: number;
+        cell_id: string;
+        line: number;
+    };
+}
 
+const MarkdownBlock: React.FC<IMarkdownCodeProps> = ({ markdown, renderMimeRegistry, notebookTracker }) => {
+    const [citationPortals, setCitationPortals] = useState<React.ReactElement[]>([]);
+    const containerRef = useRef<HTMLDivElement>(null);
 
-    useEffect(() => {
-        const renderMarkdown = async (): Promise<void> => {
+    // Extract citations from the markdown, returning the markdown with the JSON citations replaced with 
+    // citation placeholders {{${id}}} and an array of citation objects.
+    const extractCitations = useCallback((text: string): { processedMarkdown: string; citations: Citation[] } => {
+        // eslint-disable-next-line no-useless-escape
+        const citationRegex = /(\{\"type\"\:\s*\"citation\".*?\})/g;
+        const citations: Citation[] = [];
+        let counter = 0;
+
+        // Replace each citation with a placeholder
+        const processedMarkdown = text.replace(citationRegex, (match) => {
+            try {
+                const citation = JSON.parse(match);
+                const id = `citation-${counter++}`;
+                citations.push({ id, data: { citation_index: counter, ...citation} });
+                return `{{${id}}}`;
+            } catch (e) {
+                console.error("Failed to parse citation JSON:", e);
+                return match;
+            }
+        });
+
+        return { processedMarkdown, citations };
+    }, []);
+
+    // Uses the Jupyter markdowm MimeRenderer to render the markdown content as normal HTML
+    const renderMarkdownContent = useCallback(async (processedMarkdown: string): Promise<void> => {
+        if (!processedMarkdown || !containerRef.current) return;
+
+        try {
             const model = new MimeModel({
-                data: { ['text/markdown']: markdown },
+                data: { ['text/markdown']: processedMarkdown },
             });
 
             const renderer = renderMimeRegistry.createRenderer('text/markdown');
             await renderer.renderModel(model);
 
-            const node = renderer.node;
-            setRenderedContent(<div ref={(el) => el && el.appendChild(node)} />);
+            // Clear previous content
+            containerRef.current.innerHTML = '';
+            containerRef.current.appendChild(renderer.node.cloneNode(true));
+        } catch (error) {
+            console.error("Error rendering markdown:", error);
+        }
+    }, [renderMimeRegistry]);
+
+    // Replace the citation placeholders with Citation Components in the DOM
+    const createCitationPortals = useCallback((citations: Citation[]): React.ReactElement[] => {
+        if (!containerRef.current || citations.length === 0) return [];
+
+        const newPortals: React.ReactElement[] = [];
+
+        // Create a map of placeholder to citation for faster lookup
+        const citationMap = new Map(citations.map(citation => [`{{${citation.id}}}`, citation]));
+
+        // Find all text nodes that contain our placeholder like {{citation-id}}).
+        // Since these placeholders exist within the text content (not as separate DOM elements):
+        //  - Find all text nodes in the rendered markdown
+        //  - Check each one to see if it contains any of your placeholders
+        //  - Process those that do contain placeholders
+        const textNodes: Text[] = [];
+        const walker = document.createTreeWalker(containerRef.current, NodeFilter.SHOW_TEXT);
+
+        let textNode;
+        while ((textNode = walker.nextNode() as Text)) {
+            textNodes.push(textNode);
+        }
+
+        // Process all text nodes in a single pass
+        textNodes.forEach(node => {
+            if (!node.nodeValue) return;
+
+            // Check if this node contains any placeholders
+            let containsPlaceholder = false;
+            for (const placeholder of citationMap.keys()) {
+                if (node.nodeValue.includes(placeholder)) {
+                    containsPlaceholder = true;
+                    break;
+                }
+            }
+
+            if (!containsPlaceholder) return;
+
+            // Create a regex to match all placeholders
+            const placeholderPattern = /\{\{citation-\d+\}\}/g;
+            const matches = [...node.nodeValue.matchAll(placeholderPattern)];
+
+            if (matches.length === 0) return;
+
+            // Split the text by all placeholders and create a fragment
+            const fragment = document.createDocumentFragment();
+            let lastIndex = 0;
+
+            matches.forEach(match => {
+                const placeholder = match[0];
+                const citation = citationMap.get(placeholder);
+                if (!citation) return;
+
+                const startIndex = match.index!;
+
+                // Add text before the placeholder
+                if (startIndex > lastIndex) {
+                    fragment.appendChild(
+                        document.createTextNode(node.nodeValue!.substring(lastIndex, startIndex))
+                    );
+                }
+
+                // Create span for the citation
+                const span = document.createElement('span');
+                span.classList.add('citation-container');
+                span.dataset.citationId = citation.id;
+                fragment.appendChild(span);
+
+                // Create React portal for this span
+                newPortals.push(
+                    <CitationPortal
+                        key={citation.id + '-' + matches.indexOf(match)}
+                        container={span}
+                        citationIndex={citation.data.citation_index}
+                        cellId={citation.data.cell_id}
+                        line={citation.data.line}
+                        notebookTracker={notebookTracker}
+                    />
+                );
+
+                lastIndex = startIndex + placeholder.length;
+            });
+
+            // Add any remaining text after the last placeholder
+            if (lastIndex < node.nodeValue.length) {
+                fragment.appendChild(
+                    document.createTextNode(node.nodeValue.substring(lastIndex))
+                );
+            }
+
+            // Replace the original text node with our fragment
+            if (node.parentNode) {
+                node.parentNode.replaceChild(fragment, node);
+            }
+        });
+
+        return newPortals;
+    }, []);
+
+    // Process everything in one effect, but with clear separation via helper functions
+    useEffect(() => {
+        const processMarkdown = async (): Promise<void> => {
+            // Step 1: Extract citations and get processed markdown
+            const { processedMarkdown, citations } = extractCitations(markdown);
+
+            // Step 2: Render markdown with placeholders
+            await renderMarkdownContent(processedMarkdown);
+
+            // Step 3: Create and insert citation portals
+            const portals = createCitationPortals(citations);
+            setCitationPortals(portals);
         };
 
-        void renderMarkdown();
-    }, [markdown, renderMimeRegistry]);
+        void processMarkdown();
+    }, [markdown, extractCitations, renderMarkdownContent, createCitationPortals]);
 
     return (
-        <div>{renderedContent || <div></div>} </div>
+        <div ref={containerRef} className="markdown-block-with-citations">
+            {citationPortals}
+        </div>
     );
 };
 
-export default MarkdownBlock;
+export default MarkdownBlock;
\ No newline at end of file
diff --git a/mito-ai/src/utils/notebook.tsx b/mito-ai/src/utils/notebook.tsx
index a563d5ce94..8436baa024 100644
--- a/mito-ai/src/utils/notebook.tsx
+++ b/mito-ai/src/utils/notebook.tsx
@@ -36,7 +36,7 @@ export const getCellIndexByID = (notebookTracker: INotebookTracker, cellID: stri
     const cellList = notebookTracker.currentWidget?.model?.cells
 
     if (cellList === undefined) {
-        return undefined 
+        return undefined
     }
 
     // In order to get the cell index, we need to iterate over the cells and call the `get` method
@@ -49,7 +49,7 @@ export const getCellIndexByID = (notebookTracker: INotebookTracker, cellID: stri
         }
     }
 
-    return undefined 
+    return undefined
 }
 
 export const setActiveCellByID = (notebookTracker: INotebookTracker, cellID: string | undefined): void => {
@@ -62,8 +62,8 @@ export const setActiveCellByID = (notebookTracker: INotebookTracker, cellID: str
 
 
 export const writeCodeToCellByID = (
-    notebookTracker: INotebookTracker, 
-    code: string | undefined, 
+    notebookTracker: INotebookTracker,
+    code: string | undefined,
     codeCellID: string,
 ): void => {
     if (code === undefined) {
@@ -76,7 +76,7 @@ export const writeCodeToCellByID = (
 
     console.log("codeMirrorValidCode", codeMirrorValidCode)
     console.log("cell", cell)
-    
+
     if (cell) {
         cell.model.sharedModel.source = codeMirrorValidCode;
     }
@@ -97,7 +97,7 @@ export const getAIOptimizedCells = (
     const cells: AIOptimizedCell[] = []
     for (let i = 0; i < cellList.length; i++) {
         const cellModel = cellList.get(i)
-        
+
         const cell: AIOptimizedCell = {
             id: cellModel.id,
             cell_type: cellModel.type,
@@ -155,10 +155,10 @@ export const highlightCodeCell = (notebookTracker: INotebookTracker, codeCellID:
     if (cell) {
         const cellElement = cell.node;
         const originalBackground = cellElement.style.background;
-        
+
         // Add a yellow highlight
         cellElement.style.background = 'var(--purple-400)';
-        
+
         // Remove highlight after 500ms
         cellElement.style.transition = 'background 0.5s ease';
         setTimeout(() => {
@@ -167,3 +167,90 @@ export const highlightCodeCell = (notebookTracker: INotebookTracker, codeCellID:
     }
 }
 
+export const highlightLineOfCodeInCodeCell = (notebookTracker: INotebookTracker, codeCellID: string, lineNumber: number): void => {
+    /*
+        Briefly highlights a specific line in a code cell, to draw the user's attention to it.
+        
+        Args:
+            notebookTracker: The notebook tracker.
+            codeCellID: The ID of the code cell.
+            lineNumber: The 0-indexed line number to highlight.
+    */
+    // Get the cell with the given ID
+    const cell = getCellByID(notebookTracker, codeCellID);
+    if (!cell) {
+        return;
+    }
+
+    // Get the cell's editor
+    const editor = cell.editor;
+    if (!editor) {
+        return;
+    }
+
+    // We expect the line number to be 0-indexed. To be safe, if the line number is out of bounds, we clamp it.
+    const lines = editor.model.sharedModel.source.split('\n');
+    const targetLine = Math.min(Math.max(lineNumber, 0), lines.length - 1);
+
+    // Find the line element in the DOM
+    const cmEditor = cell.node.querySelector('.jp-Editor');
+    if (!cmEditor) {
+        return;
+    }
+
+    // Find all line elements and get the one at the target index
+    const lineElements = cmEditor.querySelectorAll('.cm-line');
+    if (targetLine >= lineElements.length) {
+        return;
+    }
+
+    const lineElement = lineElements[targetLine] as HTMLElement;
+    if (!lineElement) {
+        return;
+    }
+
+    // Store the original background color
+    const originalBackground = lineElement.style.background;
+
+    // Change the background color to highlight the line
+    lineElement.style.background = 'var(--purple-400)';
+    lineElement.style.transition = 'background 0.5s ease';
+
+    // Reset the background color after a delay
+    setTimeout(() => {
+        lineElement.style.background = originalBackground;
+    }, 2000);
+}
+
+export const scrollToCell = (notebookTracker: INotebookTracker, cellID: string, lineNumber?: number): void => {
+
+    // First activate the cell
+    setActiveCellByID(notebookTracker, cellID);
+
+    // Get the cell
+    const cell = getCellByID(notebookTracker, cellID);
+    if (!cell) {
+        return;
+    }
+
+    // Get the cell's editor
+    const editor = cell.editor;
+    if (!editor) {
+        return;
+    }
+
+    // Make the cell node visible by scrolling to it
+    const cellNode = cell.node;
+    if (cellNode) {
+        cellNode.scrollIntoView({ behavior: 'smooth', block: 'center' });
+
+        // Wait for the scroll animation to complete before highlighting the line
+        // The default smooth scroll takes about 300-500ms to complete
+        setTimeout(() => {
+            if (lineNumber !== undefined) {
+                highlightLineOfCodeInCodeCell(notebookTracker, cellID, lineNumber);
+            }
+        }, 500);
+    }
+}
+
diff --git a/mito-ai/style/Citation.css b/mito-ai/style/Citation.css
new file mode 100644
index 0000000000..364012e226
--- /dev/null
+++ b/mito-ai/style/Citation.css
@@ -0,0 +1,20 @@
+.citation-button {
+    background-color: var(--purple-400);
+    color: var(--purple-700);
+    height: 14px;
+    width: 14px;
+    border-radius: 2px;
+    font-size: 12px;
+    cursor: pointer;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    position: relative;
+    top: -0.5em; /* Makes it appear as superscript */
+    vertical-align: baseline;
+    margin-right: 2px;
+}
+
+.citation-button:hover {
+    background-color: var(--purple-500);
+}
\ No newline at end of file


Reviews:
user: login: aarondr77
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T01:30:51Z
user: login: ngafar
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-17T16:24:48Z
user: login: ngafar
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-17T20:39:34Z

Review comments:
diff_hunk: @@ -116,6 +116,8 @@ const ChatMessage: React.FC<IChatMessageProps> = ({
         return <></>
     }
 
+    console.log('messageContentParts', messageContentParts);
path: mito-ai/src/Extensions/AiChat/ChatMessage/ChatMessage.tsx
user: login: aarondr77
body: remove
created_at: 2025-03-17T01:29:54Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,32 @@
+import React from 'react';
+import { scrollToCell } from '../../../utils/notebook';
+import { INotebookTracker } from '@jupyterlab/notebook';
+import '../../../../style/Citation.css';
+
+// Citation component props interface
+export interface CitationProps {
+  citationIndex: number;
+  cellId: string;
+  line: number;
+  notebookTracker: INotebookTracker;
+}
+
+// Citation button component
+export const Citation: React.FC<CitationProps> = ({ citationIndex, cellId, line, notebookTracker }) => {
+  const handleClick = () => {
+    console.log("citationIndex", citationIndex)
path: mito-ai/src/Extensions/AiChat/ChatMessage/Citation.tsx
user: login: aarondr77
body: remove
created_at: 2025-03-17T01:30:01Z
author_association: MEMBER
diff_hunk: @@ -1,35 +1,234 @@
-import React, { useEffect, useState } from 'react';
+import React, { useEffect, useState, useRef, useCallback } from 'react';
 import { IRenderMimeRegistry, MimeModel } from '@jupyterlab/rendermime';
+import { createPortal } from 'react-dom';
+import { Citation, CitationProps } from './Citation';
+import { INotebookTracker } from '@jupyterlab/notebook';
 
+/**
+ * React Portals in Markdown Rendering
+ * -----------------------------------
+ * 
+ * This component uses React Portals to integrate interactive React components (Citations) 
+ * into markdown content that's rendered by JupyterLab's renderer (non-React).
+ * 
+ * Why Portals?
+ * ------------
+ * 1. The markdown content is processed by JupyterLab's renderer which outputs regular DOM nodes,
+ *    not React components.
+ * 2. We need to insert interactive React components (Citations) at specific points within this
+ *    rendered content.
+ * 3. React's normal component hierarchy can't directly insert components into arbitrary DOM positions.
+ * 
+ * How Portals are Used Here:
+ * -------------------------
+ * 1. Citation data is extracted from the markdown and replaced with placeholder text ({{citation-n}})
+ * 2. The markdown with placeholders is rendered to HTML using JupyterLab's renderer
+ * 3. The rendered HTML is searched for placeholder text
+ * 4. When placeholders are found, they're replaced with:
+ *    - The surrounding text content
+ *    - Empty <span> elements at the placeholder positions
+ * 5. React Portals are created for each <span>, which render Citation components into those
+ *    specific DOM locations
+ * 
+ * Benefits:
+ * --------
+ * - We can use a non-React renderer while still having interactive React components
+ * - Citations appear exactly where they should in the rendered markdown
+ * - React maintains control of the Citation components (event handling, updates, etc.)
+ * - The approach is clean and maintainable compared to alternatives
+ */
+
+// Citation Portal component to render Citation into DOM
+interface CitationPortalProps extends CitationProps {
+    container: HTMLElement;
+}
+
+const CitationPortal: React.FC<CitationPortalProps> = ({ container, ...props }) => {
+    return createPortal(<Citation {...props} />, container);
+};
 
 interface IMarkdownCodeProps {
     markdown: string;
     renderMimeRegistry: IRenderMimeRegistry;
+    notebookTracker: INotebookTracker;
 }
 
-const MarkdownBlock: React.FC<IMarkdownCodeProps> = ({ markdown, renderMimeRegistry }) => {
-    const [renderedContent, setRenderedContent] = useState<JSX.Element | null>(null);
+interface Citation {
+    id: string;
+    data: {
+        citation_index: number;
+        cell_id: string;
+        line: number;
+    };
+}
 
+const MarkdownBlock: React.FC<IMarkdownCodeProps> = ({ markdown, renderMimeRegistry, notebookTracker }) => {
+    const [citationPortals, setCitationPortals] = useState<React.ReactElement[]>([]);
+    const containerRef = useRef<HTMLDivElement>(null);
 
-    useEffect(() => {
-        const renderMarkdown = async (): Promise<void> => {
+    // Extract citations from the markdown, returning the markdown with the JSON citations replaced with 
+    // citation placeholders {{${id}}} and an array of citation objects.
+    const extractCitations = useCallback((text: string): { processedMarkdown: string; citations: Citation[] } => {
+        const citationRegex = /(\{\"type\"\:\s*\"citation\".*?\})/g;
+        const citations: Citation[] = [];
+        let counter = 0;
+
+        // Replace each citation with a placeholder
+        const processedMarkdown = text.replace(citationRegex, (match) => {
+            try {
+                const citation = JSON.parse(match);
+                const id = `citation-${counter++}`;
+                citations.push({ id, data: { citation_index: counter, ...citation} });
path: mito-ai/src/Extensions/AiChat/ChatMessage/MarkdownBlock.tsx
user: login: aarondr77
body: remove
created_at: 2025-03-17T01:30:11Z
author_association: MEMBER
diff_hunk: @@ -167,3 +167,99 @@ export const highlightCodeCell = (notebookTracker: INotebookTracker, codeCellID:
     }
 }
 
+export const highlightLineOfCodeInCodeCell = (notebookTracker: INotebookTracker, codeCellID: string, lineNumber: number): void => {
+    /*
+        Briefly highlights a specific line in a code cell, to draw the user's attention to it.
+        
+        Args:
+            notebookTracker: The notebook tracker.
+            codeCellID: The ID of the code cell.
+            lineNumber: The 0-indexed line number to highlight.
+    */
+    // Get the cell with the given ID
+    const cell = getCellByID(notebookTracker, codeCellID);
+    if (!cell) {
+        return;
+    }
+    
+    // Get the cell's editor
+    const editor = cell.editor;
+    if (!editor) {
+        return;
+    }
+    
+    // We expect the line number to be 0-indexed. To be safe, if the line number is out of bounds, we clamp it.
+    const lines = editor.model.sharedModel.source.split('\n');
+    const targetLine = Math.min(Math.max(lineNumber, 0), lines.length - 1);
+    
+    // Find the line element in the DOM
+    const cmEditor = cell.node.querySelector('.jp-Editor');
+    if (!cmEditor) {
+        return;
+    }
+    
+    // Find all line elements and get the one at the target index
+    const lineElements = cmEditor.querySelectorAll('.cm-line');
+    if (targetLine >= lineElements.length) {
+        return;
+    }
+    
+    console.log("lineElements", lineElements)
+    console.log("targetLine", targetLine)
path: mito-ai/src/utils/notebook.tsx
user: login: aarondr77
body: remove
created_at: 2025-03-17T01:30:34Z
author_association: MEMBER
diff_hunk: @@ -167,3 +167,99 @@ export const highlightCodeCell = (notebookTracker: INotebookTracker, codeCellID:
     }
 }
 
+export const highlightLineOfCodeInCodeCell = (notebookTracker: INotebookTracker, codeCellID: string, lineNumber: number): void => {
+    /*
+        Briefly highlights a specific line in a code cell, to draw the user's attention to it.
+        
+        Args:
+            notebookTracker: The notebook tracker.
+            codeCellID: The ID of the code cell.
+            lineNumber: The 0-indexed line number to highlight.
+    */
+    // Get the cell with the given ID
+    const cell = getCellByID(notebookTracker, codeCellID);
+    if (!cell) {
+        return;
+    }
+    
+    // Get the cell's editor
+    const editor = cell.editor;
+    if (!editor) {
+        return;
+    }
+    
+    // We expect the line number to be 0-indexed. To be safe, if the line number is out of bounds, we clamp it.
+    const lines = editor.model.sharedModel.source.split('\n');
+    const targetLine = Math.min(Math.max(lineNumber, 0), lines.length - 1);
+    
+    // Find the line element in the DOM
+    const cmEditor = cell.node.querySelector('.jp-Editor');
+    if (!cmEditor) {
+        return;
+    }
+    
+    // Find all line elements and get the one at the target index
+    const lineElements = cmEditor.querySelectorAll('.cm-line');
+    if (targetLine >= lineElements.length) {
+        return;
+    }
+    
+    console.log("lineElements", lineElements)
+    console.log("targetLine", targetLine)
+    const lineElement = lineElements[targetLine] as HTMLElement;
+    if (!lineElement) {
+        return;
+    }
+    
+    // Store the original background color
+    const originalBackground = lineElement.style.background;
+    
+    // Change the background color to highlight the line
+    lineElement.style.background = 'var(--purple-400)';
+    lineElement.style.transition = 'background 0.5s ease';
+    
+    // Reset the background color after a delay
+    setTimeout(() => {
+        lineElement.style.background = originalBackground;
+    }, 2000);
+}
+
+export const scrollToCell = (notebookTracker: INotebookTracker, cellID: string, lineNumber?: number): void => {
+    /*
+        Scrolls to a specific line in a specific cell. 
+        Steps:
+        1. Set the active cell to the target cell
+        2. Get the cell's editor
+        3. Make sure the cell is visible
+    */
+    // First activate the cell
+    console.log("scrollToCell", notebookTracker, cellID, lineNumber)
path: mito-ai/src/utils/notebook.tsx
user: login: aarondr77
body: remove
created_at: 2025-03-17T01:30:42Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,20 @@
+.citation-button {
+    background-color: var(--purple-400);
+    color: var(--purple-700);
+    height: 14px;
+    width: 14px;
+    border-radius: 2px;
path: mito-ai/style/Citation.css
user: login: ngafar
body: Tiniest of nitpicks, but at some point we should probably have our radius values in the base css files, and just use it as a variable. That way we don't have diffrent levels of round-ness across the UI. 
created_at: 2025-03-17T16:24:47Z
author_association: COLLABORATOR

PR comments:
user: login: vercel[bot]
created_at: 2025-03-14T21:26:26Z
body: [vc]: #Mega7CRMAigBiEd+Ix9xNSA/j6gL7vdaMMWyE8bobm4=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJtb25vcmVwbyIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJtb25vcmVwby1naXQtY2l0YXRpb25zLTItdHJ5bWl0by52ZXJjZWwuYXBwIn0sImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS90cnltaXRvL21vbm9yZXBvLzhqRjRvZUxnckpuRzVlZnFLcUJFd2J5eFlrTFciLCJwcmV2aWV3VXJsIjoibW9ub3JlcG8tZ2l0LWNpdGF0aW9ucy0yLXRyeW1pdG8udmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJERVBMT1lFRCIsInJvb3REaXJlY3RvcnkiOiJ0cnltaXRvLmlvIn1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **monorepo** | ✅ Ready ([Inspect](https://vercel.com/trymito/monorepo/8jF4oeLgrJnG5efqKqBEwbyxYkLW)) | [Visit Preview](https://monorepo-git-citations-2-trymito.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/monorepo-git-citations-2-trymito.vercel.app?via=pr-comment-feedback-link) | Mar 17, 2025 1:48am |


author_association: NONE