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/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/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/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/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(); 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 {