Compare commits

..

3 Commits

Author SHA1 Message Date
Otto
5f55e1cd7c refactor(copilot): Extract DeleteChatDialog to own component
Move delete confirmation dialog from ChatSidebar to reusable component:
- Create DeleteChatDialog under app/copilot/components
- Clean props interface: session, isDeleting, onConfirm, onCancel
- Update ChatSidebar to use new component
- All existing tests pass
2026-02-17 04:37:33 +00:00
Otto
1681a84c2f test(copilot): Add integration tests for ChatSidebar delete dialog
- Tests session list rendering and empty state
- Tests date formatting (Today, Yesterday, X days ago)
- Tests delete dialog open/close flow
- Tests delete API call on confirmation
- Tests error handling for deletion failures
- Tests session selection and URL state
- Tests new chat button clearing selection

Reference: MainMarketplacePage/__tests__ pattern
2026-02-17 04:35:53 +00:00
Otto-AGPT
275950c98c refactor(copilot): Replace legacy delete dialog with molecules/Dialog
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
- CopilotPage: Use Dialog component for mobile delete confirmation
- 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

The new Dialog component provides consistent styling and behavior
across desktop (modal) and mobile (drawer) views.
2026-02-17 04:17:18 +00:00
7 changed files with 514 additions and 58 deletions

View File

