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,7 +120,37 @@ 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)]"> <SidebarGroup className="overflow-y-auto h-[calc(100vh-112px)]">
<SidebarGroupLabel>Recent Chats</SidebarGroupLabel> <SidebarGroupLabel>Recent Chats</SidebarGroupLabel>
<SidebarGroupContent> <SidebarGroupContent>
@@ -138,7 +180,10 @@ export function ChatList({ show }: { show?: boolean }) {
<Button <Button
variant="ghost" variant="ghost"
onClick={() => onClick={() =>
handleChatClick({ chatId: chat.id, appId: chat.appId }) handleChatClick({
chatId: chat.id,
appId: chat.appId,
})
} }
className={`justify-start w-full text-left py-3 pr-1 hover:bg-sidebar-accent/80 ${ className={`justify-start w-full text-left py-3 pr-1 hover:bg-sidebar-accent/80 ${
selectedChatId === chat.id selectedChatId === chat.id
@@ -160,6 +205,7 @@ export function ChatList({ show }: { show?: boolean }) {
{selectedChatId === chat.id && ( {selectedChatId === chat.id && (
<DropdownMenu <DropdownMenu
modal={false}
onOpenChange={(open) => setIsDropdownOpen(open)} onOpenChange={(open) => setIsDropdownOpen(open)}
> >
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -172,10 +218,27 @@ export function ChatList({ show }: { show?: boolean }) {
<MoreVertical className="h-4 w-4" /> <MoreVertical className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent
align="end"
className="space-y-1 p-2"
>
<DropdownMenuItem <DropdownMenuItem
variant="destructive" onClick={() =>
onClick={() => handleDeleteChat(chat.id)} 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" /> <Trash2 className="mr-2 h-4 w-4" />
<span>Delete Chat</span> <span>Delete Chat</span>
@@ -191,5 +254,25 @@ export function ChatList({ show }: { show?: boolean }) {
</div> </div>
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </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",