Compare commits

...

1 Commits

Author SHA1 Message Date
Jiayuan Zhang
4107ab84d0 feat(chat): add session history panel to view archived conversations
Support viewing historical/archived chat sessions in the Master Agent chat
window. Previously, only active sessions were visible and archived ones were
permanently hidden.

Changes:
- Add ListAllChatSessionsByCreator SQL query (no status filter)
- Add ?status=all query param to GET /api/chat/sessions endpoint
- Add history button in chat header that opens a session list panel
- Sessions grouped by Active/Archived with archive action on active ones
- Clicking an archived session loads its messages in read-only mode
- Chat input disabled with "This session is archived" placeholder

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:37:03 +08:00
10 changed files with 367 additions and 60 deletions

View File

@@ -12,6 +12,7 @@ export function useCreateChatSession() {
api.createChatSession(data),
onSettled: () => {
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });
},
});
}
@@ -24,6 +25,7 @@ export function useArchiveChatSession() {
mutationFn: (sessionId: string) => api.archiveChatSession(sessionId),
onSettled: () => {
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });
},
});
}

View File

@@ -4,6 +4,7 @@ import { api } from "@/platform/api";
export const chatKeys = {
all: (wsId: string) => ["chat", wsId] as const,
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,
};
@@ -16,6 +17,14 @@ export function chatSessionsOptions(wsId: string) {
});
}
export function allChatSessionsOptions(wsId: string) {
return queryOptions({
queryKey: chatKeys.allSessions(wsId),
queryFn: () => api.listChatSessions({ status: "all" }),
staleTime: Infinity,
});
}
export function chatSessionOptions(wsId: string, id: string) {
return queryOptions({
queryKey: chatKeys.session(wsId, id),

View File

@@ -7,22 +7,23 @@ interface ChatInputProps {
onSend: (content: string) => void;
onStop?: () => void;
isRunning?: boolean;
disabled?: boolean;
}
export function ChatInput({ onSend, onStop, isRunning }: ChatInputProps) {
export function ChatInput({ onSend, onStop, isRunning, disabled }: ChatInputProps) {
const [value, setValue] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleSend = useCallback(() => {
const trimmed = value.trim();
if (!trimmed || isRunning) return;
if (!trimmed || isRunning || disabled) return;
onSend(trimmed);
setValue("");
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
}
textareaRef.current?.focus();
}, [value, isRunning, onSend]);
}, [value, isRunning, disabled, onSend]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@@ -52,8 +53,8 @@ export function ChatInput({ onSend, onStop, isRunning }: ChatInputProps) {
handleInput();
}}
onKeyDown={handleKeyDown}
placeholder="Ask Multica..."
disabled={isRunning}
placeholder={disabled ? "This session is archived" : "Ask Multica..."}
disabled={isRunning || disabled}
className="block w-full resize-none bg-transparent px-3 pt-3 pb-2 text-sm placeholder:text-muted-foreground focus:outline-none disabled:opacity-50"
rows={1}
/>
@@ -68,7 +69,7 @@ export function ChatInput({ onSend, onStop, isRunning }: ChatInputProps) {
) : (
<button
onClick={handleSend}
disabled={!value.trim()}
disabled={!value.trim() || disabled}
className="flex size-7 items-center justify-center rounded-full bg-foreground text-background transition-opacity hover:opacity-80 disabled:opacity-30"
>
<ArrowUp className="size-4" />

View File

@@ -0,0 +1,202 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, MessageSquare, Archive, Trash2 } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@multica/ui/components/ui/avatar";
import { Bot } from "lucide-react";
import { useWorkspaceId } from "@multica/core/hooks";
import { agentListOptions } from "@multica/core/workspace/queries";
import { allChatSessionsOptions } from "@/core/chat/queries";
import { useArchiveChatSession } from "@/core/chat/mutations";
import { useChatStore } from "../store";
import type { ChatSession, Agent } from "@multica/core/types";
export function ChatSessionHistory() {
const wsId = useWorkspaceId();
const setShowHistory = useChatStore((s) => s.setShowHistory);
const setActiveSession = useChatStore((s) => s.setActiveSession);
const clearTimeline = useChatStore((s) => s.clearTimeline);
const setPendingTask = useChatStore((s) => s.setPendingTask);
const activeSessionId = useChatStore((s) => s.activeSessionId);
const { data: sessions = [] } = useQuery(allChatSessionsOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const archiveSession = useArchiveChatSession();
const agentMap = new Map(agents.map((a) => [a.id, a]));
const handleSelectSession = (session: ChatSession) => {
setActiveSession(session.id);
clearTimeline();
setPendingTask(null);
setShowHistory(false);
};
const handleArchive = (e: React.MouseEvent, sessionId: string) => {
e.stopPropagation();
archiveSession.mutate(sessionId);
if (activeSessionId === sessionId) {
setActiveSession(null);
}
};
const activeSessions = sessions.filter((s) => s.status === "active");
const archivedSessions = sessions.filter((s) => s.status === "archived");
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">
<button
onClick={() => setShowHistory(false)}
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<ArrowLeft className="size-3.5" />
</button>
<span className="text-sm font-medium">Chat History</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">No chat sessions yet</span>
</div>
) : (
<>
{activeSessions.length > 0 && (
<SessionGroup
label="Active"
sessions={activeSessions}
agentMap={agentMap}
activeSessionId={activeSessionId}
onSelect={handleSelectSession}
onArchive={handleArchive}
/>
)}
{archivedSessions.length > 0 && (
<SessionGroup
label="Archived"
sessions={archivedSessions}
agentMap={agentMap}
activeSessionId={activeSessionId}
onSelect={handleSelectSession}
/>
)}
</>
)}
</div>
</div>
);
}
function SessionGroup({
label,
sessions,
agentMap,
activeSessionId,
onSelect,
onArchive,
}: {
label: string;
sessions: ChatSession[];
agentMap: Map<string, Agent>;
activeSessionId: string | null;
onSelect: (session: ChatSession) => void;
onArchive?: (e: React.MouseEvent, sessionId: string) => void;
}) {
return (
<div>
<div className="px-4 pt-3 pb-1">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{label}
</span>
</div>
{sessions.map((session) => (
<SessionItem
key={session.id}
session={session}
agent={agentMap.get(session.agent_id) ?? null}
isActive={session.id === activeSessionId}
onSelect={() => onSelect(session)}
onArchive={onArchive ? (e) => onArchive(e, session.id) : undefined}
/>
))}
</div>
);
}
function SessionItem({
session,
agent,
isActive,
onSelect,
onArchive,
}: {
session: ChatSession;
agent: Agent | null;
isActive: boolean;
onSelect: () => void;
onArchive?: (e: React.MouseEvent) => void;
}) {
const timeAgo = formatTimeAgo(session.updated_at);
return (
<button
onClick={onSelect}
className={`group flex w-full items-start gap-3 px-4 py-2.5 text-left transition-colors hover:bg-accent/50 ${
isActive ? "bg-accent/30" : ""
}`}
>
<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 text-[10px]">
<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 || "Untitled"}
</span>
{session.status === "archived" && (
<Archive className="size-3 shrink-0 text-muted-foreground" />
)}
</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>
{onArchive && (
<button
onClick={onArchive}
title="Archive"
className="invisible group-hover:visible flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-destructive shrink-0 mt-0.5"
>
<Trash2 className="size-3" />
</button>
)}
</button>
);
}
function formatTimeAgo(dateStr: string): 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 "just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}

