Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/components/layout/app-shell/AppShell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,11 @@ describe("AppShell agent sidebar pass-through", () => {
fireEvent.keyDown(input, { key: "Enter" });
expect(onRename).toHaveBeenCalledWith("h1", "New title");

// Delete opens a confirmation dialog first; the forwarded handler fires
// only after the user clicks the destructive confirm button.
fireEvent.click(screen.getByLabelText("Delete conversation"));
expect(onDelete).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
expect(onDelete).toHaveBeenCalledWith("h1");
});

Expand Down
9 changes: 5 additions & 4 deletions src/components/layout/app-shell/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,12 @@ export interface AppShellProps {
/** Enables the hover rename affordance on agent history rows.
* Called with the trimmed title (1-200 chars) when the user commits an inline rename. */
onRenameAgentConversation?: AgentSidebarHeaderProps["onRenameConversation"];
/** Enables the hover delete affordance on agent history rows. Confirmation
* (if any) is the consumer's responsibility; the kit fires immediately. */
/** Enables the hover delete affordance on agent history rows. Clicking it
* opens a destructive confirmation dialog first; this fires only once the
* user confirms (cancel/dismiss does nothing). */
onDeleteAgentConversation?: AgentSidebarHeaderProps["onDeleteConversation"];
/** Label overrides for the agent header (history, rename, new-chat strings).
* All have EN defaults. */
/** Label overrides for the agent header (history, rename, new-chat, and the
* delete-confirmation dialog strings). All have EN defaults. */
agentHeaderLabels?: AgentSidebarHeaderProps["labels"];
/** Messages waiting to send once the current reply finishes — rendered as
* cancellable rows above the agent input, in send order. Display-only:
Expand Down
4 changes: 3 additions & 1 deletion src/components/ui/agent/sidebar/AgentSidebar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,9 @@ export const ActiveLastTab: StoryObj = {
},
};

/** History row actions: hover edit (inline rename) + hover delete. */
/** History row actions: hover edit (inline rename) + hover delete. Delete
* opens a destructive confirmation dialog; onDeleteConversation fires only
* on confirm. */
export const History: StoryObj = {
render: () => {
const [history, setHistory] = useState<AgentSidebarHistoryGroup[]>(mockAgentHistory);
Expand Down
63 changes: 61 additions & 2 deletions src/components/ui/agent/sidebar/AgentSidebar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { cleanup, render, screen, fireEvent } from "@testing-library/react";
import { describe, expect, it, vi, afterEach, beforeAll, afterAll } from "vitest";
import { AgentSidebarHeader } from "./AgentSidebarHeader";
import type { AgentSidebarHeaderProps } from "./AgentSidebarHeader";
import { AgentSidebarInput } from "./AgentSidebarInput";
import type { AgentSidebarHistoryGroup, ChatTab } from "./types";

Expand Down Expand Up @@ -250,6 +251,7 @@ describe("AgentSidebarHeader history delete", () => {
const renderHistory = (props?: {
onDelete?: (id: string) => void;
onSelect?: (id: string) => void;
labels?: AgentSidebarHeaderProps["labels"];
}) =>
render(
<AgentSidebarHeader
Expand All @@ -259,10 +261,12 @@ describe("AgentSidebarHeader history delete", () => {
history={historyData}
onSelectHistoryItem={props?.onSelect ?? (() => {})}
onDeleteConversation={props?.onDelete}
labels={props?.labels}
/>,
);

const openHistory = () => fireEvent.click(screen.getByLabelText("Chat history"));
const clickDelete = () => fireEvent.click(screen.getByLabelText("Delete conversation"));

it("shows the delete button when onDeleteConversation is provided", () => {
renderHistory({ onDelete: () => {} });
Expand All @@ -276,14 +280,69 @@ describe("AgentSidebarHeader history delete", () => {
expect(screen.queryByLabelText("Delete conversation")).not.toBeInTheDocument();
});

it("calls onDeleteConversation with the row id, without opening the conversation", () => {
it("opens a confirmation dialog instead of deleting immediately", () => {
const onDelete = vi.fn();
const onSelect = vi.fn();
renderHistory({ onDelete, onSelect });
openHistory();
fireEvent.click(screen.getByLabelText("Delete conversation"));
clickDelete();
// Dialog is shown; nothing deleted or opened yet.
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByText("Delete conversation?")).toBeInTheDocument();
expect(onDelete).not.toHaveBeenCalled();
expect(onSelect).not.toHaveBeenCalled();
});

it("calls onDeleteConversation with the row id only after confirm", () => {
const onDelete = vi.fn();
const onSelect = vi.fn();
renderHistory({ onDelete, onSelect });
openHistory();
clickDelete();
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
expect(onDelete).toHaveBeenCalledWith("h-1");
expect(onDelete).toHaveBeenCalledTimes(1);
expect(onSelect).not.toHaveBeenCalled();
// Dialog closes after confirm.
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});

it("does not delete when the dialog is cancelled", () => {
const onDelete = vi.fn();
renderHistory({ onDelete });
openHistory();
clickDelete();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onDelete).not.toHaveBeenCalled();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});

it("does not delete when the dialog is dismissed via Escape", () => {
const onDelete = vi.fn();
renderHistory({ onDelete });
openHistory();
clickDelete();
fireEvent.keyDown(document, { key: "Escape" });
expect(onDelete).not.toHaveBeenCalled();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});

it("uses overridable labels for the confirm dialog", () => {
renderHistory({
onDelete: () => {},
labels: {
deleteConfirmTitle: "Eliminar conversación?",
deleteConfirmMessage: "Se eliminará de forma permanente.",
deleteConfirmConfirmLabel: "Eliminar",
deleteConfirmCancelLabel: "Cancelar",
},
});
openHistory();
clickDelete();
expect(screen.getByText("Eliminar conversación?")).toBeInTheDocument();
expect(screen.getByText("Se eliminará de forma permanente.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Eliminar" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Cancelar" })).toBeInTheDocument();
});
});

Expand Down
43 changes: 40 additions & 3 deletions src/components/ui/agent/sidebar/AgentSidebarHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { isDev } from "@/utils/env";
import { IconButton } from "@/components/ui/actions/icon-button/IconButton";
import { Icon } from "@/components/ui/media/icon/Icon";
import { Notch } from "@/components/ui/surfaces/notch/Notch";
import { Dialog } from "@/components/ui/surfaces/dialog/Dialog";
import { useClickOutside } from "@/hooks/useClickOutside";
import type { AgentSidebarHeaderLabels, AgentSidebarHistoryGroup, ChatTab } from "./types";

Expand All @@ -29,8 +30,9 @@ export interface AgentSidebarHeaderProps {
/** Enables the hover rename affordance on history rows.
* Called with the trimmed title (1-200 chars) when the user commits an inline rename. */
onRenameConversation?: (id: string, title: string) => void;
/** Enables the hover delete affordance on history rows. Confirmation (if any)
* is the consumer's responsibility; the kit fires immediately. */
/** Enables the hover delete affordance on history rows. Clicking it opens a
* destructive confirmation dialog first; this fires only once the user
* confirms (cancel/dismiss does nothing). */
onDeleteConversation?: (id: string) => void;
/** Label overrides. All have EN defaults. */
labels?: AgentSidebarHeaderLabels;
Expand Down Expand Up @@ -125,6 +127,11 @@ export function AgentSidebarHeader({
const [editValue, setEditValue] = useState("");
const editInputRef = useRef<HTMLInputElement>(null);

// Id of the conversation pending a delete confirmation; null = dialog closed.
// Deletion only fires once the user confirms, so a misclick on the row's
// trash icon can't silently remove a conversation.
const [deletingId, setDeletingId] = useState<string | null>(null);

const calculateVisible = useCallback(() => {
if (!tabsContainerRef.current) return;
// clientWidth includes the strip's own pl-10 + pr-3 padding — subtract
Expand Down Expand Up @@ -416,7 +423,7 @@ export function AgentSidebarHeader({
<button
type="button"
className="w-5 h-5 flex items-center justify-center rounded-full cursor-pointer text-on-surface opacity-70 hover:opacity-100 hover:bg-error/10 hover:text-error shrink-0"
onClick={(e) => { e.stopPropagation(); onDeleteConversation(item.id); }}
onClick={(e) => { e.stopPropagation(); setDeletingId(item.id); }}
aria-label={labels?.deleteConversationLabel ?? "Delete conversation"}
>
<Icon name="delete" size={12} />
Expand Down Expand Up @@ -565,6 +572,36 @@ export function AgentSidebarHeader({
</div>
</div>
)}

{/* Destructive delete confirmation. onDeleteConversation fires only on
confirm; Cancel, Escape, and backdrop click all just close the
dialog (via setDeletingId(null)) so a misclick is recoverable. */}
<Dialog
open={deletingId !== null}
onClose={() => setDeletingId(null)}
title={labels?.deleteConfirmTitle ?? "Delete conversation?"}
actions={[
{
label: labels?.deleteConfirmCancelLabel ?? "Cancel",
variant: "text",
onClick: () => setDeletingId(null),
},
{
label: labels?.deleteConfirmConfirmLabel ?? "Delete",
variant: "filled",
color: "error",
onClick: () => {
if (deletingId !== null) onDeleteConversation?.(deletingId);
setDeletingId(null);
},
},
]}
>
<p className="text-sm text-on-surface-variant">
{labels?.deleteConfirmMessage ??
"This conversation will be permanently deleted."}
</p>
</Dialog>
</div>
);
}
9 changes: 9 additions & 0 deletions src/components/ui/agent/sidebar/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ export interface AgentSidebarHeaderLabels {
cancelRenameLabel?: string;
/** aria-label on the delete icon button in a history row. Default: "Delete conversation" */
deleteConversationLabel?: string;
/** Title of the delete-confirmation dialog. Default: "Delete conversation?" */
deleteConfirmTitle?: string;
/** Body text of the delete-confirmation dialog.
* Default: "This conversation will be permanently deleted." */
deleteConfirmMessage?: string;
/** Label on the destructive confirm button in the delete dialog. Default: "Delete" */
deleteConfirmConfirmLabel?: string;
/** Label on the cancel button in the delete dialog. Default: "Cancel" */
deleteConfirmCancelLabel?: string;
/** aria-label on the + button and text of the overflow new-chat entry. Default: "New chat" */
newChatLabel?: string;
}
Expand Down
Loading