Compare commits

...

3 Commits

Author SHA1 Message Date
Bently
905373a712 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_comment -->

<details><summary><h3>Greptile Summary</h3></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.
</details>


<details><summary><h3>Confidence Score: 4/5</h3></summary>

- 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
</details>


<details><summary><h3>Sequence Diagram</h3></summary>

```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
```
</details>


<sub>Last reviewed commit: 96c793b</sub>

<!-- greptile_other_comments_section -->

<!-- /greptile_comment -->
2026-02-17 12:15:53 +00:00
Otto
ee9d39bc0f 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_comment -->

<details><summary><h3>Greptile Summary</h3></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.
</details>


<details><summary><h3>Confidence Score: 5/5</h3></summary>

- 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
</details>


<details><summary><h3>Flowchart</h3></summary>

```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]
```
</details>


<sub>Last reviewed commit: 275950c</sub>

<!-- greptile_other_comments_section -->

<!-- /greptile_comment -->

---------

Co-authored-by: Lluis Agusti <hi@llu.lu>
Co-authored-by: Ubbe <hi@ubbe.dev>
2026-02-17 19:12:27 +07:00
Swifty
05aaf7a85e fix(backend): Rename LINEAR_API_KEY to COPILOT_LINEAR_API_KEY to prevent global access (#12143)
The `LINEAR_API_KEY` environment variable name is too generic — it
matches the key name used by integrations/blocks, meaning that if set
globally, it could inadvertently grant all users access to Linear
through the blocks system rather than restricting it to the copilot
feature-request tool.

This renames the setting to `COPILOT_LINEAR_API_KEY` to make it clear
this key is scoped exclusively to the copilot's feature-request
functionality, preventing it from being picked up as a general-purpose
Linear credential.

### Changes 🏗️

- Renamed `linear_api_key` → `copilot_linear_api_key` in `Secrets`
settings model (`backend/util/settings.py`)
- Updated all references in the copilot feature-request tool
(`backend/api/features/chat/tools/feature_requests.py`)

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] Verified the rename is consistent across all references (settings
+ feature_requests tool)
  - [x] No other files reference the old `linear_api_key` setting name

#### For configuration changes:
- [x] `.env.default` is updated or already compatible with my changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**)

> **Note:** The env var changes from `LINEAR_API_KEY` to
`COPILOT_LINEAR_API_KEY`. Any deployment using the old name will need to
update accordingly.

<!-- greptile_comment -->

<details><summary><h3>Greptile Summary</h3></summary>

Renamed `LINEAR_API_KEY` to `COPILOT_LINEAR_API_KEY` in settings and the
copilot feature-request tool to prevent unintended access through Linear
blocks.

**Key changes:**
- Updated `Secrets.linear_api_key` → `Secrets.copilot_linear_api_key` in
`backend/util/settings.py`
- Updated all references in
`backend/api/features/chat/tools/feature_requests.py`
- The rename prevents the copilot Linear key from being picked up by the
Linear blocks integration (which uses `LINEAR_API_KEY` via
`ProviderBuilder` in `backend/blocks/linear/_config.py`)

**Issues found:**
- `.env.default` still references `LINEAR_API_KEY` instead of
`COPILOT_LINEAR_API_KEY`
- Frontend styleguide has a hardcoded error message with the old
variable name
</details>


<details><summary><h3>Confidence Score: 3/5</h3></summary>

- Generally safe but requires fixing `.env.default` before deployment
- The code changes are correct and achieve the intended security
improvement by preventing scope leakage. However, the PR is incomplete -
`.env.default` wasn't updated (critical for deployment) and a frontend
error message reference was missed. These issues will cause
configuration problems for anyone deploying with the new variable name.
- Check `autogpt_platform/backend/.env.default` and
`autogpt_platform/frontend/src/app/(platform)/copilot/styleguide/page.tsx`
- both need updates to match the renamed variable
</details>


<details><summary><h3>Flowchart</h3></summary>

```mermaid
flowchart TD
    A[".env file<br/>COPILOT_LINEAR_API_KEY"] --> B["Secrets model<br/>copilot_linear_api_key"]
    B --> C["feature_requests.py<br/>_get_linear_config()"]
    C --> D["Creates APIKeyCredentials<br/>for copilot feature requests"]
    
    E[".env file<br/>LINEAR_API_KEY"] --> F["ProviderBuilder<br/>in blocks/linear/_config.py"]
    F --> G["Linear blocks integration<br/>for user workflows"]
    
    style A fill:#90EE90
    style B fill:#90EE90
    style C fill:#90EE90
    style D fill:#90EE90
    style E fill:#FFD700
    style F fill:#FFD700
    style G fill:#FFD700
```
</details>


