Skip to content
Open
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
128 changes: 100 additions & 28 deletions src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Link, useNavigate } from '@tanstack/react-router';
import { Link, useNavigate, useRouterState } from '@tanstack/react-router';
import { Menu, Plus, LogOut, Crown, Settings, LayoutGrid } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Expand Down Expand Up @@ -32,7 +32,14 @@ import { DiscordIcon, GitHubIcon } from './icons/CompanyIcons';
import { cn } from '@/lib/utils';
import { Conversation, ConversationSettings } from '@shared/types';
import { UserAvatar } from '@/components/chat/UserAvatar';
import { SidebarConversationItem } from '@/components/sidebar/SidebarConversationItem';
import { RenameDialogDrawer } from '@/components/history/RenameDialogDrawer';
import {
useDeleteConversation,
useRenameConversation,
} from '@/services/conversationService';
import { useProfile } from '@/services/profileService';
import { useToast } from '@/hooks/use-toast';

interface SidebarProps {
isSidebarOpen: boolean;
Expand All @@ -46,6 +53,28 @@ function DesktopSidebar({ isSidebarOpen, setIsSidebarOpen }: SidebarProps) {
const { user, signOut } = useAuth();
const isMobile = useIsMobile();
const { data: profile } = useProfile();
const { toast } = useToast();
const activeEditorId = useRouterState({
select: (state) => state.location.pathname.match(/^\/editor\/([^/]+)/)?.[1],
});
const [renameTarget, setRenameTarget] = useState<Conversation | null>(null);
const [renameTitle, setRenameTitle] = useState('');
const [renameOpen, setRenameOpen] = useState(false);

const deleteConversation = useDeleteConversation({
onDeleted: (conversationId) => {
if (activeEditorId === conversationId) {
navigate({ to: '/' });
}
},
});

const renameConversation = useRenameConversation({
onRenamed: () => {
setRenameOpen(false);
setRenameTarget(null);
},
});

// Get 10 most recent conversations
const { data: recentConversations } = useQuery<Conversation[]>({
Expand Down Expand Up @@ -82,6 +111,40 @@ function DesktopSidebar({ isSidebarOpen, setIsSidebarOpen }: SidebarProps) {
navigate({ to: path });
};

const closeMobileSidebar = () => {
if (isMobile) {
setIsSidebarOpen(false);
}
};

const handleRenameRequest = (
conversationId: string,
currentTitle: string,
) => {
const conversation = recentConversations?.find(
(item) => item.id === conversationId,
);
if (!conversation) return;
setRenameTarget(conversation);
setRenameTitle(currentTitle || conversation.title);
setRenameOpen(true);
};

const handleRenameSave = () => {
if (!renameTarget) return;
if (!renameTitle.trim()) {
toast({
title: 'Title cannot be empty',
variant: 'default',
});
return;
}
renameConversation.mutate({
conversationId: renameTarget.id,
newTitle: renameTitle.trim(),
});
};

const renderUserSectionTrigger = () => {
if (isSidebarOpen) {
return (
Expand Down Expand Up @@ -228,35 +291,34 @@ function DesktopSidebar({ isSidebarOpen, setIsSidebarOpen }: SidebarProps) {
</Button>
</ConditionalWrapper>
{isSidebarOpen && submenu && (
<ul className="ml-7 flex list-none flex-col gap-1 border-l border-adam-neutral-500 px-2">
{submenu.map(
(
conversation: Omit<
Conversation,
'message_count' | 'last_message_at'
>,
) => {
return (
<Link
to="/editor/$id"
params={{ id: conversation.id }}
<div className="ml-7 border-l border-adam-neutral-500 px-2">
{submenu.length === 0 ? (
<p className="px-1 py-1.5 text-xs text-adam-neutral-500">
No creations yet
</p>
) : (
<ul className="flex list-none flex-col gap-0.5">
{submenu.map((conversation) => (
<SidebarConversationItem
key={conversation.id}
onClick={() => {
if (isMobile) {
setIsSidebarOpen(false);
}
}}
>
<li key={conversation.id}>
<span className="line-clamp-1 text-ellipsis text-nowrap rounded-md p-1 text-xs font-medium text-adam-neutral-400 transition-colors duration-200 ease-in-out [@media(hover:hover)]:hover:bg-adam-neutral-950 [@media(hover:hover)]:hover:text-adam-neutral-10">
{conversation.title}
</span>
</li>
</Link>
);
},
conversation={conversation}
onDelete={(id) => deleteConversation.mutate(id)}
onRename={handleRenameRequest}
onNavigate={closeMobileSidebar}
/>
))}
</ul>
)}
</ul>
{submenu.length > 0 && (
<Link
to="/history"
onClick={closeMobileSidebar}
className="mt-1 block px-1 py-1 text-xs font-medium text-adam-blue transition-colors [@media(hover:hover)]:hover:text-adam-blue/80"
>
View all creations
</Link>
)}
</div>
)}
</div>
))}
Expand Down Expand Up @@ -399,6 +461,16 @@ function DesktopSidebar({ isSidebarOpen, setIsSidebarOpen }: SidebarProps) {
</div>
</div>
</div>
<RenameDialogDrawer
open={renameOpen}
onOpenChange={(open) => {
setRenameOpen(open);
if (!open) setRenameTarget(null);
}}
newTitle={renameTitle}
onNewTitleChange={setRenameTitle}
onRename={handleRenameSave}
/>
</div>
);
}
Expand Down
126 changes: 126 additions & 0 deletions src/components/sidebar/SidebarConversationItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { Link } from '@tanstack/react-router';
import { ExternalLink, MoreVertical, Pencil, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { Conversation } from '@shared/types';

interface SidebarConversationItemProps {
conversation: Pick<Conversation, 'id' | 'title'>;
onDelete: (conversationId: string) => void;
onRename: (conversationId: string, currentTitle: string) => void;
onNavigate?: () => void;
}

export function SidebarConversationItem({
conversation,
onDelete,
onRename,
onNavigate,
}: SidebarConversationItemProps) {
return (
<li className="group relative list-none">
<Link
to="/editor/$id"
params={{ id: conversation.id }}
onClick={onNavigate}
className="block min-w-0 pr-7"
>
<span className="line-clamp-1 text-ellipsis text-nowrap rounded-md p-1 text-xs font-medium text-adam-neutral-400 transition-colors duration-200 ease-in-out [@media(hover:hover)]:group-hover:bg-adam-neutral-950 [@media(hover:hover)]:group-hover:text-adam-neutral-10">
{conversation.title || 'Untitled creation'}
</span>
</Link>
<div className="absolute right-0 top-1/2 -translate-y-1/2 opacity-0 transition-opacity duration-150 [@media(hover:hover)]:focus-within:opacity-100 [@media(hover:hover)]:group-hover:opacity-100">

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Hover-gated visibility makes sidebar item actions inaccessible on touch devices. The menu button uses [@media(hover:hover)] for both group-hover and focus-within, which never matches on touch-only devices, leaving the controls permanently invisible while still occupying layout space. This breaks mobile sidebar parity claimed in the PR test plan.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/components/sidebar/SidebarConversationItem.tsx, line 48:

<comment>Hover-gated visibility makes sidebar item actions inaccessible on touch devices. The menu button uses `[@media(hover:hover)]` for both `group-hover` and `focus-within`, which never matches on touch-only devices, leaving the controls permanently invisible while still occupying layout space. This breaks mobile sidebar parity claimed in the PR test plan.</comment>

<file context>
@@ -0,0 +1,126 @@
+          {conversation.title || 'Untitled creation'}
+        </span>
+      </Link>
+      <div className="absolute right-0 top-1/2 -translate-y-1/2 opacity-0 transition-opacity duration-150 [@media(hover:hover)]:focus-within:opacity-100 [@media(hover:hover)]:group-hover:opacity-100">
+        <AlertDialog>
+          <DropdownMenu>
</file context>

<AlertDialog>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 p-0 text-adam-neutral-400 hover:bg-adam-neutral-950 hover:text-adam-neutral-100"
onClick={(event) => event.preventDefault()}
>
<MoreVertical className="h-3.5 w-3.5" />
<span className="sr-only">Creation options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="min-w-[10rem] border-adam-neutral-700 bg-[#191A1A]"
>
<DropdownMenuItem
asChild
className="text-adam-neutral-50 hover:cursor-pointer hover:bg-adam-neutral-950 focus:bg-adam-neutral-950"
>
<Link
to="/editor/$id"
params={{ id: conversation.id }}
onClick={onNavigate}
>
<ExternalLink className="mr-2 h-3.5 w-3.5" />
Open
</Link>
</DropdownMenuItem>
<DropdownMenuItem
className="text-adam-neutral-50 hover:cursor-pointer hover:bg-adam-neutral-950 focus:bg-adam-neutral-950"
onClick={(event) => {
event.preventDefault();
onRename(conversation.id, conversation.title);
}}
>
<Pencil className="mr-2 h-3.5 w-3.5" />
Rename
</DropdownMenuItem>
<AlertDialogTrigger asChild>
<DropdownMenuItem
className="text-adam-neutral-50 hover:cursor-pointer hover:bg-adam-neutral-950 hover:text-red-500 focus:bg-adam-neutral-950 focus:text-red-500"
onSelect={(event) => event.preventDefault()}
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</AlertDialogTrigger>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialogContent className="border-[2px] border-adam-neutral-700 bg-adam-background-1">
<AlertDialogHeader>
<AlertDialogTitle className="text-adam-neutral-100">
Delete creation
</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &ldquo;
{conversation.title || 'Untitled creation'}&rdquo;? This action
cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700 dark:bg-red-900 dark:hover:bg-red-800"
onClick={() => onDelete(conversation.id)}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
Comment on lines +49 to +122

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 DropdownMenu stays open after AlertDialog dismissal

onSelect={(event) => event.preventDefault()} on the Delete item intentionally suppresses the dropdown's auto-close behaviour when the item is selected. This lets the AlertDialogTrigger fire, but it also means the DropdownMenu remains open while the AlertDialog is shown. Once the user confirms or cancels the dialog, focus returns to the DropdownMenuItem that is still live inside the open dropdown — the dropdown reappears visually and the user must click away to dismiss it.

The standard fix for Radix UI is to manage state manually: hold open state on the dropdown and an open state for the confirm dialog, then close the dropdown first (setDropdownOpen(false)) in the Delete onClick, and open the dialog (setDeleteDialogOpen(true)) separately — no AlertDialogTrigger inside the menu at all.

</div>
</li>
);
}
Loading