Compare commits

..

9 Commits

Author SHA1 Message Date
Zamil Majdy
b2f35fe2d4 fix: add gap spacing to dialog footer buttons on mobile 2026-02-15 09:29:10 +04:00
Otto
7d63e5ec93 fix: move mobile delete button to header instead of drawer
- Moved delete button from MobileDrawer to MobileHeader (next to menu button)
- Delete button only shows when a session is selected
- Fixes modal z-index issue (dialog was showing behind drawer)
- Fixes layout scroll issue in drawer
- Simplified MobileDrawer back to original layout
2026-02-14 21:28:24 +00:00
Otto
de6c0392b2 feat: add delete button to MobileDrawer for mobile chat deletion
- Added TrashIcon and delete button to each session in MobileDrawer
- Added delete state and handlers to useCopilotPage hook
- Added DeleteConfirmDialog to CopilotPage for mobile delete confirmation
- Shared delete mutation with proper error handling via toast
2026-02-14 19:47:36 +00:00
Otto
40cd6ec83d fix: sync openapi description with backend docstring (include Raises section) 2026-02-14 13:30:08 +00:00
Otto
89e52b3fa7 fix: reorder openapi.json operations to match backend route definition order 2026-02-14 13:23:33 +00:00
Otto
4beec4cf24 fix: add 404 response to OpenAPI spec, fix TypeScript type for optional title 2026-02-14 13:14:51 +00:00
Otto
bc9f4abd32 fix: address review feedback - use isDeleting, add toast, keyboard a11y 2026-02-14 13:06:06 +00:00
Otto
44a92c6f8d chore: remove plan files from PR 2026-02-14 12:43:56 +00:00
Otto
a086118e0d feat(chat): add delete chat session endpoint and UI
Adds the ability to delete chat sessions from the CoPilot interface:

Backend:
- Add DELETE /api/chat/sessions/{session_id} endpoint
- Returns 204 on success, 404 if not found or not owned

Frontend:
- Add delete button (trash icon) on hover for each chat session
- Add confirmation dialog before deletion
- Refresh session list after successful delete
- Clear current session if deleted

Closes: SECRT-1928
2026-02-14 12:39:46 +00:00
10 changed files with 435 additions and 186 deletions

View File

@@ -23,6 +23,7 @@ from .model import (
ChatSession, ChatSession,
append_and_save_message, append_and_save_message,
create_chat_session, create_chat_session,
delete_chat_session,
get_chat_session, get_chat_session,
get_user_sessions, get_user_sessions,
) )
@@ -211,6 +212,43 @@ async def create_session(
) )
@router.delete(
"/sessions/{session_id}",
dependencies=[Security(auth.requires_user)],
status_code=204,
responses={404: {"description": "Session not found or access denied"}},
)
async def delete_session(
session_id: str,
user_id: Annotated[str, Security(auth.get_user_id)],
) -> Response:
"""
Delete a chat session.
Permanently removes a chat session and all its messages.
Only the owner can delete their sessions.
Args:
session_id: The session ID to delete.
user_id: The authenticated user's ID.
Returns:
204 No Content on success.
Raises:
HTTPException: 404 if session not found or not owned by user.
"""
deleted = await delete_chat_session(session_id, user_id)
if not deleted:
raise HTTPException(
status_code=404,
detail=f"Session {session_id} not found or access denied",
)
return Response(status_code=204)
@router.get( @router.get(
"/sessions/{session_id}", "/sessions/{session_id}",
) )

View File