<sub>Last reviewed commit: 86dc57a</sub>

<!-- greptile_other_comments_section -->

<!-- /greptile_comment -->
2026-02-17 11:16:43 +01:00
16 changed files with 409 additions and 92 deletions

View File

@@ -104,8 +104,8 @@ def _get_linear_config() -> tuple[LinearClient, str, str]:
Raises RuntimeError if any required setting is missing.
"""
secrets = _get_settings().secrets
if not secrets.linear_api_key:
raise RuntimeError("LINEAR_API_KEY is not configured")
if not secrets.copilot_linear_api_key:
raise RuntimeError("COPILOT_LINEAR_API_KEY is not configured")
if not secrets.linear_feature_request_project_id:
raise RuntimeError("LINEAR_FEATURE_REQUEST_PROJECT_ID is not configured")
if not secrets.linear_feature_request_team_id:
@@ -114,7 +114,7 @@ def _get_linear_config() -> tuple[LinearClient, str, str]:
credentials = APIKeyCredentials(
id="system-linear",
provider="linear",
api_key=SecretStr(secrets.linear_api_key),
api_key=SecretStr(secrets.copilot_linear_api_key),
title="System Linear API Key",
)
client = LinearClient(credentials=credentials)

View File

@@ -662,7 +662,7 @@ class Secrets(UpdateTrackingModel["Secrets"], BaseSettings):
mem0_api_key: str = Field(default="", description="Mem0 API key")
elevenlabs_api_key: str = Field(default="", description="ElevenLabs API key")
linear_api_key: str = Field(
copilot_linear_api_key: str = Field(
default="", description="Linear API key for system-level operations"
)
linear_feature_request_project_id: str = Field(

View File

@@ -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",

View File

@@ -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

View File

@@ -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 && <ChatSidebar />}
<div className="relative flex h-full w-full flex-col overflow-hidden bg-[#f8f8f9] px-0">
{isMobile && (
<MobileHeader
onOpenDrawer={handleOpenDrawer}
showDelete={!!sessionId}
isDeleting={isDeleting}
onDelete={() => {
const session = sessions.find((s) => s.id === sessionId);
if (session) {
handleDeleteClick(session.id, session.title);
}
}}
/>
)}
{isMobile && <MobileHeader onOpenDrawer={handleOpenDrawer} />}
<div className="flex-1 overflow-hidden">
<ChatContainer
messages={messages}
@@ -80,6 +74,38 @@ export function CopilotPage() {
onCreateSession={createSession}
onSend={onSend}
onStop={stop}
headerSlot={
isMobile && sessionId ? (
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="rounded p-1.5 hover:bg-neutral-100"
aria-label="More actions"
>
<DotsThree className="h-5 w-5 text-neutral-600" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
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
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
) : undefined
}
/>
</div>
</div>
@@ -97,12 +123,11 @@ export function CopilotPage() {
)}
{/* Delete confirmation dialog - rendered at top level for proper z-index on mobile */}
{isMobile && (
<DeleteConfirmDialog
entityType="chat"
entityName={sessionToDelete?.title || "Untitled chat"}
open={!!sessionToDelete}
onOpenChange={(open) => !open && handleCancelDelete()}
onDoDelete={handleConfirmDelete}
<DeleteChatDialog
session={sessionToDelete}
isDeleting={isDeleting}
onConfirm={handleConfirmDelete}
onCancel={handleCancelDelete}
/>
)}
</SidebarProvider>

View File

@@ -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<string>;
onSend: (message: string) => void | Promise<void>;
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}
/>
<motion.div
initial={{ opacity: 0 }}

View File

@@ -118,6 +118,7 @@ interface ChatMessagesContainerProps {
status: string;
error: Error | undefined;
isLoading: boolean;
headerSlot?: React.ReactNode;
}
export const ChatMessagesContainer = ({
@@ -125,6 +126,7 @@ export const ChatMessagesContainer = ({
status,
error,
isLoading,
headerSlot,
}: ChatMessagesContainerProps) => {
const [thinkingPhrase, setThinkingPhrase] = useState(getRandomPhrase);
const lastToastTimeRef = useRef(0);
@@ -165,6 +167,7 @@ export const ChatMessagesContainer = ({
return (
<Conversation className="min-h-0 flex-1">
<ConversationContent className="flex flex-1 flex-col gap-6 px-3 py-6">
{headerSlot}
{isLoading && messages.length === 0 && (
<div className="flex min-h-full flex-1 items-center justify-center">
<LoadingSpinner className="text-neutral-600" />

View File

@@ -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() {
</Text>
</div>
</button>
<button
onClick={(e) =>
handleDeleteClick(e, session.id, session.title)
}
disabled={isDeleting}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1.5 text-zinc-400 opacity-0 transition-all group-hover:opacity-100 hover:bg-red-100 hover:text-red-600 focus-visible:opacity-100 disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Delete chat"
>
<TrashIcon className="h-4 w-4" />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-1.5 text-zinc-600 transition-all hover:bg-neutral-100"
aria-label="More actions"
>
<DotsThree className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) =>
handleDeleteClick(e, session.id, session.title)
}
disabled={isDeleting}
className="text-red-600 focus:bg-red-50 focus:text-red-600"
>
Delete chat
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
))
)}
@@ -257,12 +280,11 @@ export function ChatSidebar() {
)}
</Sidebar>
<DeleteConfirmDialog
entityType="chat"
entityName={sessionToDelete?.title || "Untitled chat"}
open={!!sessionToDelete}
onOpenChange={(open) => !open && setSessionToDelete(null)}
onDoDelete={handleConfirmDelete}
<DeleteChatDialog
session={sessionToDelete}
isDeleting={isDeleting}
onConfirm={handleConfirmDelete}
onCancel={handleCancelDelete}
/>
</>
);

View File

@@ -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 (
<Dialog
title="Delete chat"
styling={{ maxWidth: "30rem", minWidth: "auto" }}
controlled={{
isOpen: !!session,
set: async (open) => {
if (!open && !isDeleting) {
onCancel();
}
},
}}
onClose={isDeleting ? undefined : onCancel}
>
<Dialog.Content>
<Text variant="body">
Are you sure you want to delete{" "}
<Text variant="body-medium" as="span">
&quot;{session?.title || "Untitled chat"}&quot;
</Text>
? This action cannot be undone.
</Text>
<Dialog.Footer>
<Button variant="secondary" onClick={onCancel} disabled={isDeleting}>
Cancel
</Button>
<Button
variant="destructive"
onClick={onConfirm}
loading={isDeleting}
>
Delete
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -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 (
<div
className="fixed z-50 flex gap-2"
@@ -29,18 +21,6 @@ export function MobileHeader({
>
<ListIcon width="1.25rem" height="1.25rem" />
</Button>
{showDelete && onDelete && (
<Button
variant="icon"
size="icon"
aria-label="Delete current chat"
onClick={onDelete}
disabled={isDeleting}
className="bg-white text-red-500 shadow-md hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
>
<TrashIcon width="1.25rem" height="1.25rem" />
</Button>
)}
</div>
);
}

View File

@@ -192,8 +192,10 @@ export function useCopilotPage() {
}, [sessionToDelete, deleteSessionMutation]);
const handleCancelDelete = useCallback(() => {
setSessionToDelete(null);
}, []);
if (!isDeleting) {
setSessionToDelete(null);
}
}, [isDeleting]);
return {
sessionId,

View File

@@ -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";

View File

@@ -25,7 +25,7 @@ export function BaseFooter({
</div>
) : (
<div
className={`flex w-full items-end justify-between gap-4 pt-6 ${className}`}
className={`flex w-full items-end justify-end gap-4 pt-6 ${className}`}
data-testid={testId}
>
{children}

View File

@@ -0,0 +1,70 @@
import {
bundledLanguages,
bundledLanguagesInfo,
createHighlighter,
type BundledLanguage,
type BundledTheme,
type HighlighterGeneric,
} from "shiki";
export type { BundledLanguage, BundledTheme };
const LANGUAGE_ALIASES: Record<string, string> = 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<BundledLanguage, BundledTheme>
> | null = null;
export function getShikiHighlighter(): Promise<
HighlighterGeneric<BundledLanguage, BundledTheme>
> {
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[];
}

View File

@@ -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<string, string>;
}[][];
fg?: string;
bg?: string;
}
type HighlightCallback = (result: HighlightResult) => void;
const MAX_CACHE_SIZE = 500;
const tokenCache = new Map<string, HighlightResult>();
const pendingCallbacks = new Map<string, Set<HighlightCallback>>();
const inFlightLanguageLoads = new Map<string, Promise<void>>();
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();

View File

@@ -465,9 +465,13 @@ export async function navigateToAgentByName(
export async function clickRunButton(page: Page): Promise<void> {
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<void> {
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<void> {
);
}
// 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<void> {
export async function waitForAgentPageLoad(page: Page): Promise<void> {
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<string> {