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 { useNavigate, useRouterState } from "@tanstack/react-router";
import { formatDistanceToNow } from "date-fns"; 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 { useAtom } from "jotai";
import { selectedChatIdAtom } from "@/atoms/chatAtoms"; import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { selectedAppIdAtom } from "@/atoms/appAtoms"; import { selectedAppIdAtom } from "@/atoms/appAtoms";
@@ -24,6 +24,8 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { useChats } from "@/hooks/useChats"; import { useChats } from "@/hooks/useChats";
import { RenameChatDialog } from "@/components/chat/RenameChatDialog";
import { DeleteChatDialog } from "@/components/chat/DeleteChatDialog";
export function ChatList({ show }: { show?: boolean }) { export function ChatList({ show }: { show?: boolean }) {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -34,6 +36,16 @@ export function ChatList({ show }: { show?: boolean }) {
const routerState = useRouterState(); const routerState = useRouterState();
const isChatRoute = routerState.location.pathname === "/chat"; 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 // Update selectedChatId when route changes
useEffect(() => { useEffect(() => {
if (isChatRoute) { 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 ( return (
<SidebarGroup className="overflow-y-auto h-[calc(100vh-112px)]"> <>
<SidebarGroupLabel>Recent Chats</SidebarGroupLabel> <SidebarGroup className="overflow-y-auto h-[calc(100vh-112px)]">
<SidebarGroupContent> <SidebarGroupLabel>Recent Chats</SidebarGroupLabel>
<div className="flex flex-col space-y-4"> <SidebarGroupContent>
<Button <div className="flex flex-col space-y-4">
onClick={handleNewChat} <Button
variant="outline" onClick={handleNewChat}
className="flex items-center justify-start gap-2 mx-2 py-3" variant="outline"
> className="flex items-center justify-start gap-2 mx-2 py-3"
<PlusCircle size={16} /> >
<span>New Chat</span> <PlusCircle size={16} />
</Button> <span>New Chat</span>
</Button>
{loading ? ( {loading ? (
<div className="py-3 px-4 text-sm text-gray-500"> <div className="py-3 px-4 text-sm text-gray-500">
Loading chats... Loading chats...
</div> </div>
) : chats.length === 0 ? ( ) : chats.length === 0 ? (
<div className="py-3 px-4 text-sm text-gray-500"> <div className="py-3 px-4 text-sm text-gray-500">
No chats found No chats found
</div> </div>
) : ( ) : (
<SidebarMenu className="space-y-1"> <SidebarMenu className="space-y-1">
{chats.map((chat) => ( {chats.map((chat) => (
<SidebarMenuItem key={chat.id} className="mb-1"> <SidebarMenuItem key={chat.id} className="mb-1">
<div className="flex w-[175px] items-center"> <div className="flex w-[175px] items-center">
<Button <Button
variant="ghost" variant="ghost"
onClick={() => onClick={() =>
handleChatClick({ chatId: chat.id, appId: chat.appId }) handleChatClick({
} chatId: chat.id,
className={`justify-start w-full text-left py-3 pr-1 hover:bg-sidebar-accent/80 ${ appId: chat.appId,
selectedChatId === chat.id })
? "bg-sidebar-accent text-sidebar-accent-foreground" }
: "" 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)}
> >
<DropdownMenuTrigger asChild> <div className="flex flex-col w-full">
<Button <span className="truncate">
variant="ghost" {chat.title || "New Chat"}
size="icon" </span>
className="ml-1 w-4" <span className="text-xs text-gray-500">
onClick={(e) => e.stopPropagation()} {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" /> <DropdownMenuItem
</Button> onClick={() =>
</DropdownMenuTrigger> handleRenameChat(chat.id, chat.title || "")
<DropdownMenuContent align="end"> }
<DropdownMenuItem className="px-3 py-2"
variant="destructive" >
onClick={() => handleDeleteChat(chat.id)} <Edit3 className="mr-2 h-4 w-4" />
> <span>Rename Chat</span>
<Trash2 className="mr-2 h-4 w-4" /> </DropdownMenuItem>
<span>Delete Chat</span> <DropdownMenuItem
</DropdownMenuItem> onClick={() =>
</DropdownMenuContent> handleDeleteChatClick(
</DropdownMenu> chat.id,
)} chat.title || "New Chat",
</div> )
</SidebarMenuItem> }
))} 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"
</SidebarMenu> >
)} <Trash2 className="mr-2 h-4 w-4" />
</div> <span>Delete Chat</span>
</SidebarGroupContent> </DropdownMenuItem>
</SidebarGroup> </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 log from "electron-log";
import { getDyadAppPath } from "../../paths/paths"; import { getDyadAppPath } from "../../paths/paths";
import { UpdateChatParams } from "../ipc_types";
const logger = log.scope("chat_handlers"); const logger = log.scope("chat_handlers");
const handle = createLoggedHandler(logger); const handle = createLoggedHandler(logger);
@@ -107,6 +108,10 @@ export function registerChatHandlers() {
await db.delete(chats).where(eq(chats.id, chatId)); 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> => { handle("delete-messages", async (_, chatId: number): Promise<void> => {
await db.delete(messages).where(eq(messages.chatId, chatId)); await db.delete(messages).where(eq(messages.chatId, chatId));
}); });

View File

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

View File

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

View File

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