@@ -11,15 +11,45 @@ import re
from collections.abc import Callable from collections.abc import Callable
from typing import Any, cast from typing import Any, cast
from backend.api.features.chat.sdk.tool_adapter import ( from backend.api.features.chat.sdk.tool_adapter import MCP_TOOL_PREFIX
BLOCKED_TOOLS,
DANGEROUS_PATTERNS,
MCP_TOOL_PREFIX,
WORKSPACE_SCOPED_TOOLS,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Tools that are blocked entirely (CLI/system access).
# "Bash" (capital) is the SDK built-in — it's NOT in allowed_tools but blocked
# here as defence-in-depth. The agent uses mcp__copilot__bash_exec instead,
# which has kernel-level network isolation (unshare --net).
BLOCKED_TOOLS = {
"Bash",
"bash",
"shell",
"exec",
"terminal",
"command",
}
# Tools allowed only when their path argument stays within the SDK workspace.
# The SDK uses these to handle oversized tool results (writes to tool-results/
# files, then reads them back) and for workspace file operations.
WORKSPACE_SCOPED_TOOLS = {"Read", "Write", "Edit", "Glob", "Grep"}
# Dangerous patterns in tool inputs
DANGEROUS_PATTERNS = [
r"sudo",
r"rm\s+-rf",
r"dd\s+if=",
r"/etc/passwd",
r"/etc/shadow",
r"chmod\s+777",
r"curl\s+.*\|.*sh",
r"wget\s+.*\|.*sh",
r"eval\s*\(",
r"exec\s*\(",
r"__import__",
r"os\.system",
r"subprocess",
]
def _deny(reason: str) -> dict[str, Any]: def _deny(reason: str) -> dict[str, Any]:
"""Return a hook denial response.""" """Return a hook denial response."""

View File

@@ -41,7 +41,6 @@ from .response_adapter import SDKResponseAdapter
from .security_hooks import create_security_hooks from .security_hooks import create_security_hooks
from .tool_adapter import ( from .tool_adapter import (
COPILOT_TOOL_NAMES, COPILOT_TOOL_NAMES,
SDK_DISALLOWED_TOOLS,
LongRunningCallback, LongRunningCallback,
create_copilot_mcp_server, create_copilot_mcp_server,
set_execution_context, set_execution_context,
@@ -544,7 +543,7 @@ async def stream_chat_completion_sdk(
"system_prompt": system_prompt, "system_prompt": system_prompt,
"mcp_servers": {"copilot": mcp_server}, "mcp_servers": {"copilot": mcp_server},
"allowed_tools": COPILOT_TOOL_NAMES, "allowed_tools": COPILOT_TOOL_NAMES,
"disallowed_tools": SDK_DISALLOWED_TOOLS, "disallowed_tools": ["Bash"],
"hooks": security_hooks, "hooks": security_hooks,
"cwd": sdk_cwd, "cwd": sdk_cwd,
"max_buffer_size": config.claude_agent_max_buffer_size, "max_buffer_size": config.claude_agent_max_buffer_size,

View File

@@ -310,48 +310,7 @@ def create_copilot_mcp_server():
# Bash is NOT included — use the sandboxed MCP bash_exec tool instead, # Bash is NOT included — use the sandboxed MCP bash_exec tool instead,
# which provides kernel-level network isolation via unshare --net. # which provides kernel-level network isolation via unshare --net.
# Task allows spawning sub-agents (rate-limited by security hooks). # Task allows spawning sub-agents (rate-limited by security hooks).
# WebSearch uses Brave Search via Anthropic's API — safe, no SSRF risk. _SDK_BUILTIN_TOOLS = ["Read", "Write", "Edit", "Glob", "Grep", "Task"]
_SDK_BUILTIN_TOOLS = ["Read", "Write", "Edit", "Glob", "Grep", "Task", "WebSearch"]
# SDK built-in tools that must be explicitly blocked.
# Bash: dangerous — agent uses mcp__copilot__bash_exec with kernel-level
# network isolation (unshare --net) instead.
# WebFetch: SSRF risk — can reach internal network (localhost, 10.x, etc.).
# Agent uses the SSRF-protected mcp__copilot__web_fetch tool instead.
SDK_DISALLOWED_TOOLS = ["Bash", "WebFetch"]
# Tools that are blocked entirely in security hooks (defence-in-depth).
# Includes SDK_DISALLOWED_TOOLS plus common aliases/synonyms.
BLOCKED_TOOLS = {
*SDK_DISALLOWED_TOOLS,
"bash",
"shell",
"exec",
"terminal",
"command",
}
# Tools allowed only when their path argument stays within the SDK workspace.
# The SDK uses these to handle oversized tool results (writes to tool-results/
# files, then reads them back) and for workspace file operations.
WORKSPACE_SCOPED_TOOLS = {"Read", "Write", "Edit", "Glob", "Grep"}
# Dangerous patterns in tool inputs
DANGEROUS_PATTERNS = [
r"sudo",
r"rm\s+-rf",
r"dd\s+if=",
r"/etc/passwd",
r"/etc/shadow",
r"chmod\s+777",
r"curl\s+.*\|.*sh",
r"wget\s+.*\|.*sh",
r"eval\s*\(",
r"exec\s*\(",
r"__import__",
r"os\.system",
r"subprocess",
]
# List of tool names for allowed_tools configuration # List of tool names for allowed_tools configuration
# Include MCP tools, the MCP Read tool for oversized results, # Include MCP tools, the MCP Read tool for oversized results,

View File

@@ -1,6 +1,8 @@
"use client"; "use client";
import { SidebarProvider } from "@/components/ui/sidebar"; import { SidebarProvider } from "@/components/ui/sidebar";
// TODO: Replace with modern Dialog component when available
import DeleteConfirmDialog from "@/components/__legacy__/delete-confirm-dialog";
import { ChatContainer } from "./components/ChatContainer/ChatContainer"; import { ChatContainer } from "./components/ChatContainer/ChatContainer";
import { ChatSidebar } from "./components/ChatSidebar/ChatSidebar"; import { ChatSidebar } from "./components/ChatSidebar/ChatSidebar";
import { MobileDrawer } from "./components/MobileDrawer/MobileDrawer"; import { MobileDrawer } from "./components/MobileDrawer/MobileDrawer";
@@ -31,6 +33,12 @@ export function CopilotPage() {
handleDrawerOpenChange, handleDrawerOpenChange,
handleSelectSession, handleSelectSession,
handleNewChat, handleNewChat,
// Delete functionality
sessionToDelete,
isDeleting,
handleDeleteClick,
handleConfirmDelete,
handleCancelDelete,
} = useCopilotPage(); } = useCopilotPage();
if (isUserLoading || !isLoggedIn) { if (isUserLoading || !isLoggedIn) {
@@ -48,7 +56,19 @@ export function CopilotPage() {
> >
{!isMobile && <ChatSidebar />} {!isMobile && <ChatSidebar />}
<div className="relative flex h-full w-full flex-col overflow-hidden bg-[#f8f8f9] px-0"> <div className="relative flex h-full w-full flex-col overflow-hidden bg-[#f8f8f9] px-0">
{isMobile && <MobileHeader onOpenDrawer={handleOpenDrawer} />} {isMobile && (
<MobileHeader
onOpenDrawer={handleOpenDrawer}
showDelete={!!sessionId}
isDeleting={isDeleting}
onDelete={() => {
const session = sessions.find((s) => s.id === sessionId);
if (session) {
handleDeleteClick(session.id, session.title);
}
}}
/>
)}
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<ChatContainer <ChatContainer
messages={messages} messages={messages}
@@ -75,6 +95,16 @@ export function CopilotPage() {
onOpenChange={handleDrawerOpenChange} onOpenChange={handleDrawerOpenChange}
/> />
)} )}
{/* 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}
/>
)}
</SidebarProvider> </SidebarProvider>
); );
} }

View File

@@ -1,8 +1,15 @@
"use client"; "use client";
import { useGetV2ListSessions } from "@/app/api/__generated__/endpoints/chat/chat"; import {
getGetV2ListSessionsQueryKey,
useDeleteV2DeleteSession,
useGetV2ListSessions,
} from "@/app/api/__generated__/endpoints/chat/chat";
import { Button } from "@/components/atoms/Button/Button"; import { Button } from "@/components/atoms/Button/Button";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner"; import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { Text } from "@/components/atoms/Text/Text"; import { Text } from "@/components/atoms/Text/Text";
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 { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -12,18 +19,52 @@ import {
useSidebar, useSidebar,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PlusCircleIcon, PlusIcon } from "@phosphor-icons/react"; import { PlusCircleIcon, PlusIcon, TrashIcon } from "@phosphor-icons/react";
import { useQueryClient } from "@tanstack/react-query";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { useState } from "react";
import { parseAsString, useQueryState } from "nuqs"; import { parseAsString, useQueryState } from "nuqs";
export function ChatSidebar() { export function ChatSidebar() {
const { state } = useSidebar(); const { state } = useSidebar();
const isCollapsed = state === "collapsed"; const isCollapsed = state === "collapsed";
const [sessionId, setSessionId] = useQueryState("sessionId", parseAsString); const [sessionId, setSessionId] = useQueryState("sessionId", parseAsString);
const [sessionToDelete, setSessionToDelete] = useState<{
id: string;
title: string | null | undefined;
} | null>(null);
const queryClient = useQueryClient();
const { data: sessionsResponse, isLoading: isLoadingSessions } = const { data: sessionsResponse, isLoading: isLoadingSessions } =
useGetV2ListSessions({ limit: 50 }); useGetV2ListSessions({ limit: 50 });
const { mutate: deleteSession, isPending: isDeleting } =
useDeleteV2DeleteSession({
mutation: {
onSuccess: () => {
// Invalidate sessions list to refetch
queryClient.invalidateQueries({
queryKey: getGetV2ListSessionsQueryKey(),
});
// If we deleted the current session, clear selection
if (sessionToDelete?.id === sessionId) {
setSessionId(null);
}
setSessionToDelete(null);
},
onError: (error) => {
toast({
title: "Failed to delete chat",
description:
error instanceof Error ? error.message : "An error occurred",
variant: "destructive",
});
setSessionToDelete(null);
},
},
});
const sessions = const sessions =
sessionsResponse?.status === 200 ? sessionsResponse.data.sessions : []; sessionsResponse?.status === 200 ? sessionsResponse.data.sessions : [];
@@ -35,6 +76,22 @@ export function ChatSidebar() {
setSessionId(id); setSessionId(id);
} }
function handleDeleteClick(
e: React.MouseEvent,
id: string,
title: string | null | undefined,
) {
e.stopPropagation(); // Prevent session selection
if (isDeleting) return; // Prevent double-click during deletion
setSessionToDelete({ id, title });
}
function handleConfirmDelete() {
if (sessionToDelete) {
deleteSession({ sessionId: sessionToDelete.id });
}
}
function formatDate(dateString: string) { function formatDate(dateString: string) {
const date = new Date(dateString); const date = new Date(dateString);
const now = new Date(); const now = new Date();
@@ -61,6 +118,7 @@ export function ChatSidebar() {
} }
return ( return (
<>
<Sidebar <Sidebar
variant="inset" variant="inset"
collapsible="icon" collapsible="icon"
@@ -130,15 +188,18 @@ export function ChatSidebar() {
</p> </p>
) : ( ) : (
sessions.map((session) => ( sessions.map((session) => (
<button <div
key={session.id} key={session.id}
onClick={() => handleSelectSession(session.id)}
className={cn( className={cn(
"w-full rounded-lg px-3 py-2.5 text-left transition-colors", "group relative w-full rounded-lg transition-colors",
session.id === sessionId session.id === sessionId
? "bg-zinc-100" ? "bg-zinc-100"
: "hover:bg-zinc-50", : "hover:bg-zinc-50",
)} )}
>
<button
onClick={() => handleSelectSession(session.id)}
className="w-full px-3 py-2.5 pr-10 text-left"
> >
<div className="flex min-w-0 max-w-full flex-col overflow-hidden"> <div className="flex min-w-0 max-w-full flex-col overflow-hidden">
<div className="min-w-0 max-w-full"> <div className="min-w-0 max-w-full">
@@ -159,6 +220,17 @@ export function ChatSidebar() {
</Text> </Text>
</div> </div>
</button> </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>
</div>
)) ))
)} )}
</motion.div> </motion.div>
@@ -184,5 +256,14 @@ export function ChatSidebar() {
</SidebarFooter> </SidebarFooter>
)} )}
</Sidebar> </Sidebar>
<DeleteConfirmDialog
entityType="chat"
entityName={sessionToDelete?.title || "Untitled chat"}
open={!!sessionToDelete}
onOpenChange={(open) => !open && setSessionToDelete(null)}
onDoDelete={handleConfirmDelete}
/>
</>
); );
} }

View File

@@ -1,22 +1,46 @@
import { Button } from "@/components/atoms/Button/Button"; import { Button } from "@/components/atoms/Button/Button";
import { NAVBAR_HEIGHT_PX } from "@/lib/constants"; import { NAVBAR_HEIGHT_PX } from "@/lib/constants";
import { ListIcon } from "@phosphor-icons/react"; import { ListIcon, TrashIcon } from "@phosphor-icons/react";
interface Props { interface Props {
onOpenDrawer: () => void; onOpenDrawer: () => void;
showDelete?: boolean;
isDeleting?: boolean;
onDelete?: () => void;
} }
export function MobileHeader({ onOpenDrawer }: Props) { export function MobileHeader({
onOpenDrawer,
showDelete,
isDeleting,
onDelete,
}: Props) {
return ( return (
<div
className="fixed z-50 flex gap-2"
style={{ left: "1rem", top: `${NAVBAR_HEIGHT_PX + 20}px` }}
>
<Button <Button
variant="icon" variant="icon"
size="icon" size="icon"
aria-label="Open sessions" aria-label="Open sessions"
onClick={onOpenDrawer} onClick={onOpenDrawer}
className="fixed z-50 bg-white shadow-md" className="bg-white shadow-md"
style={{ left: "1rem", top: `${NAVBAR_HEIGHT_PX + 20}px` }}
> >
<ListIcon width="1.25rem" height="1.25rem" /> <ListIcon width="1.25rem" height="1.25rem" />
</Button> </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

@@ -1,10 +1,15 @@
import { useGetV2ListSessions } from "@/app/api/__generated__/endpoints/chat/chat"; import {
getGetV2ListSessionsQueryKey,
useDeleteV2DeleteSession,
useGetV2ListSessions,
} from "@/app/api/__generated__/endpoints/chat/chat";
import { toast } from "@/components/molecules/Toast/use-toast"; import { toast } from "@/components/molecules/Toast/use-toast";
import { useBreakpoint } from "@/lib/hooks/useBreakpoint"; import { useBreakpoint } from "@/lib/hooks/useBreakpoint";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase"; import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { useChat } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react";
import { useQueryClient } from "@tanstack/react-query";
import { DefaultChatTransport } from "ai"; import { DefaultChatTransport } from "ai";
import { useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useChatSession } from "./useChatSession"; import { useChatSession } from "./useChatSession";
import { useLongRunningToolPolling } from "./hooks/useLongRunningToolPolling"; import { useLongRunningToolPolling } from "./hooks/useLongRunningToolPolling";
@@ -14,6 +19,11 @@ export function useCopilotPage() {
const { isUserLoading, isLoggedIn } = useSupabase(); const { isUserLoading, isLoggedIn } = useSupabase();
const [isDrawerOpen, setIsDrawerOpen] = useState(false); const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [pendingMessage, setPendingMessage] = useState<string | null>(null); const [pendingMessage, setPendingMessage] = useState<string | null>(null);
const [sessionToDelete, setSessionToDelete] = useState<{
id: string;
title: string | null | undefined;
} | null>(null);
const queryClient = useQueryClient();
const { const {
sessionId, sessionId,
@@ -24,6 +34,30 @@ export function useCopilotPage() {
isCreatingSession, isCreatingSession,
} = useChatSession(); } = useChatSession();
const { mutate: deleteSessionMutation, isPending: isDeleting } =
useDeleteV2DeleteSession({
mutation: {
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: getGetV2ListSessionsQueryKey(),
});
if (sessionToDelete?.id === sessionId) {
setSessionId(null);
}
setSessionToDelete(null);
},
onError: (error) => {
toast({
title: "Failed to delete chat",
description:
error instanceof Error ? error.message : "An error occurred",
variant: "destructive",
});
setSessionToDelete(null);
},
},
});
const breakpoint = useBreakpoint(); const breakpoint = useBreakpoint();
const isMobile = const isMobile =
breakpoint === "base" || breakpoint === "sm" || breakpoint === "md"; breakpoint === "base" || breakpoint === "sm" || breakpoint === "md";
@@ -143,6 +177,24 @@ export function useCopilotPage() {
if (isMobile) setIsDrawerOpen(false); if (isMobile) setIsDrawerOpen(false);
} }
const handleDeleteClick = useCallback(
(id: string, title: string | null | undefined) => {
if (isDeleting) return;
setSessionToDelete({ id, title });
},
[isDeleting],
);
const handleConfirmDelete = useCallback(() => {
if (sessionToDelete) {
deleteSessionMutation({ sessionId: sessionToDelete.id });
}
}, [sessionToDelete, deleteSessionMutation]);
const handleCancelDelete = useCallback(() => {
setSessionToDelete(null);
}, []);
return { return {
sessionId, sessionId,
messages, messages,
@@ -165,5 +217,11 @@ export function useCopilotPage() {
handleDrawerOpenChange, handleDrawerOpenChange,
handleSelectSession, handleSelectSession,
handleNewChat, handleNewChat,
// Delete functionality
sessionToDelete,
isDeleting,
handleDeleteClick,
handleConfirmDelete,
handleCancelDelete,
}; };
} }

View File

@@ -1151,6 +1151,36 @@
} }
}, },
"/api/chat/sessions/{session_id}": { "/api/chat/sessions/{session_id}": {
"delete": {
"tags": ["v2", "chat", "chat"],
"summary": "Delete Session",
"description": "Delete a chat session.\n\nPermanently removes a chat session and all its messages.\nOnly the owner can delete their sessions.\n\nArgs:\n session_id: The session ID to delete.\n user_id: The authenticated user's ID.\n\nReturns:\n 204 No Content on success.\n\nRaises:\n HTTPException: 404 if session not found or not owned by user.",
"operationId": "deleteV2DeleteSession",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "session_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Session Id" }
}
],
"responses": {
"204": { "description": "Successful Response" },
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"404": { "description": "Session not found or access denied" },
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
},
"get": { "get": {
"tags": ["v2", "chat", "chat"], "tags": ["v2", "chat", "chat"],
"summary": "Get Session", "summary": "Get Session",

View File

@@ -115,7 +115,7 @@ const DialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className, className,
)} )}
{...props} {...props}