mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
refactor(chat): unify session list into single dropdown with grouped active/archived (#2220)
The chat window used to fire two parallel session queries (active subset + full list) and surfaced them through two UI entry points (the title dropdown + a History icon panel). The two caches drifted during the WS-invalidate window — visible as "completed → reload → ghost row" flickers — and the History toggle was a redundant entry into the same underlying data. Collapse to one cache (full list, ?status=all) and one entry point (dropdown). The dropdown groups locally into Active / Archived; the archived group is collapsed by default with a count, and per-row delete moves into the dropdown via hover-revealed trash + confirm dialog. Backend stays untouched: old desktop builds still hit GET /chat-sessions without ?status and continue receiving the active subset, so installed clients are unaffected. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,14 +24,13 @@ export function useCreateChatSession() {
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the session's unread state server-side. Optimistically flips
|
||||
* has_unread to false in the cached lists so the FAB badge drops
|
||||
* has_unread to false in the cached list so the FAB badge drops
|
||||
* immediately. The server broadcasts chat:session_read so other devices
|
||||
* also sync.
|
||||
*/
|
||||
@@ -46,35 +45,30 @@ export function useMarkChatSessionRead() {
|
||||
},
|
||||
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));
|
||||
|
||||
const clear = (old?: ChatSession[]) =>
|
||||
old?.map((s) => (s.id === sessionId ? { ...s, has_unread: false } : s));
|
||||
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), clear);
|
||||
qc.setQueryData<ChatSession[]>(chatKeys.allSessions(wsId), clear);
|
||||
|
||||
return { prevSessions, prevAll };
|
||||
return { prevSessions };
|
||||
},
|
||||
onError: (err, sessionId, ctx) => {
|
||||
logger.error("markChatSessionRead.error.rollback", { sessionId, err });
|
||||
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) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard-deletes a chat session. Optimistically removes the row from both
|
||||
* the active and all-sessions lists so the history panel updates instantly;
|
||||
* rolls back on error. The matching `chat:session_deleted` WS event keeps
|
||||
* other tabs/devices in sync — see use-realtime-sync.ts.
|
||||
* Hard-deletes a chat session. Optimistically removes the row from the
|
||||
* sessions list so the dropdown updates instantly; rolls back on error.
|
||||
* The matching `chat:session_deleted` WS event keeps other tabs/devices
|
||||
* in sync — see use-realtime-sync.ts.
|
||||
*/
|
||||
export function useDeleteChatSession() {
|
||||
const qc = useQueryClient();
|
||||
@@ -87,27 +81,22 @@ export function useDeleteChatSession() {
|
||||
},
|
||||
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));
|
||||
|
||||
const drop = (old?: ChatSession[]) => old?.filter((s) => s.id !== sessionId);
|
||||
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), drop);
|
||||
qc.setQueryData<ChatSession[]>(chatKeys.allSessions(wsId), drop);
|
||||
|
||||
logger.debug("deleteChatSession.optimistic", { sessionId });
|
||||
return { prevSessions, prevAll };
|
||||
return { prevSessions };
|
||||
},
|
||||
onError: (err, sessionId, ctx) => {
|
||||
logger.error("deleteChatSession.error.rollback", { sessionId, err });
|
||||
if (ctx?.prevSessions) qc.setQueryData(chatKeys.sessions(wsId), ctx.prevSessions);
|
||||
if (ctx?.prevAll) qc.setQueryData(chatKeys.allSessions(wsId), ctx.prevAll);
|
||||
},
|
||||
onSettled: (_data, _err, sessionId) => {
|
||||
logger.debug("deleteChatSession.settled", { sessionId });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ import { api } from "../api";
|
||||
|
||||
export const chatKeys = {
|
||||
all: (wsId: string) => ["chat", wsId] as const,
|
||||
/** Full sessions list (active + archived); the dropdown splits locally. */
|
||||
sessions: (wsId: string) => [...chatKeys.all(wsId), "sessions"] as const,
|
||||
allSessions: (wsId: string) => [...chatKeys.all(wsId), "sessions", "all"] as const,
|
||||
session: (wsId: string, id: string) => [...chatKeys.all(wsId), "session", id] as const,
|
||||
messages: (sessionId: string) => ["chat", "messages", sessionId] as const,
|
||||
pendingTask: (sessionId: string) => ["chat", "pending-task", sessionId] as const,
|
||||
@@ -24,14 +24,6 @@ export const chatKeys = {
|
||||
export function chatSessionsOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: chatKeys.sessions(wsId),
|
||||
queryFn: () => api.listChatSessions(),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
export function allChatSessionsOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: chatKeys.allSessions(wsId),
|
||||
queryFn: () => api.listChatSessions({ status: "all" }),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
@@ -87,7 +87,6 @@ export interface ChatState {
|
||||
isOpen: boolean;
|
||||
activeSessionId: string | null;
|
||||
selectedAgentId: string | null;
|
||||
showHistory: boolean;
|
||||
/** Drafts per session: sessionId (or DRAFT_NEW_SESSION) → markdown text. */
|
||||
inputDrafts: Record<string, string>;
|
||||
/**
|
||||
@@ -104,7 +103,6 @@ export interface ChatState {
|
||||
toggle: () => void;
|
||||
setActiveSession: (id: string | null) => void;
|
||||
setSelectedAgentId: (id: string) => void;
|
||||
setShowHistory: (show: boolean) => void;
|
||||
/** sessionId accepts a real session UUID or DRAFT_NEW_SESSION. */
|
||||
setInputDraft: (sessionId: string, draft: string) => void;
|
||||
clearInputDraft: (sessionId: string) => void;
|
||||
@@ -136,7 +134,6 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
isOpen: initialIsOpen,
|
||||
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
|
||||
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
|
||||
showHistory: false,
|
||||
inputDrafts: readDrafts(storage, wsKey(DRAFTS_KEY)),
|
||||
focusMode: storage.getItem(FOCUS_MODE_KEY) === "true",
|
||||
chatWidth: Number(storage.getItem(CHAT_WIDTH_KEY)) || CHAT_DEFAULT_W,
|
||||
@@ -167,10 +164,6 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
storage.setItem(wsKey(AGENT_STORAGE_KEY), id);
|
||||
set({ selectedAgentId: id });
|
||||
},
|
||||
setShowHistory: (show) => {
|
||||
logger.debug("setShowHistory", { to: show });
|
||||
set({ showHistory: show });
|
||||
},
|
||||
setInputDraft: (sessionId, draft) => {
|
||||
// Debug level — onUpdate fires on every keystroke.
|
||||
logger.debug("setInputDraft", { sessionId, length: draft.length });
|
||||
|
||||
@@ -516,10 +516,7 @@ export function useRealtimeSync(
|
||||
};
|
||||
const invalidateSessionLists = () => {
|
||||
const id = getCurrentWsId();
|
||||
if (id) {
|
||||
qc.invalidateQueries({ queryKey: chatKeys.sessions(id) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.allSessions(id) });
|
||||
}
|
||||
if (id) qc.invalidateQueries({ queryKey: chatKeys.sessions(id) });
|
||||
};
|
||||
|
||||
const unsubChatMessage = ws.on("chat:message", (p) => {
|
||||
@@ -656,7 +653,6 @@ export function useRealtimeSync(
|
||||
const drop = (old?: { id: string }[]) =>
|
||||
old?.filter((s) => s.id !== payload.chat_session_id);
|
||||
qc.setQueryData(chatKeys.sessions(id), drop);
|
||||
qc.setQueryData(chatKeys.allSessions(id), drop);
|
||||
}
|
||||
qc.removeQueries({ queryKey: chatKeys.messages(payload.chat_session_id) });
|
||||
qc.removeQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) });
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeft, MessageSquare, Bot, Trash2 } from "lucide-react";
|
||||
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 {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@multica/ui/components/ui/alert-dialog";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { agentListOptions } from "@multica/core/workspace/queries";
|
||||
import { allChatSessionsOptions } from "@multica/core/chat/queries";
|
||||
import { useChatStore } from "@multica/core/chat";
|
||||
import { useDeleteChatSession } from "@multica/core/chat/mutations";
|
||||
import { createLogger } from "@multica/core/logger";
|
||||
import type { ChatSession, Agent } from "@multica/core/types";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
const logger = createLogger("chat.ui");
|
||||
|
||||
export function ChatSessionHistory() {
|
||||
const { t } = useT("chat");
|
||||
const wsId = useWorkspaceId();
|
||||
const setShowHistory = useChatStore((s) => s.setShowHistory);
|
||||
const setActiveSession = useChatStore((s) => s.setActiveSession);
|
||||
const activeSessionId = useChatStore((s) => s.activeSessionId);
|
||||
|
||||
const { data: sessions = [] } = useQuery(allChatSessionsOptions(wsId));
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
|
||||
const deleteSession = useDeleteChatSession();
|
||||
const [pendingDelete, setPendingDelete] = useState<ChatSession | null>(null);
|
||||
|
||||
const agentMap = new Map(agents.map((a) => [a.id, a]));
|
||||
|
||||
const handleSelectSession = (session: ChatSession) => {
|
||||
logger.info("selectSession", {
|
||||
from: activeSessionId,
|
||||
to: session.id,
|
||||
agentId: session.agent_id,
|
||||
status: session.status,
|
||||
});
|
||||
// Changing activeSessionId flips the query keys for messages +
|
||||
// pending-task; no manual clear needed.
|
||||
setActiveSession(session.id);
|
||||
setShowHistory(false);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
if (!pendingDelete) return;
|
||||
const sessionId = pendingDelete.id;
|
||||
logger.info("deleteSession.confirm", { sessionId });
|
||||
// Clear the active pointer locally so the chat window doesn't keep
|
||||
// pointing at a session we're about to remove. Other tabs are handled
|
||||
// by the chat:session_deleted WS handler.
|
||||
if (activeSessionId === sessionId) {
|
||||
setActiveSession(null);
|
||||
}
|
||||
deleteSession.mutate(sessionId, {
|
||||
onSettled: () => setPendingDelete(null),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 border-b px-4 py-2.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => setShowHistory(false)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ArrowLeft />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t(($) => $.session_history.back_tooltip)}</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="text-sm font-medium">{t(($) => $.session_history.header)}</span>
|
||||
</div>
|
||||
|
||||
{/* Session list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-12 text-muted-foreground">
|
||||
<MessageSquare className="size-6" />
|
||||
<span className="text-sm">{t(($) => $.session_history.empty)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{sessions.map((session) => (
|
||||
<SessionItem
|
||||
key={session.id}
|
||||
session={session}
|
||||
agent={agentMap.get(session.agent_id) ?? null}
|
||||
isActive={session.id === activeSessionId}
|
||||
onSelect={() => handleSelectSession(session)}
|
||||
onRequestDelete={() => setPendingDelete(session)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialog
|
||||
open={!!pendingDelete}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !deleteSession.isPending) setPendingDelete(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t(($) => $.session_history.delete_dialog.title)}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{pendingDelete?.title
|
||||
? t(($) => $.session_history.delete_dialog.description_with_title, { title: pendingDelete.title })
|
||||
: t(($) => $.session_history.delete_dialog.description_default)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteSession.isPending}>
|
||||
{t(($) => $.session_history.delete_dialog.cancel)}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={deleteSession.isPending}
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
>
|
||||
{deleteSession.isPending
|
||||
? t(($) => $.session_history.delete_dialog.confirming)
|
||||
: t(($) => $.session_history.delete_dialog.confirm)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useFormatTimeAgo(): (dateStr: string) => string {
|
||||
const { t } = useT("chat");
|
||||
return (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return t(($) => $.session_history.time.just_now);
|
||||
if (diffMins < 60) return t(($) => $.session_history.time.minutes, { count: diffMins });
|
||||
if (diffHours < 24) return t(($) => $.session_history.time.hours, { count: diffHours });
|
||||
if (diffDays < 7) return t(($) => $.session_history.time.days, { count: diffDays });
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
}
|
||||
|
||||
function SessionItem({
|
||||
session,
|
||||
agent,
|
||||
isActive,
|
||||
onSelect,
|
||||
onRequestDelete,
|
||||
}: {
|
||||
session: ChatSession;
|
||||
agent: Agent | null;
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
onRequestDelete: () => void;
|
||||
}) {
|
||||
const { t } = useT("chat");
|
||||
const formatTimeAgo = useFormatTimeAgo();
|
||||
const timeAgo = formatTimeAgo(session.updated_at);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex w-full items-start gap-3 px-4 py-2.5 text-left transition-colors hover:bg-accent/50",
|
||||
isActive && "bg-accent/30",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className="flex flex-1 items-start gap-3 min-w-0 text-left"
|
||||
>
|
||||
<Avatar className="size-6 shrink-0 mt-0.5">
|
||||
{agent?.avatar_url && <AvatarImage src={agent.avatar_url} />}
|
||||
<AvatarFallback className="bg-purple-100 text-purple-700">
|
||||
<Bot className="size-3" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{session.title || t(($) => $.session_history.untitled)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
{agent && (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{agent.name}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground/60">{timeAgo}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRequestDelete();
|
||||
}}
|
||||
aria-label={t(($) => $.session_history.row_delete_aria)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">{t(($) => $.session_history.row_delete_tooltip)}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { motion } from "motion/react";
|
||||
import { Minus, Maximize2, Minimize2, ChevronDown, Plus, Check, History } from "lucide-react";
|
||||
import { Minus, Maximize2, Minimize2, ChevronDown, ChevronRight, Plus, Check, Trash2 } from "lucide-react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||||
import {
|
||||
@@ -15,6 +15,16 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@multica/ui/components/ui/alert-dialog";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { agentListOptions, memberListOptions } from "@multica/core/workspace/queries";
|
||||
@@ -26,17 +36,19 @@ import { OfflineBanner } from "./offline-banner";
|
||||
import { NoAgentBanner } from "./no-agent-banner";
|
||||
import {
|
||||
chatSessionsOptions,
|
||||
allChatSessionsOptions,
|
||||
chatMessagesOptions,
|
||||
pendingChatTaskOptions,
|
||||
pendingChatTasksOptions,
|
||||
chatKeys,
|
||||
} from "@multica/core/chat/queries";
|
||||
import { useCreateChatSession, useMarkChatSessionRead } from "@multica/core/chat/mutations";
|
||||
import {
|
||||
useCreateChatSession,
|
||||
useDeleteChatSession,
|
||||
useMarkChatSessionRead,
|
||||
} from "@multica/core/chat/mutations";
|
||||
import { useChatStore } from "@multica/core/chat";
|
||||
import { ChatMessageList, ChatMessageSkeleton } from "./chat-message-list";
|
||||
import { ChatInput } from "./chat-input";
|
||||
import { ChatSessionHistory } from "./chat-session-history";
|
||||
import {
|
||||
ContextAnchorButton,
|
||||
ContextAnchorCard,
|
||||
@@ -61,13 +73,13 @@ export function ChatWindow() {
|
||||
const setOpen = useChatStore((s) => s.setOpen);
|
||||
const setActiveSession = useChatStore((s) => s.setActiveSession);
|
||||
const setSelectedAgentId = useChatStore((s) => s.setSelectedAgentId);
|
||||
const showHistory = useChatStore((s) => s.showHistory);
|
||||
const setShowHistory = useChatStore((s) => s.setShowHistory);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
// Single sessions cache. The dropdown groups locally into "active" /
|
||||
// "archived" — eliminating the separate active/all queries that used
|
||||
// to drift during the WS-invalidate window.
|
||||
const { data: sessions = [] } = useQuery(chatSessionsOptions(wsId));
|
||||
const { data: allSessions = [] } = useQuery(allChatSessionsOptions(wsId));
|
||||
const { data: rawMessages, isLoading: messagesLoading } = useQuery(
|
||||
chatMessagesOptions(activeSessionId ?? ""),
|
||||
);
|
||||
@@ -90,10 +102,10 @@ export function ChatWindow() {
|
||||
|
||||
// Legacy archived sessions (the old soft-archive feature was removed but
|
||||
// pre-existing rows with status='archived' may still exist) render as
|
||||
// read-only: history list keeps showing them, but ChatInput is disabled
|
||||
// and the server still rejects POST /messages for them.
|
||||
// read-only: dropdown keeps showing them under "archived", but ChatInput
|
||||
// is disabled and the server still rejects POST /messages for them.
|
||||
const currentSession = activeSessionId
|
||||
? allSessions.find((s) => s.id === activeSessionId)
|
||||
? sessions.find((s) => s.id === activeSessionId)
|
||||
: null;
|
||||
const isSessionArchived = currentSession?.status === "archived";
|
||||
|
||||
@@ -411,24 +423,6 @@ export function ChatWindow() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground data-[active=true]:bg-accent"
|
||||
data-active={showHistory ? "true" : undefined}
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<History />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{showHistory ? t(($) => $.window.history_back_tooltip) : t(($) => $.window.history_show_tooltip)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
@@ -464,67 +458,58 @@ export function ChatWindow() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* History panel takes over the body when toggled — surfaces the
|
||||
* per-row delete button. Hidden by default; the input + banners
|
||||
* are skipped here because the panel has its own affordances. */}
|
||||
{showHistory ? (
|
||||
<ChatSessionHistory />
|
||||
{/* Messages / skeleton / empty state */}
|
||||
{showSkeleton ? (
|
||||
<ChatMessageSkeleton />
|
||||
) : hasMessages ? (
|
||||
<ChatMessageList
|
||||
messages={messages}
|
||||
pendingTask={pendingTask}
|
||||
availability={availability}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Messages / skeleton / empty state */}
|
||||
{showSkeleton ? (
|
||||
<ChatMessageSkeleton />
|
||||
) : hasMessages ? (
|
||||
<ChatMessageList
|
||||
messages={messages}
|
||||
pendingTask={pendingTask}
|
||||
availability={availability}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
hasSessions={sessions.length > 0}
|
||||
agentName={activeAgent?.name}
|
||||
onPickPrompt={(text) => handleSend(text)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Status banner above the input — single mutually-exclusive slot.
|
||||
* Priority: no-agent > offline / unstable. Agent presence is the
|
||||
* hard prerequisite (you can't send anything without one), so it
|
||||
* always wins over a presence hint. ContextAnchorCard stays in
|
||||
* topSlot because that's per-message context, not session state.
|
||||
*
|
||||
* We key off `noAgent` (the resolved-empty state) rather than
|
||||
* `!activeAgent`, so the loading window between mount and the
|
||||
* first agent-list response stays banner-free. */}
|
||||
{noAgent ? (
|
||||
<NoAgentBanner />
|
||||
) : (
|
||||
<OfflineBanner agentName={activeAgent?.name} availability={availability} />
|
||||
)}
|
||||
|
||||
{/* Input — disabled for legacy archived sessions; locked out entirely
|
||||
* when there's no agent (the EmptyState above carries the CTA). */}
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
onStop={handleStop}
|
||||
isRunning={!!pendingTaskId}
|
||||
disabled={isSessionArchived}
|
||||
noAgent={noAgent}
|
||||
agentName={activeAgent?.name}
|
||||
topSlot={<ContextAnchorCard />}
|
||||
leftAdornment={
|
||||
<AgentDropdown
|
||||
agents={availableAgents}
|
||||
activeAgent={activeAgent}
|
||||
userId={user?.id}
|
||||
onSelect={handleSelectAgent}
|
||||
/>
|
||||
}
|
||||
rightAdornment={<ContextAnchorButton />}
|
||||
/>
|
||||
</>
|
||||
<EmptyState
|
||||
hasSessions={sessions.length > 0}
|
||||
agentName={activeAgent?.name}
|
||||
onPickPrompt={(text) => handleSend(text)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Status banner above the input — single mutually-exclusive slot.
|
||||
* Priority: no-agent > offline / unstable. Agent presence is the
|
||||
* hard prerequisite (you can't send anything without one), so it
|
||||
* always wins over a presence hint. ContextAnchorCard stays in
|
||||
* topSlot because that's per-message context, not session state.
|
||||
*
|
||||
* We key off `noAgent` (the resolved-empty state) rather than
|
||||
* `!activeAgent`, so the loading window between mount and the
|
||||
* first agent-list response stays banner-free. */}
|
||||
{noAgent ? (
|
||||
<NoAgentBanner />
|
||||
) : (
|
||||
<OfflineBanner agentName={activeAgent?.name} availability={availability} />
|
||||
)}
|
||||
|
||||
{/* Input — disabled for legacy archived sessions; locked out entirely
|
||||
* when there's no agent (the EmptyState above carries the CTA). */}
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
onStop={handleStop}
|
||||
isRunning={!!pendingTaskId}
|
||||
disabled={isSessionArchived}
|
||||
noAgent={noAgent}
|
||||
agentName={activeAgent?.name}
|
||||
topSlot={<ContextAnchorCard />}
|
||||
leftAdornment={
|
||||
<AgentDropdown
|
||||
agents={availableAgents}
|
||||
activeAgent={activeAgent}
|
||||
userId={user?.id}
|
||||
onSelect={handleSelectAgent}
|
||||
/>
|
||||
}
|
||||
rightAdornment={<ContextAnchorButton />}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -636,8 +621,9 @@ function AgentMenuItem({
|
||||
}
|
||||
|
||||
/**
|
||||
* Session dropdown: lists ALL sessions across agents. Each row carries the
|
||||
* owning agent's avatar so the user can tell them apart. Selecting a
|
||||
* Session dropdown: groups all sessions into "active" and "archived". The
|
||||
* archived branch is collapsed by default and only mounts on demand to
|
||||
* keep the menu compact when the user has many old chats. Selecting a
|
||||
* session from a different agent implicitly switches the agent too
|
||||
* (sessions are bound 1:1 to an agent). "New chat" lives in the header's
|
||||
* ⊕ button, not inside this dropdown.
|
||||
@@ -660,6 +646,22 @@ function SessionDropdown({
|
||||
const title = activeSession?.title?.trim() || t(($) => $.window.untitled);
|
||||
const triggerAgent = activeSession ? agentById.get(activeSession.agent_id) ?? null : null;
|
||||
|
||||
const { active, archived } = useMemo(() => {
|
||||
const active: ChatSession[] = [];
|
||||
const archived: ChatSession[] = [];
|
||||
for (const s of sessions) {
|
||||
if (s.status === "archived") archived.push(s);
|
||||
else active.push(s);
|
||||
}
|
||||
return { active, archived };
|
||||
}, [sessions]);
|
||||
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [pendingDelete, setPendingDelete] = useState<ChatSession | null>(null);
|
||||
const deleteSession = useDeleteChatSession();
|
||||
const setActiveSession = useChatStore((s) => s.setActiveSession);
|
||||
const formatTimeAgo = useFormatTimeAgo();
|
||||
|
||||
// Aggregate "which sessions have an in-flight task right now". Reuses
|
||||
// the same workspace-scoped query the FAB consumes, so toggling the chat
|
||||
// window doesn't fire a second request — TanStack dedupes by key.
|
||||
@@ -682,93 +684,214 @@ function SessionDropdown({
|
||||
(s) => s.id !== activeSessionId && s.has_unread,
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center gap-1.5 min-w-0 rounded-md px-1.5 py-1 transition-colors hover:bg-accent aria-expanded:bg-accent">
|
||||
{triggerAgent && (
|
||||
const handleConfirmDelete = () => {
|
||||
if (!pendingDelete) return;
|
||||
const sessionId = pendingDelete.id;
|
||||
// Eager local clear when the user is deleting the session they're
|
||||
// currently looking at — otherwise messages / pendingTask queries
|
||||
// keep rendering the now-deleted session until chat:session_deleted
|
||||
// arrives over WS (~50–200ms gap).
|
||||
if (activeSessionId === sessionId) setActiveSession(null);
|
||||
deleteSession.mutate(sessionId, {
|
||||
onSettled: () => setPendingDelete(null),
|
||||
});
|
||||
};
|
||||
|
||||
const renderRow = (session: ChatSession) => {
|
||||
const isCurrent = session.id === activeSessionId;
|
||||
const agent = agentById.get(session.agent_id) ?? null;
|
||||
const isRunning = inFlightSessionIds.has(session.id);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={session.id}
|
||||
onClick={() => onSelectSession(session)}
|
||||
className="group flex min-w-0 items-center gap-2"
|
||||
>
|
||||
{agent ? (
|
||||
<ActorAvatar
|
||||
actorType="agent"
|
||||
actorId={triggerAgent.id}
|
||||
actorId={agent.id}
|
||||
size={24}
|
||||
enableHoverCard
|
||||
showStatusDot
|
||||
/>
|
||||
) : (
|
||||
<span className="size-6 shrink-0" />
|
||||
)}
|
||||
<span className="truncate text-sm font-medium">{title}</span>
|
||||
{otherSessionRunning ? (
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm">
|
||||
{session.title?.trim() || t(($) => $.window.untitled)}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground/70">
|
||||
{formatTimeAgo(session.updated_at)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Right-edge status pip: in-flight wins over unread because
|
||||
* "still working" is more actionable than "has reply" — and
|
||||
* the two rarely coexist in practice (the unread flag fires
|
||||
* on chat_message write, by which point the task has just
|
||||
* finished). Same pip shape as unread for visual rhythm,
|
||||
* amber + pulse to read as activity. */}
|
||||
{isRunning ? (
|
||||
<span
|
||||
aria-label={t(($) => $.window.another_running)}
|
||||
title={t(($) => $.window.another_running)}
|
||||
aria-label={t(($) => $.window.running)}
|
||||
title={t(($) => $.window.running)}
|
||||
className="size-1.5 shrink-0 rounded-full bg-amber-500 animate-pulse"
|
||||
/>
|
||||
) : otherSessionUnread ? (
|
||||
) : session.has_unread ? (
|
||||
<span
|
||||
aria-label={t(($) => $.window.another_unread)}
|
||||
title={t(($) => $.window.another_unread)}
|
||||
aria-label={t(($) => $.window.unread)}
|
||||
title={t(($) => $.window.unread)}
|
||||
className="size-1.5 shrink-0 rounded-full bg-brand"
|
||||
/>
|
||||
) : null}
|
||||
<ChevronDown className="size-3 text-muted-foreground shrink-0" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-80 w-auto min-w-56 max-w-80">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{t(($) => $.window.no_previous)}
|
||||
</div>
|
||||
) : (
|
||||
sessions.map((session) => {
|
||||
const isCurrent = session.id === activeSessionId;
|
||||
const agent = agentById.get(session.agent_id) ?? null;
|
||||
const isRunning = inFlightSessionIds.has(session.id);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={session.id}
|
||||
onClick={() => onSelectSession(session)}
|
||||
className="flex min-w-0 items-center gap-2"
|
||||
>
|
||||
{agent ? (
|
||||
<ActorAvatar
|
||||
actorType="agent"
|
||||
actorId={agent.id}
|
||||
size={24}
|
||||
enableHoverCard
|
||||
showStatusDot
|
||||
/>
|
||||
) : (
|
||||
<span className="size-6 shrink-0" />
|
||||
)}
|
||||
<span className="truncate flex-1 text-sm">
|
||||
{session.title?.trim() || t(($) => $.window.untitled)}
|
||||
</span>
|
||||
{/* Right-edge status pip: in-flight wins over unread because
|
||||
* "still working" is more actionable than "has reply" — and
|
||||
* the two rarely coexist in practice (the unread flag fires
|
||||
* on chat_message write, by which point the task has just
|
||||
* finished). Same pip shape as unread for visual rhythm,
|
||||
* amber + pulse to read as activity. */}
|
||||
{isRunning ? (
|
||||
<span
|
||||
aria-label={t(($) => $.window.running)}
|
||||
title={t(($) => $.window.running)}
|
||||
className="size-1.5 shrink-0 rounded-full bg-amber-500 animate-pulse"
|
||||
/>
|
||||
) : session.has_unread ? (
|
||||
<span
|
||||
aria-label={t(($) => $.window.unread)}
|
||||
title={t(($) => $.window.unread)}
|
||||
className="size-1.5 shrink-0 rounded-full bg-brand"
|
||||
/>
|
||||
) : null}
|
||||
{isCurrent && <Check className="size-3.5 text-muted-foreground shrink-0" />}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{isCurrent && <Check className="size-3.5 text-muted-foreground shrink-0" />}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setPendingDelete(session);
|
||||
}}
|
||||
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
|
||||
aria-label={t(($) => $.session_history.row_delete_aria)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center gap-1.5 min-w-0 rounded-md px-1.5 py-1 transition-colors hover:bg-accent aria-expanded:bg-accent">
|
||||
{triggerAgent && (
|
||||
<ActorAvatar
|
||||
actorType="agent"
|
||||
actorId={triggerAgent.id}
|
||||
size={24}
|
||||
enableHoverCard
|
||||
showStatusDot
|
||||
/>
|
||||
)}
|
||||
<span className="truncate text-sm font-medium">{title}</span>
|
||||
{otherSessionRunning ? (
|
||||
<span
|
||||
aria-label={t(($) => $.window.another_running)}
|
||||
title={t(($) => $.window.another_running)}
|
||||
className="size-1.5 shrink-0 rounded-full bg-amber-500 animate-pulse"
|
||||
/>
|
||||
) : otherSessionUnread ? (
|
||||
<span
|
||||
aria-label={t(($) => $.window.another_unread)}
|
||||
title={t(($) => $.window.another_unread)}
|
||||
className="size-1.5 shrink-0 rounded-full bg-brand"
|
||||
/>
|
||||
) : null}
|
||||
<ChevronDown className="size-3 text-muted-foreground shrink-0" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-96 w-auto min-w-64 max-w-80 overflow-y-auto">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{t(($) => $.window.no_previous)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{active.length > 0 && (
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>{t(($) => $.window.active_group)}</DropdownMenuLabel>
|
||||
{active.map(renderRow)}
|
||||
</DropdownMenuGroup>
|
||||
)}
|
||||
{archived.length > 0 && (
|
||||
<>
|
||||
{active.length > 0 && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowArchived((v) => !v);
|
||||
}}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{showArchived ? (
|
||||
<ChevronDown className="size-3" />
|
||||
) : (
|
||||
<ChevronRight className="size-3" />
|
||||
)}
|
||||
<span>
|
||||
{t(($) => $.window.archived_group, { count: archived.length })}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
{showArchived && (
|
||||
<DropdownMenuGroup>
|
||||
{archived.map(renderRow)}
|
||||
</DropdownMenuGroup>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<AlertDialog
|
||||
open={!!pendingDelete}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !deleteSession.isPending) setPendingDelete(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t(($) => $.session_history.delete_dialog.title)}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{pendingDelete?.title
|
||||
? t(($) => $.session_history.delete_dialog.description_with_title, {
|
||||
title: pendingDelete.title,
|
||||
})
|
||||
: t(($) => $.session_history.delete_dialog.description_default)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteSession.isPending}>
|
||||
{t(($) => $.session_history.delete_dialog.cancel)}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={deleteSession.isPending}
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
>
|
||||
{deleteSession.isPending
|
||||
? t(($) => $.session_history.delete_dialog.confirming)
|
||||
: t(($) => $.session_history.delete_dialog.confirm)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function useFormatTimeAgo(): (dateStr: string) => string {
|
||||
const { t } = useT("chat");
|
||||
return (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return t(($) => $.session_history.time.just_now);
|
||||
if (diffMins < 60) return t(($) => $.session_history.time.minutes, { count: diffMins });
|
||||
if (diffHours < 24) return t(($) => $.session_history.time.hours, { count: diffHours });
|
||||
if (diffDays < 7) return t(($) => $.session_history.time.days, { count: diffDays });
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
}
|
||||
|
||||
// Three starter prompts shown on the empty state. Each is keyed into the
|
||||
// chat namespace so labels translate per locale; the icon stays raw since
|
||||
// emojis are locale-neutral.
|
||||
|
||||
@@ -27,9 +27,6 @@
|
||||
"copy_failed_toast": "Copy failed"
|
||||
},
|
||||
"session_history": {
|
||||
"back_tooltip": "Back",
|
||||
"header": "Chat History",
|
||||
"empty": "No chat sessions yet",
|
||||
"untitled": "Untitled",
|
||||
"time": {
|
||||
"just_now": "just now",
|
||||
@@ -38,7 +35,6 @@
|
||||
"days": "{{count}}d ago"
|
||||
},
|
||||
"row_delete_aria": "Delete chat session",
|
||||
"row_delete_tooltip": "Delete",
|
||||
"delete_dialog": {
|
||||
"title": "Delete chat session",
|
||||
"description_with_title": "\"{{title}}\" and its messages will be permanently removed. This action cannot be undone.",
|
||||
@@ -62,8 +58,9 @@
|
||||
"my_agents": "My agents",
|
||||
"others": "Others",
|
||||
"no_agents": "No agents",
|
||||
"history_show_tooltip": "Chat history",
|
||||
"history_back_tooltip": "Back to chat"
|
||||
"active_group": "Active",
|
||||
"archived_group_one": "{{count}} archived chat",
|
||||
"archived_group_other": "{{count}} archived chats"
|
||||
},
|
||||
"empty_state": {
|
||||
"first_time_title": "Chat with your agents",
|
||||
|
||||
@@ -24,9 +24,6 @@
|
||||
"copy_failed_toast": "复制失败"
|
||||
},
|
||||
"session_history": {
|
||||
"back_tooltip": "返回",
|
||||
"header": "对话历史",
|
||||
"empty": "还没有对话记录",
|
||||
"untitled": "无标题",
|
||||
"time": {
|
||||
"just_now": "刚刚",
|
||||
@@ -35,7 +32,6 @@
|
||||
"days": "{{count}} 天前"
|
||||
},
|
||||
"row_delete_aria": "删除对话",
|
||||
"row_delete_tooltip": "删除",
|
||||
"delete_dialog": {
|
||||
"title": "删除对话",
|
||||
"description_with_title": "\"{{title}}\" 及其消息会被永久删除,无法撤销。",
|
||||
@@ -59,8 +55,8 @@
|
||||
"my_agents": "我的智能体",
|
||||
"others": "其他",
|
||||
"no_agents": "暂无智能体",
|
||||
"history_show_tooltip": "对话历史",
|
||||
"history_back_tooltip": "返回对话"
|
||||
"active_group": "进行中",
|
||||
"archived_group_other": "{{count}} 条已归档"
|
||||
},
|
||||
"empty_state": {
|
||||
"first_time_title": "和你的智能体对话",
|
||||
|
||||
Reference in New Issue
Block a user