Rename chat (#673)

Fixes #102
This commit is contained in:
Will Chen
2025-07-21 17:44:56 -07:00
committed by GitHub
parent c5fdbeb90f
commit de21c6ff25
7 changed files with 338 additions and 81 deletions

View File

@@ -1,8 +1,8 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useNavigate, useRouterState } from "@tanstack/react-router";
import { formatDistanceToNow } from "date-fns";
import { PlusCircle, MoreVertical, Trash2 } from "lucide-react";
import { PlusCircle, MoreVertical, Trash2, Edit3 } from "lucide-react";
import { useAtom } from "jotai";
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
@@ -24,6 +24,8 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useChats } from "@/hooks/useChats";
import { RenameChatDialog } from "@/components/chat/RenameChatDialog";
import { DeleteChatDialog } from "@/components/chat/DeleteChatDialog";
export function ChatList({ show }: { show?: boolean }) {
const navigate = useNavigate();
@@ -34,6 +36,16 @@ export function ChatList({ show }: { show?: boolean }) {
const routerState = useRouterState();
const isChatRoute = routerState.location.pathname === "/chat";
// Rename dialog state
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const [renameChatId, setRenameChatId] = useState<number | null>(null);
const [renameChatTitle, setRenameChatTitle] = useState("");
// Delete dialog state
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deleteChatId, setDeleteChatId] = useState<number | null>(null);
const [deleteChatTitle, setDeleteChatTitle] = useState("");
// Update selectedChatId when route changes
useEffect(() => {
if (isChatRoute) {
@@ -108,88 +120,159 @@ export function ChatList({ show }: { show?: boolean }) {
}
};
const handleDeleteChatClick = (chatId: number, chatTitle: string) => {
setDeleteChatId(chatId);
setDeleteChatTitle(chatTitle);
setIsDeleteDialogOpen(true);
};
const handleConfirmDelete = async () => {
if (deleteChatId !== null) {
await handleDeleteChat(deleteChatId);
setIsDeleteDialogOpen(false);
setDeleteChatId(null);
setDeleteChatTitle("");
}
};
const handleRenameChat = (chatId: number, currentTitle: string) => {
setRenameChatId(chatId);
setRenameChatTitle(currentTitle);
setIsRenameDialogOpen(true);
};
const handleRenameDialogClose = (open: boolean) => {
setIsRenameDialogOpen(open);
if (!open) {
setRenameChatId(null);
setRenameChatTitle("");
}
};
return (
<SidebarGroup className="overflow-y-auto h-[calc(100vh-112px)]">
<SidebarGroupLabel>Recent Chats</SidebarGroupLabel>
<SidebarGroupContent>
<div className="flex flex-col space-y-4">
<Button
onClick={handleNewChat}
variant="outline"
className="flex items-center justify-start gap-2 mx-2 py-3"
>
<PlusCircle size={16} />
<span>New Chat</span>
</Button>
<>
<SidebarGroup className="overflow-y-auto h-[calc(100vh-112px)]">
<SidebarGroupLabel>Recent Chats</SidebarGroupLabel>
<SidebarGroupContent>
<div className="flex flex-col space-y-4">
<Button
onClick={handleNewChat}
variant="outline"
className="flex items-center justify-start gap-2 mx-2 py-3"
>
<PlusCircle size={16} />
<span>New Chat</span>
</Button>
{loading ? (
<div className="py-3 px-4 text-sm text-gray-500">
Loading chats...
</div>
) : chats.length === 0 ? (
<div className="py-3 px-4 text-sm text-gray-500">
No chats found
</div>
) : (
<SidebarMenu className="space-y-1">
{chats.map((chat) => (
<SidebarMenuItem key={chat.id} className="mb-1">
<div className="flex w-[175px] items-center">
<Button
variant="ghost"
onClick={() =>
handleChatClick({ chatId: chat.id, appId: chat.appId })
}
className={`justify-start w-full text-left py-3 pr-1 hover:bg-sidebar-accent/80 ${
selectedChatId === chat.id
? "bg-sidebar-accent text-sidebar-accent-foreground"
: ""
}`}
>
<div className="flex flex-col w-full">
<span className="truncate">
{chat.title || "New Chat"}
</span>
<span className="text-xs text-gray-500">
{formatDistanceToNow(new Date(chat.createdAt), {
addSuffix: true,
})}
</span>
</div>
</Button>
{selectedChatId === chat.id && (
<DropdownMenu
onOpenChange={(open) => setIsDropdownOpen(open)}
{loading ? (
<div className="py-3 px-4 text-sm text-gray-500">
Loading chats...
</div>
) : chats.length === 0 ? (
<div className="py-3 px-4 text-sm text-gray-500">
No chats found
</div>
) : (
<SidebarMenu className="space-y-1">
{chats.map((chat) => (
<SidebarMenuItem key={chat.id} className="mb-1">
<div className="flex w-[175px] items-center">
<Button
variant="ghost"
onClick={() =>
handleChatClick({
chatId: chat.id,
appId: chat.appId,
})
}
className={`justify-start w-full text-left py-3 pr-1 hover:bg-sidebar-accent/80 ${
selectedChatId === chat.id
? "bg-sidebar-accent text-sidebar-accent-foreground"
: ""
}`}
>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="ml-1 w-4"
onClick={(e) => e.stopPropagation()}
<div className="flex flex-col w-full">
<span className="truncate">
{chat.title || "New Chat"}
</span>
<span className="text-xs text-gray-500">
{formatDistanceToNow(new Date(chat.createdAt), {
addSuffix: true,
})}
</span>
</div>
</Button>
{selectedChatId === chat.id && (
<DropdownMenu
modal={false}
onOpenChange={(open) => setIsDropdownOpen(open)}
>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="ml-1 w-4"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="space-y-1 p-2"
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
variant="destructive"
onClick={() => handleDeleteChat(chat.id)}
>
<Trash2 className="mr-2 h-4 w-4" />
<span>Delete Chat</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</SidebarMenuItem>
))}
</SidebarMenu>
)}
</div>
</SidebarGroupContent>
</SidebarGroup>
<DropdownMenuItem
onClick={() =>
handleRenameChat(chat.id, chat.title || "")
}
className="px-3 py-2"
>
<Edit3 className="mr-2 h-4 w-4" />
<span>Rename Chat</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
handleDeleteChatClick(
chat.id,
chat.title || "New Chat",
)
}
className="px-3 py-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/50 focus:bg-red-50 dark:focus:bg-red-950/50"
>
<Trash2 className="mr-2 h-4 w-4" />
<span>Delete Chat</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</SidebarMenuItem>
))}
</SidebarMenu>
)}
</div>
</SidebarGroupContent>
</SidebarGroup>
{/* Rename Chat Dialog */}
{renameChatId !== null && (
<RenameChatDialog
chatId={renameChatId}
currentTitle={renameChatTitle}
isOpen={isRenameDialogOpen}
onOpenChange={handleRenameDialogClose}
onRename={refreshChats}
/>
)}
{/* Delete Chat Dialog */}
<DeleteChatDialog
isOpen={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
onConfirmDelete={handleConfirmDelete}
chatTitle={deleteChatTitle}
/>
</>
);
}

View File

@@ -0,0 +1,52 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
interface DeleteChatDialogProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onConfirmDelete: () => void;
chatTitle?: string;
}
export function DeleteChatDialog({
isOpen,
onOpenChange,
onConfirmDelete,
chatTitle,
}: DeleteChatDialogProps) {
return (
<AlertDialog open={isOpen} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Chat</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{chatTitle || "this chat"}"? This
action cannot be undone and all messages in this chat will be
permanently lost.
<br />
<br />
<strong>Note:</strong> Any code changes that have already been
accepted will be kept.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirmDelete}
className="bg-red-600 text-white hover:bg-red-700 dark:bg-red-600 dark:text-white dark:hover:bg-red-700"
>
Delete Chat
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,106 @@
import { useState } from "react";
import { IpcClient } from "@/ipc/ipc_client";
import { showError, showSuccess } from "@/lib/toast";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
interface RenameChatDialogProps {
chatId: number;
currentTitle: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onRename: () => void;
}
export function RenameChatDialog({
chatId,
currentTitle,
isOpen,
onOpenChange,
onRename,
}: RenameChatDialogProps) {
const [newTitle, setNewTitle] = useState("");
// Reset title when dialog opens
const handleOpenChange = (open: boolean) => {
if (open) {
setNewTitle(currentTitle || "");
} else {
setNewTitle("");
}
onOpenChange(open);
};
const handleSave = async () => {
if (!newTitle.trim()) {
return;
}
try {
await IpcClient.getInstance().updateChat({
chatId,
title: newTitle.trim(),
});
showSuccess("Chat renamed successfully");
// Call the parent's onRename callback to refresh the chat list
onRename();
// Close the dialog
handleOpenChange(false);
} catch (error) {
showError(`Failed to rename chat: ${(error as any).toString()}`);
}
};
const handleClose = () => {
handleOpenChange(false);
};
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Rename Chat</DialogTitle>
<DialogDescription>Enter a new name for this chat.</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="chat-title" className="text-right">
Title
</Label>
<Input
id="chat-title"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
className="col-span-3"
placeholder="Enter chat title..."
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSave();
}
}}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!newTitle.trim()}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -9,6 +9,7 @@ import { createLoggedHandler } from "./safe_handle";
import log from "electron-log";
import { getDyadAppPath } from "../../paths/paths";
import { UpdateChatParams } from "../ipc_types";
const logger = log.scope("chat_handlers");
const handle = createLoggedHandler(logger);
@@ -107,6 +108,10 @@ export function registerChatHandlers() {
await db.delete(chats).where(eq(chats.id, chatId));
});
handle("update-chat", async (_, { chatId, title }: UpdateChatParams) => {
await db.update(chats).set({ title }).where(eq(chats.id, chatId));
});
handle("delete-messages", async (_, chatId: number): Promise<void> => {
await db.delete(messages).where(eq(messages.chatId, chatId));
});

View File

@@ -49,6 +49,7 @@ import type {
IsVercelProjectAvailableParams,
SaveVercelAccessTokenParams,
VercelProject,
UpdateChatParams,
} from "./ipc_types";
import type { AppChatContext, ProposalResult } from "@/lib/schemas";
import { showError } from "@/lib/toast";
@@ -351,6 +352,10 @@ export class IpcClient {
return this.ipcRenderer.invoke("create-chat", appId);
}
public async updateChat(params: UpdateChatParams): Promise<void> {
return this.ipcRenderer.invoke("update-chat", params);
}
public async deleteChat(chatId: number): Promise<void> {
await this.ipcRenderer.invoke("delete-chat", chatId);
}

View File

@@ -316,3 +316,8 @@ export interface VercelProject {
name: string;
framework: string | null;
}
export interface UpdateChatParams {
chatId: number;
title: string;
}

View File

@@ -79,6 +79,7 @@ const validInvokeChannels = [
"get-system-platform",
"upload-to-signed-url",
"delete-chat",
"update-chat",
"delete-messages",
"start-chat-stream",
"does-release-note-exist",