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:
Naiyuan Qing
2026-05-07 17:34:07 +08:00
committed by GitHub
parent a6e8ae964e
commit 47aa32a04d
8 changed files with 297 additions and 455 deletions

View File

@@ -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) });
},
});
}

View File

@@ -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,
});

View File

@@ -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 });

View File

@@ -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) });

View File

@@ -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>
);
}

View File

@@ -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 (~50200ms 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.

View File

@@ -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",

View File

@@ -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": "和你的智能体对话",