Files
multica/packages/views/chat/components/chat-fab.tsx
Naiyuan Qing a744cd4f45 feat(chat): redesign state, header, and unread tracking
State management
- Pending task / live timeline are now Query-cache single source;
  Zustand mirror removed (fixes duplicate assistant render caused by
  the invalidate→refetch race window)
- WS subscriptions moved from ChatWindow to global useRealtimeSync so
  pending state survives minimize and refresh
- New GET /chat/sessions/:id/pending-task to recover live state on mount
- Drafts persisted per-session (was per-workspace)

Unread tracking
- Migration 040: chat_session.unread_since (event-driven; old chats
  stay clean — no mass backfill)
- POST /chat/sessions/:id/read clears unread; broadcasts
  chat:session_read so other devices sync
- New GET /chat/pending-tasks aggregate for the FAB
- ChatFab: brand-color impulse animation while running, brand-dot
  badge of unread session count
- ChatWindow auto-marks read when user is viewing the session

Header redesign
- Two independent dropdowns: agent (avatar + name + My/Others
  grouping) at the input bottom-left; session (title + agent avatar)
  in the header
- ⊕ new-chat button replaces the old + and history buttons
- Session dropdown lists all sessions across agents with avatars
- Empty state: 3 clickable starter prompts that send immediately
- Mention link renderer falls through to default span on null —
  fixes @member/@agent/@all silently disappearing app-wide
- User messages render through Markdown
- Enter submits in chat input only (with IME guard + codeBlock skip);
  bubble menu hidden in chat

Misc
- Partial index on agent_task_queue for fast pending-task lookup
- 2 new storage keys added to clearWorkspaceStorage
- useMarkChatSessionRead has onError rollback
- chat.* namespace logs across store, mutations, components, realtime

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 18:21:11 +08:00

64 lines
2.4 KiB
TypeScript

"use client";
import { MessageCircle } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { cn } from "@multica/ui/lib/utils";
import { useChatStore } from "@multica/core/chat";
import { chatSessionsOptions, pendingChatTasksOptions } from "@multica/core/chat/queries";
import { useWorkspaceId } from "@multica/core/hooks";
import { createLogger } from "@multica/core/logger";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@multica/ui/components/ui/tooltip";
const logger = createLogger("chat.ui");
export function ChatFab() {
const wsId = useWorkspaceId();
const isOpen = useChatStore((s) => s.isOpen);
const toggle = useChatStore((s) => s.toggle);
const { data: sessions = [] } = useQuery(chatSessionsOptions(wsId));
const { data: pending } = useQuery(pendingChatTasksOptions(wsId));
if (isOpen) return null;
const unreadSessionCount = sessions.filter((s) => s.has_unread).length;
const isRunning = (pending?.tasks ?? []).length > 0;
const handleClick = () => {
logger.info("fab.click (open chat)", { unreadSessionCount, isRunning });
toggle();
};
// Tooltip text communicates the state that isn't carried by the icon/badge.
const tooltip = isRunning
? "Multica is working..."
: unreadSessionCount > 0
? `${unreadSessionCount} unread ${unreadSessionCount === 1 ? "chat" : "chats"}`
: "Ask Multica";
return (
<Tooltip>
<TooltipTrigger
onClick={handleClick}
className={cn(
"absolute bottom-2 right-2 z-50 flex size-10 cursor-pointer items-center justify-center rounded-full ring-1 ring-foreground/10 bg-card text-muted-foreground shadow-sm transition-transform hover:scale-110 hover:text-accent-foreground active:scale-95",
// Impulse the button itself while a chat task is running — no
// outer ring to keep things calm.
isRunning && "animate-chat-impulse",
)}
>
<MessageCircle className="size-5" />
{unreadSessionCount > 0 && (
<span className="pointer-events-none absolute -top-0.5 -right-0.5 flex min-w-4 h-4 items-center justify-center rounded-full bg-brand px-1 text-xs font-semibold leading-none text-background">
{unreadSessionCount > 9 ? "9+" : unreadSessionCount}
</span>
)}
</TooltipTrigger>
<TooltipContent side="top" sideOffset={10}>{tooltip}</TooltipContent>
</Tooltip>
);
}