mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
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:
@@ -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) });
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user