feat(chat): improve session history UX and align chat window offset

- Add optimistic update + rollback to archive mutation
- Replace Trash2 with Archive icon (correct semantics)
- Add Tooltip on archive button, replace native title
- Show spinner during archive, toast on error
- Use cn() for className composition
- Align chat window offset to bottom-2 right-2 (match FAB)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-04-13 18:27:38 +08:00
parent acad93163b
commit 458b1e19e2
3 changed files with 61 additions and 16 deletions

View File

@@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { useWorkspaceId } from "../hooks";
import { chatKeys } from "./queries";
import type { ChatSession } from "../types";
export function useCreateChatSession() {
const qc = useQueryClient();
@@ -23,6 +24,29 @@ export function useArchiveChatSession() {
return useMutation({
mutationFn: (sessionId: string) => api.archiveChatSession(sessionId),
onMutate: async (sessionId) => {
await qc.cancelQueries({ queryKey: chatKeys.sessions(wsId) });
await qc.cancelQueries({ queryKey: chatKeys.allSessions(wsId) });
const prevSessions = qc.getQueryData<ChatSession[]>(chatKeys.sessions(wsId));
const prevAll = qc.getQueryData<ChatSession[]>(chatKeys.allSessions(wsId));
// Optimistic: remove from active, mark as archived in allSessions
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), (old) =>
old ? old.filter((s) => s.id !== sessionId) : old,
);
qc.setQueryData<ChatSession[]>(chatKeys.allSessions(wsId), (old) =>
old?.map((s) =>
s.id === sessionId ? { ...s, status: "archived" as const } : s,
),
);
return { prevSessions, prevAll };
},
onError: (_err, _id, ctx) => {
if (ctx?.prevSessions) qc.setQueryData(chatKeys.sessions(wsId), ctx.prevSessions);
if (ctx?.prevAll) qc.setQueryData(chatKeys.allSessions(wsId), ctx.prevAll);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });

View File

@@ -1,11 +1,12 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, MessageSquare, Archive, Trash2 } from "lucide-react";
import { ArrowLeft, MessageSquare, Archive, Bot, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@multica/ui/lib/utils";
import { Button } from "@multica/ui/components/ui/button";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import { Avatar, AvatarFallback, AvatarImage } from "@multica/ui/components/ui/avatar";
import { Bot } from "lucide-react";
import { useWorkspaceId } from "@multica/core/hooks";
import { agentListOptions } from "@multica/core/workspace/queries";
import { allChatSessionsOptions } from "@multica/core/chat/queries";
@@ -36,10 +37,12 @@ export function ChatSessionHistory() {
const handleArchive = (e: React.MouseEvent, sessionId: string) => {
e.stopPropagation();
archiveSession.mutate(sessionId);
if (activeSessionId === sessionId) {
setActiveSession(null);
}
archiveSession.mutate(sessionId, {
onError: () => toast.error("Failed to archive session"),
});
};
const activeSessions = sessions.filter((s) => s.status === "active");
@@ -82,6 +85,7 @@ export function ChatSessionHistory() {
sessions={activeSessions}
agentMap={agentMap}
activeSessionId={activeSessionId}
archivingId={archiveSession.isPending ? (archiveSession.variables as string) : null}
onSelect={handleSelectSession}
onArchive={handleArchive}
/>
@@ -92,6 +96,7 @@ export function ChatSessionHistory() {
sessions={archivedSessions}
agentMap={agentMap}
activeSessionId={activeSessionId}
archivingId={null}
onSelect={handleSelectSession}
/>
)}
@@ -107,6 +112,7 @@ function SessionGroup({
sessions,
agentMap,
activeSessionId,
archivingId,
onSelect,
onArchive,
}: {
@@ -114,6 +120,7 @@ function SessionGroup({
sessions: ChatSession[];
agentMap: Map<string, Agent>;
activeSessionId: string | null;
archivingId: string | null;
onSelect: (session: ChatSession) => void;
onArchive?: (e: React.MouseEvent, sessionId: string) => void;
}) {
@@ -130,6 +137,7 @@ function SessionGroup({
session={session}
agent={agentMap.get(session.agent_id) ?? null}
isActive={session.id === activeSessionId}
isArchiving={session.id === archivingId}
onSelect={() => onSelect(session)}
onArchive={onArchive ? (e) => onArchive(e, session.id) : undefined}
/>
@@ -142,12 +150,14 @@ function SessionItem({
session,
agent,
isActive,
isArchiving,
onSelect,
onArchive,
}: {
session: ChatSession;
agent: Agent | null;
isActive: boolean;
isArchiving: boolean;
onSelect: () => void;
onArchive?: (e: React.MouseEvent) => void;
}) {
@@ -156,9 +166,10 @@ function SessionItem({
return (
<button
onClick={onSelect}
className={`group flex w-full items-start gap-3 px-4 py-2.5 text-left transition-colors hover:bg-accent/50 ${
isActive ? "bg-accent/30" : ""
}`}
className={cn(
"group flex w-full items-start gap-3 px-4 py-2.5 text-left transition-colors hover:bg-accent/50",
isActive && "bg-accent/30",
)}
>
<Avatar className="size-6 shrink-0 mt-0.5">
{agent?.avatar_url && <AvatarImage src={agent.avatar_url} />}
@@ -185,15 +196,25 @@ function SessionItem({
</div>
</div>
{onArchive && (
<Button
variant="ghost"
size="icon-sm"
className="invisible group-hover:visible text-muted-foreground hover:text-destructive shrink-0 mt-0.5"
onClick={onArchive}
title="Archive"
>
<Trash2 />
</Button>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className={cn(
"shrink-0 mt-0.5 text-muted-foreground",
!isArchiving && "invisible group-hover:visible",
)}
onClick={onArchive}
disabled={isArchiving}
/>
}
>
{isArchiving ? <Loader2 className="animate-spin" /> : <Archive />}
</TooltipTrigger>
<TooltipContent side="bottom">Archive</TooltipContent>
</Tooltip>
)}
</button>
);

View File

@@ -232,7 +232,7 @@ export function ChatWindow() {
const isVisible = isOpen && boundsReady;
const containerClass = "absolute bottom-4 right-4 z-50 flex flex-col rounded-xl ring-1 ring-foreground/10 bg-sidebar shadow-2xl overflow-hidden";
const containerClass = "absolute bottom-2 right-2 z-50 flex flex-col rounded-xl ring-1 ring-foreground/10 bg-sidebar shadow-2xl overflow-hidden";
const containerStyle: React.CSSProperties = {
width: `${renderWidth}px`,
height: `${renderHeight}px`,