From ee9d39bc0fb94108c19eb9a30546f3e01a3cd7ba Mon Sep 17 00:00:00 2001 From: Otto Date: Tue, 17 Feb 2026 12:12:27 +0000 Subject: [PATCH 1/2] refactor(copilot): Replace legacy delete dialog with molecules/Dialog (#12136) ## Summary Updates the session delete confirmation in CoPilot to use the new `Dialog` component from `molecules/Dialog` instead of the legacy `DeleteConfirmDialog`. ## Changes - **ChatSidebar**: Use Dialog component for delete confirmation (desktop) - **CopilotPage**: Use Dialog component for delete confirmation (mobile) ## Behavior - Dialog stays **open** during deletion with loading state on button - Cancel button **disabled** while delete is in progress - Delete button shows **loading spinner** during deletion - Dialog only closes on successful delete or when cancel is clicked (if not deleting) ## Screenshots *Dialog uses the same styling as other molecules/Dialog instances in the app* ## Requested by @0ubbe

Greptile Summary

Replaces the legacy `DeleteConfirmDialog` component with the new `molecules/Dialog` component for session delete confirmations in both desktop (ChatSidebar) and mobile (CopilotPage) views. The new implementation maintains the same behavior: dialog stays open during deletion with a loading state on the delete button and disabled cancel button, closing only on successful deletion or cancel click.

Confidence Score: 5/5

- This PR is safe to merge with minimal risk - This is a straightforward component replacement that maintains the same behavior and UX. The Dialog component API is properly used with controlled state, the loading states are correctly implemented, and both mobile and desktop views are handled consistently. The changes are well-tested patterns used elsewhere in the codebase. - No files require special attention

Flowchart

```mermaid flowchart TD A[User clicks delete button] --> B{isMobile?} B -->|Yes| C[CopilotPage Dialog] B -->|No| D[ChatSidebar Dialog] C --> E[Set sessionToDelete state] D --> E E --> F[Dialog opens with controlled.isOpen] F --> G{User action?} G -->|Cancel| H{isDeleting?} H -->|No| I[handleCancelDelete: setSessionToDelete null] H -->|Yes| J[Cancel button disabled] G -->|Confirm Delete| K[handleConfirmDelete called] K --> L[deleteSession mutation] L --> M[isDeleting = true] M --> N[Button shows loading spinner] M --> O[Cancel button disabled] L --> P{Mutation result?} P -->|Success| Q[Invalidate sessions query] Q --> R[Clear sessionId if current] R --> S[setSessionToDelete null] S --> T[Dialog closes] P -->|Error| U[Show toast error] U --> V[setSessionToDelete null] V --> W[Dialog closes] ```
Last reviewed commit: 275950c --------- Co-authored-by: Lluis Agusti Co-authored-by: Ubbe --- .../app/(platform)/copilot/CopilotPage.tsx | 67 +++++++++++++------ .../ChatContainer/ChatContainer.tsx | 4 ++ .../ChatMessagesContainer.tsx | 3 + .../components/ChatSidebar/ChatSidebar.tsx | 62 +++++++++++------ .../DeleteChatDialog/DeleteChatDialog.tsx | 57 ++++++++++++++++ .../components/MobileHeader/MobileHeader.tsx | 24 +------ .../app/(platform)/copilot/useCopilotPage.ts | 6 +- .../Dialog/components/BaseFooter.tsx | 2 +- .../frontend/src/tests/pages/library.page.ts | 19 ++++-- 9 files changed, 171 insertions(+), 73 deletions(-) create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/DeleteChatDialog/DeleteChatDialog.tsx diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/CopilotPage.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/CopilotPage.tsx index 35b34890ce..29057d85e2 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/CopilotPage.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/CopilotPage.tsx @@ -1,10 +1,16 @@ "use client"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/molecules/DropdownMenu/DropdownMenu"; import { SidebarProvider } from "@/components/ui/sidebar"; -// TODO: Replace with modern Dialog component when available -import DeleteConfirmDialog from "@/components/__legacy__/delete-confirm-dialog"; +import { DotsThree } from "@phosphor-icons/react"; import { ChatContainer } from "./components/ChatContainer/ChatContainer"; import { ChatSidebar } from "./components/ChatSidebar/ChatSidebar"; +import { DeleteChatDialog } from "./components/DeleteChatDialog/DeleteChatDialog"; import { MobileDrawer } from "./components/MobileDrawer/MobileDrawer"; import { MobileHeader } from "./components/MobileHeader/MobileHeader"; import { ScaleLoader } from "./components/ScaleLoader/ScaleLoader"; @@ -56,19 +62,7 @@ export function CopilotPage() { > {!isMobile && }
- {isMobile && ( - { - const session = sessions.find((s) => s.id === sessionId); - if (session) { - handleDeleteClick(session.id, session.title); - } - }} - /> - )} + {isMobile && }
+ + + + + + { + const session = sessions.find( + (s) => s.id === sessionId, + ); + if (session) { + handleDeleteClick(session.id, session.title); + } + }} + disabled={isDeleting} + className="text-red-600 focus:bg-red-50 focus:text-red-600" + > + Delete chat + + + +
+ ) : undefined + } />
@@ -97,12 +123,11 @@ export function CopilotPage() { )} {/* Delete confirmation dialog - rendered at top level for proper z-index on mobile */} {isMobile && ( - !open && handleCancelDelete()} - onDoDelete={handleConfirmDelete} + )} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatContainer/ChatContainer.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatContainer/ChatContainer.tsx index 5074741095..5c4dd0dfe7 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatContainer/ChatContainer.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatContainer/ChatContainer.tsx @@ -2,6 +2,7 @@ import { ChatInput } from "@/app/(platform)/copilot/components/ChatInput/ChatInput"; import { UIDataTypes, UIMessage, UITools } from "ai"; import { LayoutGroup, motion } from "framer-motion"; +import { ReactNode } from "react"; import { ChatMessagesContainer } from "../ChatMessagesContainer/ChatMessagesContainer"; import { CopilotChatActionsProvider } from "../CopilotChatActionsProvider/CopilotChatActionsProvider"; import { EmptySession } from "../EmptySession/EmptySession"; @@ -16,6 +17,7 @@ export interface ChatContainerProps { onCreateSession: () => void | Promise; onSend: (message: string) => void | Promise; onStop: () => void; + headerSlot?: ReactNode; } export const ChatContainer = ({ messages, @@ -27,6 +29,7 @@ export const ChatContainer = ({ onCreateSession, onSend, onStop, + headerSlot, }: ChatContainerProps) => { const inputLayoutId = "copilot-2-chat-input"; @@ -41,6 +44,7 @@ export const ChatContainer = ({ status={status} error={error} isLoading={isLoadingSession} + headerSlot={headerSlot} /> { const [thinkingPhrase, setThinkingPhrase] = useState(getRandomPhrase); const lastToastTimeRef = useRef(0); @@ -165,6 +167,7 @@ export const ChatMessagesContainer = ({ return ( + {headerSlot} {isLoading && messages.length === 0 && (
diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatSidebar/ChatSidebar.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatSidebar/ChatSidebar.tsx index 8e785dd9d3..8fc9c3f438 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatSidebar/ChatSidebar.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatSidebar/ChatSidebar.tsx @@ -7,9 +7,13 @@ import { import { Button } from "@/components/atoms/Button/Button"; import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner"; import { Text } from "@/components/atoms/Text/Text"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/molecules/DropdownMenu/DropdownMenu"; import { toast } from "@/components/molecules/Toast/use-toast"; -// TODO: Replace with modern Dialog component when available -import DeleteConfirmDialog from "@/components/__legacy__/delete-confirm-dialog"; import { Sidebar, SidebarContent, @@ -19,11 +23,12 @@ import { useSidebar, } from "@/components/ui/sidebar"; import { cn } from "@/lib/utils"; -import { PlusCircleIcon, PlusIcon, TrashIcon } from "@phosphor-icons/react"; +import { DotsThree, PlusCircleIcon, PlusIcon } from "@phosphor-icons/react"; import { useQueryClient } from "@tanstack/react-query"; import { motion } from "framer-motion"; -import { useState } from "react"; import { parseAsString, useQueryState } from "nuqs"; +import { useState } from "react"; +import { DeleteChatDialog } from "../DeleteChatDialog/DeleteChatDialog"; export function ChatSidebar() { const { state } = useSidebar(); @@ -92,6 +97,12 @@ export function ChatSidebar() { } } + function handleCancelDelete() { + if (!isDeleting) { + setSessionToDelete(null); + } + } + function formatDate(dateString: string) { const date = new Date(dateString); const now = new Date(); @@ -220,16 +231,28 @@ export function ChatSidebar() {
- + + + + + + + handleDeleteClick(e, session.id, session.title) + } + disabled={isDeleting} + className="text-red-600 focus:bg-red-50 focus:text-red-600" + > + Delete chat + + + )) )} @@ -257,12 +280,11 @@ export function ChatSidebar() { )} - !open && setSessionToDelete(null)} - onDoDelete={handleConfirmDelete} + ); diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/DeleteChatDialog/DeleteChatDialog.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/DeleteChatDialog/DeleteChatDialog.tsx new file mode 100644 index 0000000000..d94625c4e3 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/DeleteChatDialog/DeleteChatDialog.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { Button } from "@/components/atoms/Button/Button"; +import { Text } from "@/components/atoms/Text/Text"; +import { Dialog } from "@/components/molecules/Dialog/Dialog"; + +interface Props { + session: { id: string; title: string | null | undefined } | null; + isDeleting: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +export function DeleteChatDialog({ + session, + isDeleting, + onConfirm, + onCancel, +}: Props) { + return ( + { + if (!open && !isDeleting) { + onCancel(); + } + }, + }} + onClose={isDeleting ? undefined : onCancel} + > + + + Are you sure you want to delete{" "} + + "{session?.title || "Untitled chat"}" + + ? This action cannot be undone. + + + + + + + + ); +} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/MobileHeader/MobileHeader.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/MobileHeader/MobileHeader.tsx index b4b7636c81..bed9989244 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/MobileHeader/MobileHeader.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/MobileHeader/MobileHeader.tsx @@ -1,20 +1,12 @@ import { Button } from "@/components/atoms/Button/Button"; import { NAVBAR_HEIGHT_PX } from "@/lib/constants"; -import { ListIcon, TrashIcon } from "@phosphor-icons/react"; +import { ListIcon } from "@phosphor-icons/react"; interface Props { onOpenDrawer: () => void; - showDelete?: boolean; - isDeleting?: boolean; - onDelete?: () => void; } -export function MobileHeader({ - onOpenDrawer, - showDelete, - isDeleting, - onDelete, -}: Props) { +export function MobileHeader({ onOpenDrawer }: Props) { return (
- {showDelete && onDelete && ( - - )}
); } diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts index 444e745ec6..a0f0a2b7fd 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts @@ -192,8 +192,10 @@ export function useCopilotPage() { }, [sessionToDelete, deleteSessionMutation]); const handleCancelDelete = useCallback(() => { - setSessionToDelete(null); - }, []); + if (!isDeleting) { + setSessionToDelete(null); + } + }, [isDeleting]); return { sessionId, diff --git a/autogpt_platform/frontend/src/components/molecules/Dialog/components/BaseFooter.tsx b/autogpt_platform/frontend/src/components/molecules/Dialog/components/BaseFooter.tsx index 3ed1157b6c..1951525ccd 100644 --- a/autogpt_platform/frontend/src/components/molecules/Dialog/components/BaseFooter.tsx +++ b/autogpt_platform/frontend/src/components/molecules/Dialog/components/BaseFooter.tsx @@ -25,7 +25,7 @@ export function BaseFooter({ ) : (
{children} diff --git a/autogpt_platform/frontend/src/tests/pages/library.page.ts b/autogpt_platform/frontend/src/tests/pages/library.page.ts index 03e98598b4..17a265d590 100644 --- a/autogpt_platform/frontend/src/tests/pages/library.page.ts +++ b/autogpt_platform/frontend/src/tests/pages/library.page.ts @@ -465,9 +465,13 @@ export async function navigateToAgentByName( export async function clickRunButton(page: Page): Promise { const { getId } = getSelectors(page); - // Wait for page to stabilize and buttons to render - // The NewAgentLibraryView shows either "Setup your task" (empty state) - // or "New task" (with items) button + // Wait for sidebar loading to complete before detecting buttons. + // During sidebar loading, the "New task" button appears transiently + // even for agents with no items, then switches to "Setup your task" + // once loading finishes. Waiting for network idle ensures the page + // has settled into its final state. + await page.waitForLoadState("networkidle"); + const setupTaskButton = page.getByRole("button", { name: /Setup your task/i, }); @@ -475,8 +479,7 @@ export async function clickRunButton(page: Page): Promise { const runButton = getId("agent-run-button"); const runAgainButton = getId("run-again-button"); - // Use Promise.race with waitFor to wait for any of the buttons to appear - // This handles the async rendering in CI environments + // Wait for any of the buttons to appear try { await Promise.race([ setupTaskButton.waitFor({ state: "visible", timeout: 15000 }), @@ -490,7 +493,7 @@ export async function clickRunButton(page: Page): Promise { ); } - // Now check which button is visible and click it + // Check which button is visible and click it if (await setupTaskButton.isVisible()) { await setupTaskButton.click(); const startTaskButton = page @@ -534,7 +537,9 @@ export async function runAgent(page: Page): Promise { export async function waitForAgentPageLoad(page: Page): Promise { await page.waitForURL(/.*\/library\/agents\/[^/]+/); - await page.getByTestId("Run actions").isVisible({ timeout: 10000 }); + // Wait for sidebar data to finish loading so the page settles + // into its final state (empty view vs sidebar view) + await page.waitForLoadState("networkidle"); } export async function getAgentName(page: Page): Promise { From 905373a712b7e88917016388a299de588e4db2a3 Mon Sep 17 00:00:00 2001 From: Bently Date: Tue, 17 Feb 2026 12:15:53 +0000 Subject: [PATCH 2/2] fix(frontend): use singleton Shiki highlighter for code syntax highlighting (#12144) ## Summary Addresses SENTRY-1051: Shiki warning about multiple highlighter instances. ## Problem The `@streamdown/code` package creates a **new Shiki highlighter for each language** encountered. When users view AI chat responses with code blocks in multiple languages (JavaScript, Python, JSON, YAML, etc.), this creates 10+ highlighter instances, triggering Shiki's warning: > "10 instances have been created. Shiki is supposed to be used as a singleton, consider refactoring your code to cache your highlighter instance" This causes memory bloat and performance degradation. ## Solution Introduced a custom code highlighting plugin that properly implements the singleton pattern: ### New files: - `src/lib/shiki-highlighter.ts` - Singleton highlighter management - `src/lib/streamdown-code-plugin.ts` - Drop-in replacement for `@streamdown/code` ### Key features: - **Single shared highlighter** - One instance serves all code blocks - **Preloaded common languages** - JS, TS, Python, JSON, Bash, YAML, etc. - **Lazy loading** - Additional languages loaded on demand - **Result caching** - Avoids re-highlighting identical code blocks ### Changes: - Added `shiki` as direct dependency - Updated `message.tsx` to use the new plugin ## Testing - [ ] Verify code blocks render correctly in AI chat - [ ] Confirm no Shiki singleton warnings in console - [ ] Test with multiple languages in same conversation ## Related - Linear: SENTRY-1051 - Sentry: Multiple Shiki instances warning

Greptile Summary

Replaced `@streamdown/code` with a custom singleton-based Shiki highlighter implementation to resolve memory bloat from creating multiple highlighter instances per language. The new implementation creates a single shared highlighter with preloaded common languages (JS, TS, Python, JSON, etc.) and lazy-loads additional languages on demand. Results are cached to avoid re-highlighting identical code blocks. **Key changes:** - Added `shiki` v3.21.0 as a direct dependency - Created `shiki-highlighter.ts` with singleton pattern and language management utilities - Created `streamdown-code-plugin.ts` as a drop-in replacement for `@streamdown/code` - Updated `message.tsx` to import from the new plugin instead of `@streamdown/code` The implementation follows React best practices with async highlighting and callback-based notifications. The cache key uses code length + prefix/suffix for efficient lookups on large code blocks.

Confidence Score: 4/5

- Safe to merge with minor considerations for edge cases - The implementation is solid with proper singleton pattern, caching, and async handling. The code is well-structured and addresses the stated problem. However, there's a subtle potential race condition in the callback handling where multiple concurrent requests for the same cache key could trigger duplicate highlight operations before the first completes. The cache key generation using prefix/suffix could theoretically cause false cache hits for large files with identical prefixes and suffixes. Despite these edge cases, the implementation should work correctly for the vast majority of use cases. - No files require special attention

Sequence Diagram

```mermaid sequenceDiagram participant UI as Streamdown Component participant Plugin as Custom Code Plugin participant Cache as Token Cache participant Singleton as Shiki Highlighter (Singleton) participant Callbacks as Pending Callbacks UI->>Plugin: highlight(code, lang) Plugin->>Cache: Check cache key alt Cache hit Cache-->>Plugin: Return cached result Plugin-->>UI: Return highlighted tokens else Cache miss Plugin->>Callbacks: Register callback Plugin->>Singleton: Get highlighter instance alt First call Singleton->>Singleton: Create highlighter with preloaded languages end Singleton-->>Plugin: Return highlighter alt Language not loaded Plugin->>Singleton: Load language dynamically end Plugin->>Singleton: codeToTokens(code, lang, themes) Singleton-->>Plugin: Return tokens Plugin->>Cache: Store result Plugin->>Callbacks: Notify all waiting callbacks Callbacks-->>UI: Async callback with result end ```
Last reviewed commit: 96c793b --- autogpt_platform/frontend/package.json | 2 +- autogpt_platform/frontend/pnpm-lock.yaml | 16 +- .../src/components/ai-elements/message.tsx | 2 +- .../frontend/src/lib/shiki-highlighter.ts | 70 ++++++++ .../src/lib/streamdown-code-plugin.ts | 159 ++++++++++++++++++ 5 files changed, 234 insertions(+), 15 deletions(-) create mode 100644 autogpt_platform/frontend/src/lib/shiki-highlighter.ts create mode 100644 autogpt_platform/frontend/src/lib/streamdown-code-plugin.ts diff --git a/autogpt_platform/frontend/package.json b/autogpt_platform/frontend/package.json index 5988e59c90..80fb46beff 100644 --- a/autogpt_platform/frontend/package.json +++ b/autogpt_platform/frontend/package.json @@ -62,7 +62,6 @@ "@rjsf/validator-ajv8": "6.1.2", "@sentry/nextjs": "10.27.0", "@streamdown/cjk": "1.0.1", - "@streamdown/code": "1.0.1", "@streamdown/math": "1.0.1", "@streamdown/mermaid": "1.0.1", "@supabase/ssr": "0.7.0", @@ -116,6 +115,7 @@ "remark-gfm": "4.0.1", "remark-math": "6.0.0", "shepherd.js": "14.5.1", + "shiki": "^3.21.0", "sonner": "2.0.7", "streamdown": "2.1.0", "tailwind-merge": "2.6.0", diff --git a/autogpt_platform/frontend/pnpm-lock.yaml b/autogpt_platform/frontend/pnpm-lock.yaml index 468e2f312d..4d76cd0cb9 100644 --- a/autogpt_platform/frontend/pnpm-lock.yaml +++ b/autogpt_platform/frontend/pnpm-lock.yaml @@ -108,9 +108,6 @@ importers: '@streamdown/cjk': specifier: 1.0.1 version: 1.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(react@18.3.1)(unified@11.0.5) - '@streamdown/code': - specifier: 1.0.1 - version: 1.0.1(react@18.3.1) '@streamdown/math': specifier: 1.0.1 version: 1.0.1(react@18.3.1) @@ -270,6 +267,9 @@ importers: shepherd.js: specifier: 14.5.1 version: 14.5.1 + shiki: + specifier: ^3.21.0 + version: 3.21.0 sonner: specifier: 2.0.7 version: 2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -3307,11 +3307,6 @@ packages: peerDependencies: react: ^18.0.0 || ^19.0.0 - '@streamdown/code@1.0.1': - resolution: {integrity: sha512-U9LITfQ28tZYAoY922jdtw1ryg4kgRBdURopqK9hph7G2fBUwPeHthjH7SvaV0fvFv7EqjqCzARJuWUljLe9Ag==} - peerDependencies: - react: ^18.0.0 || ^19.0.0 - '@streamdown/math@1.0.1': resolution: {integrity: sha512-R9WdHbpERiRU7WeO7oT1aIbnLJ/jraDr89F7X9x2OM//Y8G8UMATRnLD/RUwg4VLr8Nu7QSIJ0Pa8lXd2meM4Q==} peerDependencies: @@ -11907,11 +11902,6 @@ snapshots: - micromark-util-types - unified - '@streamdown/code@1.0.1(react@18.3.1)': - dependencies: - react: 18.3.1 - shiki: 3.21.0 - '@streamdown/math@1.0.1(react@18.3.1)': dependencies: katex: 0.16.28 diff --git a/autogpt_platform/frontend/src/components/ai-elements/message.tsx b/autogpt_platform/frontend/src/components/ai-elements/message.tsx index 5cc330e57c..cc11c2f01b 100644 --- a/autogpt_platform/frontend/src/components/ai-elements/message.tsx +++ b/autogpt_platform/frontend/src/components/ai-elements/message.tsx @@ -10,7 +10,7 @@ import { } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import { cjk } from "@streamdown/cjk"; -import { code } from "@streamdown/code"; +import { code } from "@/lib/streamdown-code-plugin"; import { math } from "@streamdown/math"; import { mermaid } from "@streamdown/mermaid"; import type { UIMessage } from "ai"; diff --git a/autogpt_platform/frontend/src/lib/shiki-highlighter.ts b/autogpt_platform/frontend/src/lib/shiki-highlighter.ts new file mode 100644 index 0000000000..f6bbcb9b94 --- /dev/null +++ b/autogpt_platform/frontend/src/lib/shiki-highlighter.ts @@ -0,0 +1,70 @@ +import { + bundledLanguages, + bundledLanguagesInfo, + createHighlighter, + type BundledLanguage, + type BundledTheme, + type HighlighterGeneric, +} from "shiki"; + +export type { BundledLanguage, BundledTheme }; + +const LANGUAGE_ALIASES: Record = Object.fromEntries( + bundledLanguagesInfo.flatMap((lang) => + (lang.aliases ?? []).map((alias) => [alias, lang.id]), + ), +); + +const SUPPORTED_LANGUAGES = new Set(Object.keys(bundledLanguages)); + +const PRELOAD_LANGUAGES: BundledLanguage[] = [ + "javascript", + "typescript", + "python", + "json", + "bash", + "yaml", + "markdown", + "html", + "css", + "sql", + "tsx", + "jsx", +]; + +export const SHIKI_THEMES: [BundledTheme, BundledTheme] = [ + "github-light", + "github-dark", +]; + +let highlighterPromise: Promise< + HighlighterGeneric +> | null = null; + +export function getShikiHighlighter(): Promise< + HighlighterGeneric +> { + if (!highlighterPromise) { + highlighterPromise = createHighlighter({ + themes: SHIKI_THEMES, + langs: PRELOAD_LANGUAGES, + }).catch((err) => { + highlighterPromise = null; + throw err; + }); + } + return highlighterPromise; +} + +export function resolveLanguage(lang: string): string { + const normalized = lang.trim().toLowerCase(); + return LANGUAGE_ALIASES[normalized] ?? normalized; +} + +export function isLanguageSupported(lang: string): boolean { + return SUPPORTED_LANGUAGES.has(resolveLanguage(lang)); +} + +export function getSupportedLanguages(): BundledLanguage[] { + return Array.from(SUPPORTED_LANGUAGES) as BundledLanguage[]; +} diff --git a/autogpt_platform/frontend/src/lib/streamdown-code-plugin.ts b/autogpt_platform/frontend/src/lib/streamdown-code-plugin.ts new file mode 100644 index 0000000000..52ccc5afed --- /dev/null +++ b/autogpt_platform/frontend/src/lib/streamdown-code-plugin.ts @@ -0,0 +1,159 @@ +import type { CodeHighlighterPlugin } from "streamdown"; + +import { + type BundledLanguage, + type BundledTheme, + getShikiHighlighter, + getSupportedLanguages, + isLanguageSupported, + resolveLanguage, + SHIKI_THEMES, +} from "./shiki-highlighter"; + +interface HighlightResult { + tokens: { + content: string; + color?: string; + htmlStyle?: Record; + }[][]; + fg?: string; + bg?: string; +} + +type HighlightCallback = (result: HighlightResult) => void; + +const MAX_CACHE_SIZE = 500; +const tokenCache = new Map(); +const pendingCallbacks = new Map>(); +const inFlightLanguageLoads = new Map>(); + +function simpleHash(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; + } + return hash.toString(36); +} + +function getCacheKey( + code: string, + lang: string, + themes: readonly string[], +): string { + return `${lang}:${themes.join(",")}:${simpleHash(code)}`; +} + +function evictOldestIfNeeded(): void { + if (tokenCache.size > MAX_CACHE_SIZE) { + const oldestKey = tokenCache.keys().next().value; + if (oldestKey) { + tokenCache.delete(oldestKey); + } + } +} + +export function createSingletonCodePlugin(): CodeHighlighterPlugin { + return { + name: "shiki", + type: "code-highlighter", + + supportsLanguage(lang: BundledLanguage): boolean { + return isLanguageSupported(lang); + }, + + getSupportedLanguages(): BundledLanguage[] { + return getSupportedLanguages(); + }, + + getThemes(): [BundledTheme, BundledTheme] { + return SHIKI_THEMES; + }, + + highlight({ code, language, themes }, callback) { + const lang = resolveLanguage(language); + const cacheKey = getCacheKey(code, lang, themes); + + if (tokenCache.has(cacheKey)) { + return tokenCache.get(cacheKey)!; + } + + if (callback) { + if (!pendingCallbacks.has(cacheKey)) { + pendingCallbacks.set(cacheKey, new Set()); + } + pendingCallbacks.get(cacheKey)!.add(callback); + } + + getShikiHighlighter() + .then(async (highlighter) => { + const loadedLanguages = highlighter.getLoadedLanguages(); + + if (!loadedLanguages.includes(lang) && isLanguageSupported(lang)) { + let loadPromise = inFlightLanguageLoads.get(lang); + if (!loadPromise) { + loadPromise = highlighter + .loadLanguage(lang as BundledLanguage) + .finally(() => { + inFlightLanguageLoads.delete(lang); + }); + inFlightLanguageLoads.set(lang, loadPromise); + } + await loadPromise; + } + + const finalLang = ( + highlighter.getLoadedLanguages().includes(lang) ? lang : "text" + ) as BundledLanguage; + + const shikiResult = highlighter.codeToTokens(code, { + lang: finalLang, + themes: { light: themes[0], dark: themes[1] }, + }); + + const result: HighlightResult = { + tokens: shikiResult.tokens.map((line) => + line.map((token) => ({ + content: token.content, + color: token.color, + htmlStyle: token.htmlStyle, + })), + ), + fg: shikiResult.fg, + bg: shikiResult.bg, + }; + + evictOldestIfNeeded(); + tokenCache.set(cacheKey, result); + + const callbacks = pendingCallbacks.get(cacheKey); + if (callbacks) { + callbacks.forEach((cb) => { + cb(result); + }); + pendingCallbacks.delete(cacheKey); + } + }) + .catch((error) => { + console.error("[Shiki] Failed to highlight code:", error); + + const fallback: HighlightResult = { + tokens: code.split("\n").map((line) => [{ content: line }]), + }; + + const callbacks = pendingCallbacks.get(cacheKey); + if (callbacks) { + callbacks.forEach((cb) => { + cb(fallback); + }); + pendingCallbacks.delete(cacheKey); + } + }); + + return null; + }, + }; +} + +export const code = createSingletonCodePlugin();