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>
This commit is contained in:
Otto
2026-02-17 12:12:27 +00:00
committed by GitHub
parent 05aaf7a85e
commit ee9d39bc0f
9 changed files with 171 additions and 73 deletions

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

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

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