From 47aa32a04db56042fd3b84fb77fbbf7ea72cee23 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Thu, 7 May 2026 17:34:07 +0800 Subject: [PATCH] refactor(chat): unify session list into single dropdown with grouped active/archived (#2220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/core/chat/mutations.ts | 25 +- packages/core/chat/queries.ts | 10 +- packages/core/chat/store.ts | 7 - packages/core/realtime/use-realtime-sync.ts | 6 +- .../chat/components/chat-session-history.tsx | 244 ---------- .../views/chat/components/chat-window.tsx | 443 +++++++++++------- packages/views/locales/en/chat.json | 9 +- packages/views/locales/zh-Hans/chat.json | 8 +- 8 files changed, 297 insertions(+), 455 deletions(-) delete mode 100644 packages/views/chat/components/chat-session-history.tsx diff --git a/packages/core/chat/mutations.ts b/packages/core/chat/mutations.ts index 1cb7994f7..58a366e07 100644 --- a/packages/core/chat/mutations.ts +++ b/packages/core/chat/mutations.ts @@ -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(chatKeys.sessions(wsId)); - const prevAll = qc.getQueryData(chatKeys.allSessions(wsId)); const clear = (old?: ChatSession[]) => old?.map((s) => (s.id === sessionId ? { ...s, has_unread: false } : s)); qc.setQueryData(chatKeys.sessions(wsId), clear); - qc.setQueryData(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(chatKeys.sessions(wsId)); - const prevAll = qc.getQueryData(chatKeys.allSessions(wsId)); const drop = (old?: ChatSession[]) => old?.filter((s) => s.id !== sessionId); qc.setQueryData(chatKeys.sessions(wsId), drop); - qc.setQueryData(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) }); }, }); } diff --git a/packages/core/chat/queries.ts b/packages/core/chat/queries.ts index 5dc5d1f19..f56c16307 100644 --- a/packages/core/chat/queries.ts +++ b/packages/core/chat/queries.ts @@ -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, }); diff --git a/packages/core/chat/store.ts b/packages/core/chat/store.ts index 8c7daf06a..4c09a37be 100644 --- a/packages/core/chat/store.ts +++ b/packages/core/chat/store.ts @@ -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; /** @@ -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 }); diff --git a/packages/core/realtime/use-realtime-sync.ts b/packages/core/realtime/use-realtime-sync.ts index b380642c4..6147cfb8e 100644 --- a/packages/core/realtime/use-realtime-sync.ts +++ b/packages/core/realtime/use-realtime-sync.ts @@ -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) }); diff --git a/packages/views/chat/components/chat-session-history.tsx b/packages/views/chat/components/chat-session-history.tsx deleted file mode 100644 index 67a4febea..000000000 --- a/packages/views/chat/components/chat-session-history.tsx +++ /dev/null @@ -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(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 ( -
- {/* Header */} -
- - setShowHistory(false)} - /> - } - > - - - {t(($) => $.session_history.back_tooltip)} - - {t(($) => $.session_history.header)} -
- - {/* Session list */} -
- {sessions.length === 0 ? ( -
- - {t(($) => $.session_history.empty)} -
- ) : ( -
- {sessions.map((session) => ( - handleSelectSession(session)} - onRequestDelete={() => setPendingDelete(session)} - /> - ))} -
- )} -
- - { - if (!open && !deleteSession.isPending) setPendingDelete(null); - }} - > - - - {t(($) => $.session_history.delete_dialog.title)} - - {pendingDelete?.title - ? t(($) => $.session_history.delete_dialog.description_with_title, { title: pendingDelete.title }) - : t(($) => $.session_history.delete_dialog.description_default)} - - - - - {t(($) => $.session_history.delete_dialog.cancel)} - - - {deleteSession.isPending - ? t(($) => $.session_history.delete_dialog.confirming) - : t(($) => $.session_history.delete_dialog.confirm)} - - - - -
- ); -} - -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 ( -
- - - { - e.stopPropagation(); - onRequestDelete(); - }} - aria-label={t(($) => $.session_history.row_delete_aria)} - /> - } - > - - - {t(($) => $.session_history.row_delete_tooltip)} - -
- ); -} diff --git a/packages/views/chat/components/chat-window.tsx b/packages/views/chat/components/chat-window.tsx index b793b7829..3769bfcb1 100644 --- a/packages/views/chat/components/chat-window.tsx +++ b/packages/views/chat/components/chat-window.tsx @@ -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() { />
- - setShowHistory(!showHistory)} - /> - } - > - - - - {showHistory ? t(($) => $.window.history_back_tooltip) : t(($) => $.window.history_show_tooltip)} - -
- {/* 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 ? ( - + {/* Messages / skeleton / empty state */} + {showSkeleton ? ( + + ) : hasMessages ? ( + ) : ( - <> - {/* Messages / skeleton / empty state */} - {showSkeleton ? ( - - ) : hasMessages ? ( - - ) : ( - 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 ? ( - - ) : ( - - )} - - {/* Input — disabled for legacy archived sessions; locked out entirely - * when there's no agent (the EmptyState above carries the CTA). */} - } - leftAdornment={ - - } - rightAdornment={} - /> - + 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 ? ( + + ) : ( + + )} + + {/* Input — disabled for legacy archived sessions; locked out entirely + * when there's no agent (the EmptyState above carries the CTA). */} + } + leftAdornment={ + + } + rightAdornment={} + /> ); } @@ -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(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 ( - - - {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 ( + onSelectSession(session)} + className="group flex min-w-0 items-center gap-2" + > + {agent ? ( + ) : ( + )} - {title} - {otherSessionRunning ? ( +
+
+ {session.title?.trim() || t(($) => $.window.untitled)} +
+
+ {formatTimeAgo(session.updated_at)} +
+
+ {/* 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 ? ( $.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 ? ( $.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} - -
- - {sessions.length === 0 ? ( -
- {t(($) => $.window.no_previous)} -
- ) : ( - sessions.map((session) => { - const isCurrent = session.id === activeSessionId; - const agent = agentById.get(session.agent_id) ?? null; - const isRunning = inFlightSessionIds.has(session.id); - return ( - onSelectSession(session)} - className="flex min-w-0 items-center gap-2" - > - {agent ? ( - - ) : ( - - )} - - {session.title?.trim() || t(($) => $.window.untitled)} - - {/* 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 ? ( - $.window.running)} - title={t(($) => $.window.running)} - className="size-1.5 shrink-0 rounded-full bg-amber-500 animate-pulse" - /> - ) : session.has_unread ? ( - $.window.unread)} - title={t(($) => $.window.unread)} - className="size-1.5 shrink-0 rounded-full bg-brand" - /> - ) : null} - {isCurrent && } - - ); - }) - )} -
-
+ {isCurrent && } + + + ); + }; + + return ( + <> + + + {triggerAgent && ( + + )} + {title} + {otherSessionRunning ? ( + $.window.another_running)} + title={t(($) => $.window.another_running)} + className="size-1.5 shrink-0 rounded-full bg-amber-500 animate-pulse" + /> + ) : otherSessionUnread ? ( + $.window.another_unread)} + title={t(($) => $.window.another_unread)} + className="size-1.5 shrink-0 rounded-full bg-brand" + /> + ) : null} + + + + {sessions.length === 0 ? ( +
+ {t(($) => $.window.no_previous)} +
+ ) : ( + <> + {active.length > 0 && ( + + {t(($) => $.window.active_group)} + {active.map(renderRow)} + + )} + {archived.length > 0 && ( + <> + {active.length > 0 && } + { + e.preventDefault(); + setShowArchived((v) => !v); + }} + className="flex items-center gap-1.5 text-xs text-muted-foreground" + > + {showArchived ? ( + + ) : ( + + )} + + {t(($) => $.window.archived_group, { count: archived.length })} + + + {showArchived && ( + + {archived.map(renderRow)} + + )} + + )} + + )} +
+
+ + { + if (!open && !deleteSession.isPending) setPendingDelete(null); + }} + > + + + + {t(($) => $.session_history.delete_dialog.title)} + + + {pendingDelete?.title + ? t(($) => $.session_history.delete_dialog.description_with_title, { + title: pendingDelete.title, + }) + : t(($) => $.session_history.delete_dialog.description_default)} + + + + + {t(($) => $.session_history.delete_dialog.cancel)} + + + {deleteSession.isPending + ? t(($) => $.session_history.delete_dialog.confirming) + : t(($) => $.session_history.delete_dialog.confirm)} + + + + + ); } +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. diff --git a/packages/views/locales/en/chat.json b/packages/views/locales/en/chat.json index 50216c84b..4bc5cbdb0 100644 --- a/packages/views/locales/en/chat.json +++ b/packages/views/locales/en/chat.json @@ -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", diff --git a/packages/views/locales/zh-Hans/chat.json b/packages/views/locales/zh-Hans/chat.json index 65da561a2..ea6555163 100644 --- a/packages/views/locales/zh-Hans/chat.json +++ b/packages/views/locales/zh-Hans/chat.json @@ -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": "和你的智能体对话",