@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand.
[[package]]
name = "aio-pika"
@@ -374,7 +374,7 @@ description = "LTS Port of Python audioop"
optional = false
python-versions = ">=3.13"
groups = ["main"]
markers = "python_version == \"3.13\""
markers = "python_version >= \"3.13\""
files = [
{file = "audioop_lts-0.2.2-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd3d4602dc64914d462924a08c1a9816435a2155d74f325853c1f1ac3b2d9800"},
{file = "audioop_lts-0.2.2-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:550c114a8df0aafe9a05442a1162dfc8fec37e9af1d625ae6060fed6e756f303"},
@@ -474,7 +474,7 @@ description = "Backport of asyncio.Runner, a context manager that controls event
optional = false
python-versions = "<3.11,>=3.8"
groups = ["main"]
markers = "python_version == \"3.10\""
markers = "python_version < \"3.11\""
files = [
{file = "backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5"},
{file = "backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162"},
@@ -487,7 +487,7 @@ description = "Backport of CPython tarfile module"
optional = false
python-versions = ">=3.8"
groups = ["main"]
markers = "python_version < \"3.12\""
markers = "python_version <= \"3.11\""
files = [
{file = "backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34"},
{file = "backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991"},
@@ -659,6 +659,7 @@ description = "Foreign Function Interface for Python calling C code."
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "platform_python_implementation != \"PyPy\" or sys_platform == \"darwin\""
files = [
{file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"},
{file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"},
@@ -1360,7 +1361,7 @@ description = "Backport of PEP 654 (exception groups)"
optional = false
python-versions = ">=3.7"
groups = ["main", "dev"]
markers = "python_version == \"3.10\""
markers = "python_version < \"3.11\""
files = [
{file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"},
{file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"},
@@ -1842,16 +1843,16 @@ files = [
google-auth = ">=2.14.1,<3.0.0"
googleapis-common-protos = ">=1.56.2,<2.0.0"
grpcio = [
{version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""},
{version = ">=1.33.2,<2.0.0", optional = true, markers = "extra == \"grpc\""},
{version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""},
]
grpcio-status = [
{version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""},
{version = ">=1.33.2,<2.0.0", optional = true, markers = "extra == \"grpc\""},
{version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""},
]
proto-plus = [
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
{version = ">=1.22.3,<2.0.0"},
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
]
protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0"
requests = ">=2.18.0,<3.0.0"
@@ -1962,8 +1963,8 @@ google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras
google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0"
grpcio = ">=1.33.2,<2.0.0"
proto-plus = [
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
{version = ">=1.22.3,<2.0.0"},
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
]
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0"
@@ -2023,9 +2024,9 @@ google-cloud-core = ">=2.0.0,<3.0.0"
grpc-google-iam-v1 = ">=0.12.4,<1.0.0"
opentelemetry-api = ">=1.9.0"
proto-plus = [
{version = ">=1.22.0,<2.0.0"},
{version = ">=1.22.2,<2.0.0", markers = "python_version >= \"3.11\""},
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
{version = ">=1.22.2,<2.0.0", markers = "python_version >= \"3.11\" and python_version < \"3.13\""},
{version = ">=1.22.0,<2.0.0", markers = "python_version < \"3.11\""},
]
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0"
@@ -3869,7 +3870,7 @@ description = "Fundamental package for array computing in Python"
optional = false
python-versions = ">=3.10"
groups = ["main"]
markers = "python_version == \"3.10\""
markers = "python_version < \"3.11\""
files = [
{file = "numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb"},
{file = "numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90"},
@@ -4354,9 +4355,9 @@ files = [
[package.dependencies]
numpy = [
{version = ">=1.26.0", markers = "python_version >= \"3.12\""},
{version = ">=1.23.2", markers = "python_version == \"3.11\""},
{version = ">=1.22.4", markers = "python_version < \"3.11\""},
{version = ">=1.23.2", markers = "python_version == \"3.11\""},
{version = ">=1.26.0", markers = "python_version >= \"3.12\""},
]
python-dateutil = ">=2.8.2"
pytz = ">=2020.1"
@@ -4599,8 +4600,8 @@ pinecone-plugin-interface = ">=0.0.7,<0.0.8"
python-dateutil = ">=2.5.3"
typing-extensions = ">=3.7.4"
urllib3 = [
{version = ">=1.26.5", markers = "python_version >= \"3.12\" and python_version < \"4.0\""},
{version = ">=1.26.0", markers = "python_version >= \"3.8\" and python_version < \"3.12\""},
{version = ">=1.26.5", markers = "python_version >= \"3.12\" and python_version < \"4.0\""},
]
[package.extras]
@@ -5428,7 +5429,7 @@ description = "C parser in Python"
optional = false
python-versions = ">=3.10"
groups = ["main"]
markers = "implementation_name != \"PyPy\""
markers = "(platform_python_implementation != \"PyPy\" or sys_platform == \"darwin\") and implementation_name != \"PyPy\""
files = [
{file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"},
{file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"},
@@ -6197,10 +6198,10 @@ files = [
grpcio = ">=1.41.0"
httpx = {version = ">=0.20.0", extras = ["http2"]}
numpy = [
{version = ">=2.1.0", markers = "python_version == \"3.13\""},
{version = ">=1.21", markers = "python_version == \"3.11\""},
{version = ">=1.26", markers = "python_version == \"3.12\""},
{version = ">=1.21,<2.3.0", markers = "python_version == \"3.10\""},
{version = ">=1.21", markers = "python_version == \"3.11\""},
{version = ">=2.1.0", markers = "python_version == \"3.13\""},
{version = ">=1.26", markers = "python_version == \"3.12\""},
]
portalocker = ">=2.7.0,<4.0"
protobuf = ">=3.20.0"
@@ -6739,30 +6740,30 @@ pyasn1 = ">=0.1.3"
[[package]]
name = "ruff"
version = "0.15.1"
version = "0.15.0"
description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false
python-versions = ">=3.7"
groups = ["dev"]
files = [
{file = "ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a"},
{file = "ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602"},
{file = "ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899"},
{file = "ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16"},
{file = "ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc"},
{file = "ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779"},
{file = "ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb"},
{file = "ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83"},
{file = "ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2"},
{file = "ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454"},
{file = "ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c"},
{file = "ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330"},
{file = "ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61"},
{file = "ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f"},
{file = "ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098"},
{file = "ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336"},
{file = "ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416"},
{file = "ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f"},
{file = "ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455"},
{file = "ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d"},
{file = "ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce"},
{file = "ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621"},
{file = "ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9"},
{file = "ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179"},
{file = "ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d"},
{file = "ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78"},
{file = "ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4"},
{file = "ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e"},
{file = "ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662"},
{file = "ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1"},
{file = "ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16"},
{file = "ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3"},
{file = "ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3"},
{file = "ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18"},
{file = "ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a"},
{file = "ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a"},
]
[[package]]
@@ -7406,7 +7407,7 @@ description = "A lil' TOML parser"
optional = false
python-versions = ">=3.8"
groups = ["main", "dev"]
markers = "python_version == \"3.10\""
markers = "python_version < \"3.11\""
files = [
{file = "tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867"},
{file = "tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9"},
@@ -8529,4 +8530,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt
[metadata]
lock-version = "2.1"
python-versions = ">=3.10,<3.14"
content-hash = "da583a7a8b2258844a07860bce6d735bad5166e3e1d4a94ed6d5556d7f0c699b"
content-hash = "55e095de555482f0fe47de7695f390fe93e7bcf739b31c391b2e5e3c3d938ae3"

View File

@@ -102,7 +102,7 @@ pyright = "^1.1.407"
pytest-mock = "^3.15.1"
pytest-watcher = "^0.6.3"
requests = "^2.32.5"
ruff = "^0.15.1"
ruff = "^0.15.0"
# NOTE: please insert new dependencies in their alphabetical location
[build-system]

View File

@@ -1,8 +1,8 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
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 { ChatSidebar } from "./components/ChatSidebar/ChatSidebar";
import { MobileDrawer } from "./components/MobileDrawer/MobileDrawer";
@@ -97,13 +97,47 @@ 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}
/>
<Dialog
title="Delete chat"
controlled={{
isOpen: !!sessionToDelete,
set: async (open) => {
if (!open && !isDeleting) {
handleCancelDelete();
}
},
}}
onClose={handleCancelDelete}
>
<Dialog.Content>
<p className="text-neutral-600">
Are you sure you want to delete{" "}
<span className="font-medium">
&quot;{sessionToDelete?.title || "Untitled chat"}&quot;
</span>
? This action cannot be undone.
</p>
<Dialog.Footer>
<Button
variant="ghost"
size="small"
onClick={handleCancelDelete}
disabled={isDeleting}
>
Cancel
</Button>
<Button
variant="primary"
size="small"
onClick={handleConfirmDelete}
loading={isDeleting}
className="bg-red-600 hover:bg-red-700"
>
Delete
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
)}
</SidebarProvider>
);

View File

@@ -8,8 +8,7 @@ import { Button } from "@/components/atoms/Button/Button";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
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 { DeleteChatDialog } from "../DeleteChatDialog";
import {
Sidebar,
SidebarContent,
@@ -92,6 +91,12 @@ export function ChatSidebar() {
}
}
function handleCancelDelete() {
if (!isDeleting) {
setSessionToDelete(null);
}
}
function formatDate(dateString: string) {
const date = new Date(dateString);
const now = new Date();
@@ -257,12 +262,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,350 @@
import { expect, test, describe, vi, beforeEach, afterEach } from "vitest";
import {
render,
screen,
waitFor,
fireEvent,
cleanup,
} from "@testing-library/react";
import { ChatSidebar } from "../ChatSidebar";
import { server } from "@/mocks/mock-server";
import {
getGetV2ListSessionsMockHandler,
getDeleteV2DeleteSessionMockHandler204,
getDeleteV2DeleteSessionMockHandler422,
} from "@/app/api/__generated__/endpoints/chat/chat.msw";
import { SidebarProvider } from "@/components/ui/sidebar";
import { NuqsTestingAdapter } from "nuqs/adapters/testing";
import { http, HttpResponse, delay } from "msw";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BackendAPIProvider } from "@/lib/autogpt-server-api/context";
// Mock sessions data
const mockSessions = {
sessions: [
{
id: "session-1",
title: "First Chat",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
{
id: "session-2",
title: "Second Chat",
created_at: new Date(Date.now() - 86400000).toISOString(),
updated_at: new Date(Date.now() - 86400000).toISOString(),
},
{
id: "session-3",
title: null,
created_at: new Date(Date.now() - 172800000).toISOString(),
updated_at: new Date(Date.now() - 172800000).toISOString(),
},
],
total: 3,
};
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
}
function TestWrapper({
children,
searchParams = "",
onUrlUpdate,
}: {
children: React.ReactNode;
searchParams?: string;
onUrlUpdate?: (event: { queryString: string }) => void;
}) {
const queryClient = createTestQueryClient();
return (
<QueryClientProvider client={queryClient}>
<BackendAPIProvider>
<NuqsTestingAdapter
searchParams={searchParams}
hasMemory
onUrlUpdate={onUrlUpdate}
>
<SidebarProvider defaultOpen={true}>{children}</SidebarProvider>
</NuqsTestingAdapter>
</BackendAPIProvider>
</QueryClientProvider>
);
}
function renderChatSidebar(
searchParams = "",
onUrlUpdate?: (event: { queryString: string }) => void,
) {
return render(
<TestWrapper searchParams={searchParams} onUrlUpdate={onUrlUpdate}>
<ChatSidebar />
</TestWrapper>,
);
}
describe("ChatSidebar", () => {
beforeEach(() => {
server.use(
getGetV2ListSessionsMockHandler(() => mockSessions),
getDeleteV2DeleteSessionMockHandler204(),
);
});
afterEach(() => {
cleanup();
});
describe("Sessions List", () => {
test("renders session list correctly", async () => {
renderChatSidebar();
// Use getAllByText since component may render multiple times
await waitFor(() => {
const elements = screen.getAllByText("First Chat");
expect(elements.length).toBeGreaterThan(0);
});
expect(screen.getAllByText("Second Chat").length).toBeGreaterThan(0);
expect(screen.getAllByText("Untitled chat").length).toBeGreaterThan(0);
});
test("shows empty state when no sessions", async () => {
server.use(
getGetV2ListSessionsMockHandler(() => ({
sessions: [],
total: 0,
})),
);
renderChatSidebar();
await waitFor(() => {
expect(
screen.getAllByText("No conversations yet").length,
).toBeGreaterThan(0);
});
});
test("formats dates correctly", async () => {
renderChatSidebar();
await waitFor(() => {
expect(screen.getAllByText("First Chat").length).toBeGreaterThan(0);
});
expect(screen.getAllByText("Today").length).toBeGreaterThan(0);
expect(screen.getAllByText("Yesterday").length).toBeGreaterThan(0);
expect(screen.getAllByText("2 days ago").length).toBeGreaterThan(0);
});
});
describe("Delete Dialog", () => {
test("opens delete dialog when trash button is clicked", async () => {
renderChatSidebar();
await waitFor(() => {
expect(screen.getAllByText("First Chat").length).toBeGreaterThan(0);
});
// Find and click the delete button for the first session
const deleteButtons = screen.getAllByLabelText("Delete chat");
fireEvent.click(deleteButtons[0]);
// Dialog should appear with confirmation text
await waitFor(() => {
expect(
screen.getByText(/Are you sure you want to delete/),
).toBeDefined();
});
});
test("closes dialog when cancel is clicked", async () => {
renderChatSidebar();
await waitFor(() => {
expect(screen.getAllByText("First Chat").length).toBeGreaterThan(0);
});
const deleteButtons = screen.getAllByLabelText("Delete chat");
fireEvent.click(deleteButtons[0]);
await waitFor(() => {
expect(
screen.getByText(/Are you sure you want to delete/),
).toBeDefined();
});
const cancelButton = screen.getByRole("button", { name: "Cancel" });
fireEvent.click(cancelButton);
await waitFor(() => {
expect(
screen.queryByText(/Are you sure you want to delete/),
).toBeNull();
});
});
test("calls delete API when delete button is clicked", async () => {
const deleteMock = vi.fn();
server.use(
http.delete(
"http://localhost:3000/api/proxy/api/chat/sessions/:sessionId",
async ({ params }) => {
deleteMock(params.sessionId);
return new HttpResponse(null, { status: 204 });
},
),
);
renderChatSidebar();
await waitFor(() => {
expect(screen.getAllByText("First Chat").length).toBeGreaterThan(0);
});
const deleteButtons = screen.getAllByLabelText("Delete chat");
fireEvent.click(deleteButtons[0]);
await waitFor(() => {
expect(
screen.getByText(/Are you sure you want to delete/),
).toBeDefined();
});
const deleteButton = screen.getByRole("button", { name: "Delete" });
fireEvent.click(deleteButton);
await waitFor(() => {
expect(deleteMock).toHaveBeenCalledWith("session-1");
});
});
test("closes dialog after successful deletion", async () => {
renderChatSidebar();
await waitFor(() => {
expect(screen.getAllByText("First Chat").length).toBeGreaterThan(0);
});
const deleteButtons = screen.getAllByLabelText("Delete chat");
fireEvent.click(deleteButtons[0]);
await waitFor(() => {
expect(
screen.getByText(/Are you sure you want to delete/),
).toBeDefined();
});
const deleteButton = screen.getByRole("button", { name: "Delete" });
fireEvent.click(deleteButton);
await waitFor(() => {
expect(
screen.queryByText(/Are you sure you want to delete/),
).toBeNull();
});
});
test("handles deletion error gracefully", async () => {
server.use(getDeleteV2DeleteSessionMockHandler422());
renderChatSidebar();
await waitFor(() => {
expect(screen.getAllByText("First Chat").length).toBeGreaterThan(0);
});
const deleteButtons = screen.getAllByLabelText("Delete chat");
fireEvent.click(deleteButtons[0]);
await waitFor(() => {
expect(
screen.getByText(/Are you sure you want to delete/),
).toBeDefined();
});
const deleteButton = screen.getByRole("button", { name: "Delete" });
fireEvent.click(deleteButton);
// Dialog should close even on error
await waitFor(() => {
expect(
screen.queryByText(/Are you sure you want to delete/),
).toBeNull();
});
});
});
describe("Session Selection", () => {
test("highlights currently selected session", async () => {
renderChatSidebar("?sessionId=session-1");
await waitFor(() => {
expect(screen.getAllByText("First Chat").length).toBeGreaterThan(0);
});
// Find the session with selected styling (text-zinc-600 indicates selected)
const selectedSessions = screen.getAllByText("First Chat");
const hasSelectedStyle = selectedSessions.some(
(el) =>
el.className.includes("text-zinc-600") ||
el.closest("div")?.className.includes("bg-zinc-100"),
);
expect(hasSelectedStyle).toBe(true);
});
test("selects session when clicked", async () => {
const urlUpdateSpy = vi.fn();
renderChatSidebar("", urlUpdateSpy);
await waitFor(() => {
expect(screen.getAllByText("First Chat").length).toBeGreaterThan(0);
});
// Find a session button and click it
const sessionElements = screen.getAllByText("First Chat");
const sessionButton = sessionElements[0].closest("button");
if (sessionButton) {
fireEvent.click(sessionButton);
}
await waitFor(() => {
expect(urlUpdateSpy).toHaveBeenCalled();
const lastCall =
urlUpdateSpy.mock.calls[urlUpdateSpy.mock.calls.length - 1];
expect(lastCall[0].queryString).toContain("sessionId=session-1");
});
});
});
describe("New Chat", () => {
test("clears session selection when new chat button is clicked", async () => {
const urlUpdateSpy = vi.fn();
renderChatSidebar("?sessionId=session-1", urlUpdateSpy);
await waitFor(() => {
expect(screen.getAllByText("First Chat").length).toBeGreaterThan(0);
});
const newChatButton = screen.getByRole("button", { name: "New Chat" });
fireEvent.click(newChatButton);
await waitFor(() => {
expect(urlUpdateSpy).toHaveBeenCalled();
const lastCall =
urlUpdateSpy.mock.calls[urlUpdateSpy.mock.calls.length - 1];
expect(lastCall[0].queryString).not.toContain("sessionId=session-1");
});
});
});
});

View File

@@ -0,0 +1,66 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
interface DeleteChatDialogProps {
/** The session to delete, or null if dialog should be closed */
session: { id: string; title: string | null | undefined } | null;
/** Whether deletion is in progress */
isDeleting: boolean;
/** Called when user confirms deletion */
onConfirm: () => void;
/** Called when user cancels (only works when not deleting) */
onCancel: () => void;
}
export function DeleteChatDialog({
session,
isDeleting,
onConfirm,
onCancel,
}: DeleteChatDialogProps) {
return (
<Dialog
title="Delete chat"
controlled={{
isOpen: !!session,
set: async (open) => {
if (!open && !isDeleting) {
onCancel();
}
},
}}
onClose={onCancel}
>
<Dialog.Content>
<p className="text-neutral-600">
Are you sure you want to delete{" "}
<span className="font-medium">
&quot;{session?.title || "Untitled chat"}&quot;
</span>
? This action cannot be undone.
</p>
<Dialog.Footer>
<Button
variant="ghost"
size="small"
onClick={onCancel}
disabled={isDeleting}
>
Cancel
</Button>
<Button
variant="primary"
size="small"
onClick={onConfirm}
loading={isDeleting}
className="bg-red-600 hover:bg-red-700"
>
Delete
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -0,0 +1 @@
export { DeleteChatDialog } from "./DeleteChatDialog";