View File

@@ -2,7 +2,7 @@
import { useCallback, useEffect, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Minus, Maximize2, Minimize2, Send, ChevronDown, Bot, Plus } from "lucide-react";
import { Minus, Maximize2, Minimize2, Send, ChevronDown, Bot, Plus, History } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@multica/ui/components/ui/avatar";
import {
DropdownMenu,
@@ -15,11 +15,12 @@ import { useAuthStore } from "@multica/core/auth";
import { agentListOptions, memberListOptions } from "@multica/core/workspace/queries";
import { canAssignAgent } from "@multica/views/issues/components";
import { api } from "@/platform/api";
import { chatSessionsOptions, chatMessagesOptions, chatKeys } from "@/core/chat/queries";
import { chatSessionsOptions, allChatSessionsOptions, chatMessagesOptions, chatKeys } from "@/core/chat/queries";
import { useCreateChatSession } from "@/core/chat/mutations";
import { useChatStore } from "../store";
import { ChatMessageList } from "./chat-message-list";
import { ChatInput } from "./chat-input";
import { ChatSessionHistory } from "./chat-session-history";
import { useWS } from "@multica/core/realtime";
import type { TaskMessagePayload, ChatDonePayload, Agent, ChatMessage } from "@multica/core/types";
@@ -33,22 +34,31 @@ export function ChatWindow() {
const selectedAgentId = useChatStore((s) => s.selectedAgentId);
const setOpen = useChatStore((s) => s.setOpen);
const toggleFullscreen = useChatStore((s) => s.toggleFullscreen);
const showHistory = useChatStore((s) => s.showHistory);
const setActiveSession = useChatStore((s) => s.setActiveSession);
const setPendingTask = useChatStore((s) => s.setPendingTask);
const addTimelineItem = useChatStore((s) => s.addTimelineItem);
const clearTimeline = useChatStore((s) => s.clearTimeline);
const setSelectedAgentId = useChatStore((s) => s.setSelectedAgentId);
const setShowHistory = useChatStore((s) => s.setShowHistory);
const user = useAuthStore((s) => s.user);
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: sessions = [] } = useQuery(chatSessionsOptions(wsId));
const { data: allSessions = [] } = useQuery(allChatSessionsOptions(wsId));
const { data: rawMessages } = useQuery(
chatMessagesOptions(activeSessionId ?? ""),
);
// When no active session, always show empty — don't use stale cache
const messages = activeSessionId ? rawMessages ?? [] : [];
// Check if current session is archived
const currentSession = activeSessionId
? allSessions.find((s) => s.id === activeSessionId)
: null;
const isSessionArchived = currentSession?.status === "archived";
const qc = useQueryClient();
const createSession = useCreateChatSession();
@@ -217,55 +227,75 @@ export function ChatWindow() {
return (
<div className={containerClass}>
{/* Header */}
<div className="flex items-center justify-between border-b px-4 py-2.5">
<AgentSelector
agents={availableAgents}
activeAgent={activeAgent}
onSelect={handleSelectAgent}
/>
<div className="flex items-center gap-0.5">
<button
onClick={() => {
setActiveSession(null);
clearTimeline();
setPendingTask(null);
}}
title="New chat"
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<Plus className="size-3.5" />
</button>
<button
onClick={toggleFullscreen}
title={isFullscreen ? "Exit fullscreen" : "Fullscreen"}
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
{isFullscreen ? <Minimize2 className="size-3.5" /> : <Maximize2 className="size-3.5" />}
</button>
<button
onClick={() => setOpen(false)}
title="Minimize"
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<Minus className="size-3.5" />
</button>
{!showHistory && (
<div className="flex items-center justify-between border-b px-4 py-2.5">
<AgentSelector
agents={availableAgents}
activeAgent={activeAgent}
onSelect={handleSelectAgent}
/>
<div className="flex items-center gap-0.5">
<button
onClick={() => setShowHistory(true)}
title="Chat history"
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<History className="size-3.5" />
</button>
<button
onClick={() => {
setActiveSession(null);
clearTimeline();
setPendingTask(null);
}}
title="New chat"
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<Plus className="size-3.5" />
</button>
<button
onClick={toggleFullscreen}
title={isFullscreen ? "Exit fullscreen" : "Fullscreen"}
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
{isFullscreen ? <Minimize2 className="size-3.5" /> : <Maximize2 className="size-3.5" />}
</button>
<button
onClick={() => setOpen(false)}
title="Minimize"
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<Minus className="size-3.5" />
</button>
</div>
</div>
</div>
{/* Messages or Empty State */}
{hasMessages ? (
<ChatMessageList
messages={messages}
agent={activeAgent}
timelineItems={timelineItems}
isWaiting={!!pendingTaskId}
/>
) : (
<EmptyState agentName={activeAgent?.name} />
)}
{/* Input */}
<ChatInput onSend={handleSend} onStop={handleStop} isRunning={!!pendingTaskId} />
{showHistory ? (
<ChatSessionHistory />
) : (
<>
{/* Messages or Empty State */}
{hasMessages ? (
<ChatMessageList
messages={messages}
agent={activeAgent}
timelineItems={timelineItems}
isWaiting={!!pendingTaskId}
/>
) : (
<EmptyState agentName={activeAgent?.name} />
)}
{/* Input — disabled for archived sessions */}
<ChatInput
onSend={handleSend}
onStop={handleStop}
isRunning={!!pendingTaskId}
disabled={isSessionArchived}
/>
</>
)}
</div>
);
}

View File

@@ -23,6 +23,7 @@ interface ChatState {
activeSessionId: string | null;
pendingTaskId: string | null;
selectedAgentId: string | null;
showHistory: boolean;
timelineItems: ChatTimelineItem[];
setOpen: (open: boolean) => void;
toggle: () => void;
@@ -30,6 +31,7 @@ interface ChatState {
setActiveSession: (id: string | null) => void;
setPendingTask: (taskId: string | null) => void;
setSelectedAgentId: (id: string) => void;
setShowHistory: (show: boolean) => void;
addTimelineItem: (item: ChatTimelineItem) => void;
clearTimeline: () => void;
}
@@ -40,6 +42,7 @@ export const useChatStore = create<ChatState>((set) => ({
activeSessionId: readStored(SESSION_STORAGE_KEY),
pendingTaskId: null,
selectedAgentId: readStored(AGENT_STORAGE_KEY),
showHistory: false,
timelineItems: [],
setOpen: (open) => set({ isOpen: open, ...(open ? {} : { isFullscreen: false }) }),
toggle: () => set((s) => ({ isOpen: !s.isOpen, ...(s.isOpen ? { isFullscreen: false } : {}) })),
@@ -57,6 +60,7 @@ export const useChatStore = create<ChatState>((set) => ({
localStorage.setItem(AGENT_STORAGE_KEY, id);
set({ selectedAgentId: id });
},
setShowHistory: (show) => set({ showHistory: show }),
addTimelineItem: (item) =>
set((s) => {
if (s.timelineItems.some((t) => t.seq === item.seq)) return s;

View File

@@ -608,8 +608,9 @@ export class ApiClient {
}
// Chat Sessions
async listChatSessions(): Promise<ChatSession[]> {
return this.fetch("/api/chat/sessions");
async listChatSessions(params?: { status?: string }): Promise<ChatSession[]> {
const query = params?.status ? `?status=${params.status}` : "";
return this.fetch(`/api/chat/sessions${query}`);
}
async getChatSession(id: string): Promise<ChatSession> {

View File

@@ -71,10 +71,21 @@ func (h *Handler) ListChatSessions(w http.ResponseWriter, r *http.Request) {
}
workspaceID := ctxWorkspaceID(r.Context())
sessions, err := h.Queries.ListChatSessionsByCreator(r.Context(), db.ListChatSessionsByCreatorParams{
WorkspaceID: parseUUID(workspaceID),
CreatorID: parseUUID(userID),
})
status := r.URL.Query().Get("status")
var sessions []db.ChatSession
var err error
if status == "all" {
sessions, err = h.Queries.ListAllChatSessionsByCreator(r.Context(), db.ListAllChatSessionsByCreatorParams{
WorkspaceID: parseUUID(workspaceID),
CreatorID: parseUUID(userID),
})
} else {
sessions, err = h.Queries.ListChatSessionsByCreator(r.Context(), db.ListChatSessionsByCreatorParams{
WorkspaceID: parseUUID(workspaceID),
CreatorID: parseUUID(userID),
})
}
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list chat sessions")
return

View File

@@ -221,6 +221,48 @@ func (q *Queries) GetLastChatTaskSession(ctx context.Context, chatSessionID pgty
return i, err
}
const listAllChatSessionsByCreator = `-- name: ListAllChatSessionsByCreator :many
SELECT id, workspace_id, agent_id, creator_id, title, session_id, work_dir, status, created_at, updated_at FROM chat_session
WHERE workspace_id = $1 AND creator_id = $2
ORDER BY updated_at DESC
`
type ListAllChatSessionsByCreatorParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
CreatorID pgtype.UUID `json:"creator_id"`
}
func (q *Queries) ListAllChatSessionsByCreator(ctx context.Context, arg ListAllChatSessionsByCreatorParams) ([]ChatSession, error) {
rows, err := q.db.Query(ctx, listAllChatSessionsByCreator, arg.WorkspaceID, arg.CreatorID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ChatSession{}
for rows.Next() {
var i ChatSession
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.AgentID,
&i.CreatorID,
&i.Title,
&i.SessionID,
&i.WorkDir,
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listChatMessages = `-- name: ListChatMessages :many
SELECT id, chat_session_id, role, content, task_id, created_at FROM chat_message
WHERE chat_session_id = $1

View File

@@ -16,6 +16,11 @@ SELECT * FROM chat_session
WHERE workspace_id = $1 AND creator_id = $2 AND status = 'active'
ORDER BY updated_at DESC;
-- name: ListAllChatSessionsByCreator :many
SELECT * FROM chat_session
WHERE workspace_id = $1 AND creator_id = $2
ORDER BY updated_at DESC;
-- name: UpdateChatSessionTitle :one
UPDATE chat_session SET title = $2, updated_at = now()
WHERE id = $1