Files
multica/packages/views/chat/components/chat-fab.tsx
Naiyuan Qing 0b054b5c8b fix(chat): clear external channel binding on archive, drop archived from FAB unread (#5214)
Archiving a channel-bound chat session now severs its
channel_chat_session_binding in the same tx as the status flip. The web
send path already treats status='archived' as read-only, but the channel
engine (Feishu/Slack) resolves inbound traffic through the binding without
checking session status, so an archived-but-still-bound session kept
receiving agent replies and a stuck, uncleared unread badge. Dropping the
binding makes the next inbound message fork a fresh session; unarchive does
NOT recreate it (a later session may already own the channel).

The FAB unread badge counted status=all sessions without excluding
archived, so residual unread on an archived session (archive does not
mark-read) held the badge even though the session is hidden and read-only.
Extract countUnreadChatSessions() and exclude archived. This matches what
mobile already computes (active-only list), restoring count parity.

Tests: backend archive-clears-binding + unarchive-does-not-recreate;
frontend countUnreadChatSessions archived-exclusion cases.

MUL-4372

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-13 10:01:13 +08:00

74 lines
2.8 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,
countUnreadChatSessions,
hasPendingChatTasksOptions,
} 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";
import { useT } from "../../i18n";
const logger = createLogger("chat.ui");
export function ChatFab() {
const { t } = useT("chat");
const wsId = useWorkspaceId();
const isOpen = useChatStore((s) => s.isOpen);
const toggle = useChatStore((s) => s.toggle);
const { data: sessions = [] } = useQuery(chatSessionsOptions(wsId));
// FAB only needs a boolean "is anything running", and only while the window
// is closed (when open, ChatWindow owns the detailed pending query). Gating
// on `enabled: !isOpen` keeps the minimised button off the per-message
// aggregate hot path entirely (MUL-4159).
const { data: hasPending } = useQuery({
...hasPendingChatTasksOptions(wsId),
enabled: !isOpen,
});
if (isOpen) return null;
const unreadSessionCount = countUnreadChatSessions(sessions);
const isRunning = hasPending?.has_pending ?? false;
const handleClick = () => {
logger.info("fab.click (open chat)", { unreadSessionCount, isRunning });
toggle();
};
// Tooltip text carries the running/unread state on hover; the FAB itself no
// longer shows an unread-count badge (it duplicated the chat tab's, MUL-4374).
const tooltip = isRunning
? t(($) => $.fab.running)
: unreadSessionCount > 0
? t(($) => $.fab.unread, { count: unreadSessionCount })
: t(($) => $.fab.default);
return (
<Tooltip>
<TooltipTrigger
onClick={handleClick}
aria-label={tooltip}
className={cn(
"absolute bottom-2 right-2 z-50 flex size-10 touch-manipulation items-center justify-center rounded-full bg-surface-raised text-muted-foreground shadow-[var(--floating-shadow)] ring-1 ring-surface-border transition-[background-color,color,box-shadow] hover:bg-surface-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/70 focus-visible:ring-offset-2 focus-visible:ring-offset-page-canvas active:bg-surface-hover",
// 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" />
</TooltipTrigger>
<TooltipContent side="top" sideOffset={10}>{tooltip}</TooltipContent>
</Tooltip>
);
}