Merge pull request #747 from AnishSarkar22/feat/inbox

feat: Move inbox to a new area
This commit is contained in:
Rohan Verma 2026-01-27 21:23:56 -08:00 committed by GitHub
commit 8b0b4a2c4d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1106 additions and 670 deletions

View file

@ -6,6 +6,7 @@ For older items (beyond the sync window), use the list endpoint.
""" """
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel from pydantic import BaseModel
@ -20,6 +21,9 @@ router = APIRouter(prefix="/notifications", tags=["notifications"])
# Must match frontend SYNC_WINDOW_DAYS in use-inbox.ts # Must match frontend SYNC_WINDOW_DAYS in use-inbox.ts
SYNC_WINDOW_DAYS = 14 SYNC_WINDOW_DAYS = 14
# Valid notification types - must match frontend InboxItemTypeEnum
NotificationType = Literal["connector_indexing", "document_processing", "new_mention"]
class NotificationResponse(BaseModel): class NotificationResponse(BaseModel):
"""Response model for a single notification.""" """Response model for a single notification."""
@ -73,6 +77,9 @@ class UnreadCountResponse(BaseModel):
@router.get("/unread-count", response_model=UnreadCountResponse) @router.get("/unread-count", response_model=UnreadCountResponse)
async def get_unread_count( async def get_unread_count(
search_space_id: int | None = Query(None, description="Filter by search space ID"), search_space_id: int | None = Query(None, description="Filter by search space ID"),
type_filter: NotificationType | None = Query(
None, alias="type", description="Filter by notification type"
),
user: User = Depends(current_active_user), user: User = Depends(current_active_user),
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
) -> UnreadCountResponse: ) -> UnreadCountResponse:
@ -103,6 +110,10 @@ async def get_unread_count(
| (Notification.search_space_id.is_(None)) | (Notification.search_space_id.is_(None))
) )
# Filter by notification type if provided
if type_filter:
base_filter.append(Notification.type == type_filter)
# Total unread count (all time) # Total unread count (all time)
total_query = select(func.count(Notification.id)).where(*base_filter) total_query = select(func.count(Notification.id)).where(*base_filter)
total_result = await session.execute(total_query) total_result = await session.execute(total_query)
@ -125,7 +136,7 @@ async def get_unread_count(
@router.get("", response_model=NotificationListResponse) @router.get("", response_model=NotificationListResponse)
async def list_notifications( async def list_notifications(
search_space_id: int | None = Query(None, description="Filter by search space ID"), search_space_id: int | None = Query(None, description="Filter by search space ID"),
type_filter: str | None = Query( type_filter: NotificationType | None = Query(
None, alias="type", description="Filter by notification type" None, alias="type", description="Filter by notification type"
), ),
before_date: str | None = Query( before_date: str | None = Query(

View file

@ -13,7 +13,11 @@ import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom"; import {
clearTargetCommentIdAtom,
currentThreadAtom,
setTargetCommentIdAtom,
} from "@/atoms/chat/current-thread.atom";
import { import {
type MentionedDocumentInfo, type MentionedDocumentInfo,
mentionedDocumentIdsAtom, mentionedDocumentIdsAtom,
@ -158,6 +162,8 @@ export default function NewChatPage() {
const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom); const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom);
const hydratePlanState = useSetAtom(hydratePlanStateAtom); const hydratePlanState = useSetAtom(hydratePlanStateAtom);
const setCurrentThreadState = useSetAtom(currentThreadAtom); const setCurrentThreadState = useSetAtom(currentThreadAtom);
const setTargetCommentId = useSetAtom(setTargetCommentIdAtom);
const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom);
// Get current user for author info in shared chats // Get current user for author info in shared chats
const { data: currentUser } = useAtomValue(currentUserAtom); const { data: currentUser } = useAtomValue(currentUserAtom);
@ -355,46 +361,33 @@ export default function NewChatPage() {
// Handle scroll to comment from URL query params (e.g., from inbox item click) // Handle scroll to comment from URL query params (e.g., from inbox item click)
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const targetCommentId = searchParams.get("commentId"); const targetCommentIdParam = searchParams.get("commentId");
// Set target comment ID from URL param - the AssistantMessage and CommentItem
// components will handle scrolling and highlighting once comments are loaded
useEffect(() => { useEffect(() => {
if (!targetCommentId || isInitializing || messages.length === 0) return; if (targetCommentIdParam && !isInitializing) {
const commentId = Number.parseInt(targetCommentIdParam, 10);
const tryScroll = () => { if (!Number.isNaN(commentId)) {
const el = document.querySelector(`[data-comment-id="${targetCommentId}"]`); setTargetCommentId(commentId);
if (el) { }
el.scrollIntoView({ behavior: "smooth", block: "center" });
return true;
} }
return false;
};
// Try immediately // Cleanup on unmount or when navigating away
if (tryScroll()) return; return () => clearTargetCommentId();
}, [targetCommentIdParam, isInitializing, setTargetCommentId, clearTargetCommentId]);
// Retry every 200ms for up to 10 seconds
const intervalId = setInterval(() => {
if (tryScroll()) clearInterval(intervalId);
}, 200);
const timeoutId = setTimeout(() => clearInterval(intervalId), 10000);
return () => {
clearInterval(intervalId);
clearTimeout(timeoutId);
};
}, [targetCommentId, isInitializing, messages.length]);
// Sync current thread state to atom // Sync current thread state to atom
useEffect(() => { useEffect(() => {
setCurrentThreadState({ setCurrentThreadState((prev) => ({
...prev,
id: currentThread?.id ?? null, id: currentThread?.id ?? null,
visibility: currentThread?.visibility ?? null, visibility: currentThread?.visibility ?? null,
hasComments: currentThread?.has_comments ?? false, hasComments: currentThread?.has_comments ?? false,
addingCommentToMessageId: null, addingCommentToMessageId: null,
publicShareEnabled: currentThread?.public_share_enabled ?? false, publicShareEnabled: currentThread?.public_share_enabled ?? false,
publicShareToken: currentThread?.public_share_token ?? null, publicShareToken: currentThread?.public_share_token ?? null,
}); }));
}, [currentThread, setCurrentThreadState]); }, [currentThread, setCurrentThreadState]);
// Cancel ongoing request // Cancel ongoing request

View file

@ -17,6 +17,8 @@ interface CurrentThreadState {
visibility: ChatVisibility | null; visibility: ChatVisibility | null;
hasComments: boolean; hasComments: boolean;
addingCommentToMessageId: number | null; addingCommentToMessageId: number | null;
/** Whether the right-side comments panel is collapsed (desktop only) */
commentsCollapsed: boolean;
publicShareEnabled: boolean; publicShareEnabled: boolean;
publicShareToken: string | null; publicShareToken: string | null;
} }
@ -26,6 +28,7 @@ const initialState: CurrentThreadState = {
visibility: null, visibility: null,
hasComments: false, hasComments: false,
addingCommentToMessageId: null, addingCommentToMessageId: null,
commentsCollapsed: false,
publicShareEnabled: false, publicShareEnabled: false,
publicShareToken: null, publicShareToken: null,
}; };
@ -38,6 +41,8 @@ export const commentsEnabledAtom = atom(
export const showCommentsGutterAtom = atom((get) => { export const showCommentsGutterAtom = atom((get) => {
const thread = get(currentThreadAtom); const thread = get(currentThreadAtom);
// Hide gutter if comments are collapsed
if (thread.commentsCollapsed) return false;
return ( return (
thread.visibility === "SEARCH_SPACE" && thread.visibility === "SEARCH_SPACE" &&
(thread.hasComments || thread.addingCommentToMessageId !== null) (thread.hasComments || thread.addingCommentToMessageId !== null)
@ -59,3 +64,34 @@ export const setThreadVisibilityAtom = atom(null, (get, set, newVisibility: Chat
export const resetCurrentThreadAtom = atom(null, (_, set) => { export const resetCurrentThreadAtom = atom(null, (_, set) => {
set(currentThreadAtom, initialState); set(currentThreadAtom, initialState);
}); });
/** Atom to read whether comments panel is collapsed */
export const commentsCollapsedAtom = atom((get) => get(currentThreadAtom).commentsCollapsed);
/** Atom to toggle the comments collapsed state */
export const toggleCommentsCollapsedAtom = atom(null, (get, set) => {
const current = get(currentThreadAtom);
set(currentThreadAtom, { ...current, commentsCollapsed: !current.commentsCollapsed });
});
/** Atom to explicitly set the comments collapsed state */
export const setCommentsCollapsedAtom = atom(null, (get, set, collapsed: boolean) => {
set(currentThreadAtom, { ...get(currentThreadAtom), commentsCollapsed: collapsed });
});
/** Target comment ID to scroll to (from URL navigation or inbox click) */
export const targetCommentIdAtom = atom<number | null>(null);
/** Setter for target comment ID - also ensures comments are not collapsed */
export const setTargetCommentIdAtom = atom(null, (get, set, commentId: number | null) => {
// Ensure comments are not collapsed when navigating to a comment
if (commentId !== null) {
set(currentThreadAtom, { ...get(currentThreadAtom), commentsCollapsed: false });
}
set(targetCommentIdAtom, commentId);
});
/** Clear target after navigation completes */
export const clearTargetCommentIdAtom = atom(null, (_, set) => {
set(targetCommentIdAtom, null);
});

View file

@ -5,13 +5,16 @@ import {
MessagePrimitive, MessagePrimitive,
useAssistantState, useAssistantState,
} from "@assistant-ui/react"; } from "@assistant-ui/react";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { CheckIcon, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react"; import { CheckIcon, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react";
import type { FC } from "react"; import type { FC } from "react";
import { useContext, useEffect, useRef, useState } from "react"; import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { import {
addingCommentToMessageIdAtom, addingCommentToMessageIdAtom,
clearTargetCommentIdAtom,
commentsCollapsedAtom,
commentsEnabledAtom, commentsEnabledAtom,
targetCommentIdAtom,
} from "@/atoms/chat/current-thread.atom"; } from "@/atoms/chat/current-thread.atom";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { BranchPicker } from "@/components/assistant-ui/branch-picker"; import { BranchPicker } from "@/components/assistant-ui/branch-picker";
@ -102,6 +105,7 @@ export const AssistantMessage: FC = () => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const dbMessageId = parseMessageId(messageId); const dbMessageId = parseMessageId(messageId);
const commentsEnabled = useAtomValue(commentsEnabledAtom); const commentsEnabled = useAtomValue(commentsEnabledAtom);
const commentsCollapsed = useAtomValue(commentsCollapsedAtom);
const [addingCommentToMessageId, setAddingCommentToMessageId] = useAtom( const [addingCommentToMessageId, setAddingCommentToMessageId] = useAtom(
addingCommentToMessageIdAtom addingCommentToMessageIdAtom
); );
@ -115,11 +119,23 @@ export const AssistantMessage: FC = () => {
const isLastMessage = useAssistantState(({ message }) => message?.isLast ?? false); const isLastMessage = useAssistantState(({ message }) => message?.isLast ?? false);
const isMessageStreaming = isThreadRunning && isLastMessage; const isMessageStreaming = isThreadRunning && isLastMessage;
const { data: commentsData } = useComments({ const { data: commentsData, isSuccess: commentsLoaded } = useComments({
messageId: dbMessageId ?? 0, messageId: dbMessageId ?? 0,
enabled: !!dbMessageId, enabled: !!dbMessageId,
}); });
// Target comment navigation - read target from global atom
const targetCommentId = useAtomValue(targetCommentIdAtom);
const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom);
// Check if target comment belongs to this message (including replies)
const hasTargetComment = useMemo(() => {
if (!targetCommentId || !commentsData?.comments) return false;
return commentsData.comments.some(
(c) => c.id === targetCommentId || c.replies?.some((r) => r.id === targetCommentId)
);
}, [targetCommentId, commentsData]);
const commentCount = commentsData?.total_count ?? 0; const commentCount = commentsData?.total_count ?? 0;
const hasComments = commentCount > 0; const hasComments = commentCount > 0;
const isAddingComment = dbMessageId !== null && addingCommentToMessageId === dbMessageId; const isAddingComment = dbMessageId !== null && addingCommentToMessageId === dbMessageId;
@ -144,6 +160,24 @@ export const AssistantMessage: FC = () => {
return () => observer.disconnect(); return () => observer.disconnect();
}, []); }, []);
// Auto-open sheet on mobile/tablet when this message has the target comment
useEffect(() => {
if (hasTargetComment && !isDesktop && commentsLoaded) {
setIsSheetOpen(true);
}
}, [hasTargetComment, isDesktop, commentsLoaded]);
// Scroll message into view when it contains target comment (desktop)
useEffect(() => {
if (hasTargetComment && isDesktop && commentsLoaded && messageRef.current) {
// Small delay to ensure DOM is ready after comments render
const timeoutId = setTimeout(() => {
messageRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
}, 100);
return () => clearTimeout(timeoutId);
}
}, [hasTargetComment, isDesktop, commentsLoaded]);
const showCommentTrigger = searchSpaceId && commentsEnabled && !isMessageStreaming && dbMessageId; const showCommentTrigger = searchSpaceId && commentsEnabled && !isMessageStreaming && dbMessageId;
// Determine sheet side based on screen size // Determine sheet side based on screen size
@ -157,8 +191,8 @@ export const AssistantMessage: FC = () => {
> >
<AssistantMessageInner /> <AssistantMessageInner />
{/* Desktop comment panel - only on lg screens and above */} {/* Desktop comment panel - only on lg screens and above, hidden when collapsed */}
{searchSpaceId && commentsEnabled && !isMessageStreaming && ( {searchSpaceId && commentsEnabled && !isMessageStreaming && !commentsCollapsed && (
<div className="absolute left-full top-0 ml-4 hidden lg:block w-72"> <div className="absolute left-full top-0 ml-4 hidden lg:block w-72">
<div <div
className={`sticky top-3 ${showCommentPanel ? "opacity-100" : "opacity-0 group-hover:opacity-100"} transition-opacity`} className={`sticky top-3 ${showCommentPanel ? "opacity-100" : "opacity-0 group-hover:opacity-100"} transition-opacity`}

View file

@ -252,13 +252,12 @@ const defaultComponents = memoizeMarkdownComponents({
<hr className={cn("aui-md-hr my-5 border-b", className)} {...props} /> <hr className={cn("aui-md-hr my-5 border-b", className)} {...props} />
), ),
table: ({ className, ...props }) => ( table: ({ className, ...props }) => (
<div className="aui-md-table-wrapper my-5 w-full overflow-x-auto">
<table <table
className={cn( className={cn("aui-md-table w-full min-w-max border-separate border-spacing-0", className)}
"aui-md-table my-5 w-full border-separate border-spacing-0 overflow-y-auto",
className
)}
{...props} {...props}
/> />
</div>
), ),
th: ({ className, children, ...props }) => ( th: ({ className, children, ...props }) => (
<th <th

View file

@ -110,7 +110,7 @@ const ThreadContent: FC<{ header?: React.ReactNode }> = ({ header }) => {
}} }}
/> />
<ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer sticky bottom-0 z-20 mx-auto mt-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-background pb-4 md:pb-6"> <ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer sticky bottom-0 z-10 mx-auto mt-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-background pb-4 md:pb-6">
<ThreadScrollToBottom /> <ThreadScrollToBottom />
<AssistantIf condition={({ thread }) => !thread.isEmpty}> <AssistantIf condition={({ thread }) => !thread.isEmpty}>
<div className="fade-in slide-in-from-bottom-4 animate-in duration-500 ease-out fill-mode-both"> <div className="fade-in slide-in-from-bottom-4 animate-in duration-500 ease-out fill-mode-both">

View file

@ -1,6 +1,9 @@
"use client"; "use client";
import { useAtomValue, useSetAtom } from "jotai";
import { MessageSquare } from "lucide-react"; import { MessageSquare } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { clearTargetCommentIdAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -76,10 +79,9 @@ function renderMentions(content: string): React.ReactNode {
const mentionPattern = /@\{([^}]+)\}/g; const mentionPattern = /@\{([^}]+)\}/g;
const parts: React.ReactNode[] = []; const parts: React.ReactNode[] = [];
let lastIndex = 0; let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = mentionPattern.exec(content)) !== null) { for (const match of content.matchAll(mentionPattern)) {
if (match.index > lastIndex) { if (match.index !== undefined && match.index > lastIndex) {
parts.push(content.slice(lastIndex, match.index)); parts.push(content.slice(lastIndex, match.index));
} }
@ -90,7 +92,7 @@ function renderMentions(content: string): React.ReactNode {
</span> </span>
); );
lastIndex = match.index + match[0].length; lastIndex = (match.index ?? 0) + match[0].length;
} }
if (lastIndex < content.length) { if (lastIndex < content.length) {
@ -113,6 +115,37 @@ export function CommentItem({
members = [], members = [],
membersLoading = false, membersLoading = false,
}: CommentItemProps) { }: CommentItemProps) {
const commentRef = useRef<HTMLDivElement>(null);
const [isHighlighted, setIsHighlighted] = useState(false);
// Target comment navigation
const targetCommentId = useAtomValue(targetCommentIdAtom);
const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom);
const isTarget = targetCommentId === comment.id;
// Scroll into view and highlight when this is the target comment
useEffect(() => {
if (isTarget && commentRef.current) {
// Small delay to ensure DOM is ready
const scrollTimeoutId = setTimeout(() => {
commentRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
setIsHighlighted(true);
}, 150);
// Remove highlight and clear target after delay
const clearTimeoutId = setTimeout(() => {
setIsHighlighted(false);
clearTargetCommentId();
}, 3000);
return () => {
clearTimeout(scrollTimeoutId);
clearTimeout(clearTimeoutId);
};
}
}, [isTarget, clearTargetCommentId]);
const displayName = const displayName =
comment.author?.displayName || comment.author?.email.split("@")[0] || "Unknown"; comment.author?.displayName || comment.author?.email.split("@")[0] || "Unknown";
const email = comment.author?.email || ""; const email = comment.author?.email || "";
@ -122,7 +155,14 @@ export function CommentItem({
}; };
return ( return (
<div className={cn("group flex gap-3")} data-comment-id={comment.id}> <div
ref={commentRef}
className={cn(
"group flex gap-3 rounded-lg p-1 -m-1 transition-all duration-300",
isHighlighted && "ring-2 ring-primary ring-offset-2 ring-offset-background"
)}
data-comment-id={comment.id}
>
<Avatar className="size-8 shrink-0"> <Avatar className="size-8 shrink-0">
{comment.author?.avatarUrl && ( {comment.author?.avatarUrl && (
<AvatarImage src={comment.author.avatarUrl} alt={displayName} /> <AvatarImage src={comment.author.avatarUrl} alt={displayName} />

View file

@ -0,0 +1,36 @@
"use client";
import { createContext, useContext, type ReactNode } from "react";
interface SidebarContextValue {
isCollapsed: boolean;
setIsCollapsed: (collapsed: boolean) => void;
toggleCollapsed: () => void;
}
const SidebarContext = createContext<SidebarContextValue | null>(null);
interface SidebarProviderProps {
children: ReactNode;
value: SidebarContextValue;
}
export function SidebarProvider({ children, value }: SidebarProviderProps) {
return <SidebarContext.Provider value={value}>{children}</SidebarContext.Provider>;
}
export function useSidebarContext(): SidebarContextValue {
const context = useContext(SidebarContext);
if (!context) {
throw new Error("useSidebarContext must be used within a SidebarProvider");
}
return context;
}
/**
* Safe version that returns null if not within provider
* Useful for components that may be rendered outside the sidebar context
*/
export function useSidebarContextSafe(): SidebarContextValue | null {
return useContext(SidebarContext);
}

View file

@ -1 +1,2 @@
export { useSidebarState } from "./useSidebarState"; export { useSidebarState } from "./useSidebarState";
export { SidebarProvider, useSidebarContext, useSidebarContextSafe } from "./SidebarContext";

View file

@ -32,7 +32,6 @@ import { CreateSearchSpaceDialog } from "../ui/dialogs";
import { LayoutShell } from "../ui/shell"; import { LayoutShell } from "../ui/shell";
import { AllPrivateChatsSidebar } from "../ui/sidebar/AllPrivateChatsSidebar"; import { AllPrivateChatsSidebar } from "../ui/sidebar/AllPrivateChatsSidebar";
import { AllSharedChatsSidebar } from "../ui/sidebar/AllSharedChatsSidebar"; import { AllSharedChatsSidebar } from "../ui/sidebar/AllSharedChatsSidebar";
import { InboxSidebar } from "../ui/sidebar/InboxSidebar";
interface LayoutDataProviderProps { interface LayoutDataProviderProps {
searchSpaceId: string; searchSpaceId: string;
@ -100,23 +99,60 @@ export function LayoutDataProvider({
// Inbox sidebar state // Inbox sidebar state
const [isInboxSidebarOpen, setIsInboxSidebarOpen] = useState(false); const [isInboxSidebarOpen, setIsInboxSidebarOpen] = useState(false);
const [isInboxDocked, setIsInboxDocked] = useState(false);
// Search space dialog state // Search space dialog state
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false); const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
// Inbox hook // Inbox hooks - separate data sources for mentions and status tabs
// This ensures each tab has independent pagination and data loading
const userId = user?.id ? String(user.id) : null; const userId = user?.id ? String(user.id) : null;
// Mentions: Only fetch "new_mention" type notifications
const { const {
inboxItems, inboxItems: mentionItems,
unreadCount, unreadCount: mentionUnreadCount,
loading: inboxLoading, loading: mentionLoading,
loadingMore: inboxLoadingMore, loadingMore: mentionLoadingMore,
hasMore: inboxHasMore, hasMore: mentionHasMore,
loadMore: inboxLoadMore, loadMore: mentionLoadMore,
markAsRead, markAsRead: markMentionAsRead,
markAllAsRead, markAllAsRead: markAllMentionsAsRead,
} = useInbox(userId, Number(searchSpaceId) || null, "new_mention");
// Status: Fetch all types (will be filtered client-side to status types)
// We pass null to get all, then InboxSidebar filters to status types
const {
inboxItems: statusItems,
unreadCount: statusUnreadCount,
loading: statusLoading,
loadingMore: statusLoadingMore,
hasMore: statusHasMore,
loadMore: statusLoadMore,
markAsRead: markStatusAsRead,
markAllAsRead: markAllStatusAsRead,
} = useInbox(userId, Number(searchSpaceId) || null, null); } = useInbox(userId, Number(searchSpaceId) || null, null);
// Combined unread count for nav badge (mentions take priority for visibility)
const totalUnreadCount = mentionUnreadCount + statusUnreadCount;
// Unified mark as read that delegates to the correct hook
const markAsRead = useCallback(
async (id: number) => {
// Try both - one will succeed based on which list has the item
const mentionResult = await markMentionAsRead(id);
if (mentionResult) return true;
return markStatusAsRead(id);
},
[markMentionAsRead, markStatusAsRead]
);
// Mark all as read for both types
const markAllAsRead = useCallback(async () => {
await Promise.all([markAllMentionsAsRead(), markAllStatusAsRead()]);
return true;
}, [markAllMentionsAsRead, markAllStatusAsRead]);
// Delete dialogs state // Delete dialogs state
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false); const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null); const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
@ -197,7 +233,7 @@ export function LayoutDataProvider({
url: "#inbox", // Special URL to indicate this is handled differently url: "#inbox", // Special URL to indicate this is handled differently
icon: Inbox, icon: Inbox,
isActive: isInboxSidebarOpen, isActive: isInboxSidebarOpen,
badge: unreadCount > 0 ? formatInboxCount(unreadCount) : undefined, badge: totalUnreadCount > 0 ? formatInboxCount(totalUnreadCount) : undefined,
}, },
{ {
title: "Documents", title: "Documents",
@ -206,7 +242,7 @@ export function LayoutDataProvider({
isActive: pathname?.includes("/documents"), isActive: pathname?.includes("/documents"),
}, },
], ],
[searchSpaceId, pathname, isInboxSidebarOpen, unreadCount] [searchSpaceId, pathname, isInboxSidebarOpen, totalUnreadCount]
); );
// Handlers // Handlers
@ -298,9 +334,9 @@ export function LayoutDataProvider({
const handleNavItemClick = useCallback( const handleNavItemClick = useCallback(
(item: NavItem) => { (item: NavItem) => {
// Handle inbox specially - open sidebar instead of navigating // Handle inbox specially - toggle sidebar instead of navigating
if (item.url === "#inbox") { if (item.url === "#inbox") {
setIsInboxSidebarOpen(true); setIsInboxSidebarOpen((prev) => !prev);
return; return;
} }
router.push(item.url); router.push(item.url);
@ -462,6 +498,32 @@ export function LayoutDataProvider({
theme={theme} theme={theme}
setTheme={setTheme} setTheme={setTheme}
isChatPage={isChatPage} isChatPage={isChatPage}
inbox={{
isOpen: isInboxSidebarOpen,
onOpenChange: setIsInboxSidebarOpen,
// Separate data sources for each tab
mentions: {
items: mentionItems,
unreadCount: mentionUnreadCount,
loading: mentionLoading,
loadingMore: mentionLoadingMore,
hasMore: mentionHasMore,
loadMore: mentionLoadMore,
},
status: {
items: statusItems,
unreadCount: statusUnreadCount,
loading: statusLoading,
loadingMore: statusLoadingMore,
hasMore: statusHasMore,
loadMore: statusLoadMore,
},
totalUnreadCount,
markAsRead,
markAllAsRead,
isDocked: isInboxDocked,
onDockedChange: setIsInboxDocked,
}}
> >
{children} {children}
</LayoutShell> </LayoutShell>
@ -607,20 +669,6 @@ export function LayoutDataProvider({
searchSpaceId={searchSpaceId} searchSpaceId={searchSpaceId}
/> />
{/* Inbox Sidebar */}
<InboxSidebar
open={isInboxSidebarOpen}
onOpenChange={setIsInboxSidebarOpen}
inboxItems={inboxItems}
unreadCount={unreadCount}
loading={inboxLoading}
loadingMore={inboxLoadingMore}
hasMore={inboxHasMore}
loadMore={inboxLoadMore}
markAsRead={markAsRead}
markAllAsRead={markAllAsRead}
/>
{/* Create Search Space Dialog */} {/* Create Search Space Dialog */}
<CreateSearchSpaceDialog <CreateSearchSpaceDialog
open={isCreateSearchSpaceDialogOpen} open={isCreateSearchSpaceDialogOpen}

View file

@ -1,14 +1,43 @@
"use client"; "use client";
import { useState } from "react"; import { useMemo, useState } from "react";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import type { InboxItem } from "@/hooks/use-inbox";
import { useIsMobile } from "@/hooks/use-mobile"; import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useSidebarState } from "../../hooks"; import { SidebarProvider, useSidebarState } from "../../hooks";
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types"; import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
import { Header } from "../header"; import { Header } from "../header";
import { IconRail } from "../icon-rail"; import { IconRail } from "../icon-rail";
import { MobileSidebar, MobileSidebarTrigger, Sidebar } from "../sidebar"; import { InboxSidebar, MobileSidebar, MobileSidebarTrigger, Sidebar } from "../sidebar";
// Tab-specific data source props
interface TabDataSource {
items: InboxItem[];
unreadCount: number;
loading: boolean;
loadingMore?: boolean;
hasMore?: boolean;
loadMore?: () => void;
}
// Inbox-related props with separate data sources per tab
interface InboxProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
/** Mentions tab data source with independent pagination */
mentions: TabDataSource;
/** Status tab data source with independent pagination */
status: TabDataSource;
/** Combined unread count for nav badge */
totalUnreadCount: number;
markAsRead: (id: number) => Promise<boolean>;
markAllAsRead: () => Promise<boolean>;
/** Whether the inbox is docked (permanent) */
isDocked?: boolean;
/** Callback to change docked state */
onDockedChange?: (docked: boolean) => void;
}
interface LayoutShellProps { interface LayoutShellProps {
searchSpaces: SearchSpace[]; searchSpaces: SearchSpace[];
@ -42,6 +71,8 @@ interface LayoutShellProps {
isChatPage?: boolean; isChatPage?: boolean;
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
// Inbox props
inbox?: InboxProps;
} }
export function LayoutShell({ export function LayoutShell({
@ -76,14 +107,22 @@ export function LayoutShell({
isChatPage = false, isChatPage = false,
children, children,
className, className,
inbox,
}: LayoutShellProps) { }: LayoutShellProps) {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const { isCollapsed, toggleCollapsed } = useSidebarState(defaultCollapsed); const { isCollapsed, setIsCollapsed, toggleCollapsed } = useSidebarState(defaultCollapsed);
// Memoize context value to prevent unnecessary re-renders
const sidebarContextValue = useMemo(
() => ({ isCollapsed, setIsCollapsed, toggleCollapsed }),
[isCollapsed, setIsCollapsed, toggleCollapsed]
);
// Mobile layout // Mobile layout
if (isMobile) { if (isMobile) {
return ( return (
<SidebarProvider value={sidebarContextValue}>
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
<div className={cn("flex h-screen w-full flex-col bg-background", className)}> <div className={cn("flex h-screen w-full flex-col bg-background", className)}>
<Header <Header
@ -125,15 +164,33 @@ export function LayoutShell({
<main className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}> <main className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
{children} {children}
</main> </main>
{/* Mobile Inbox Sidebar - only render when open to avoid scroll blocking */}
{inbox?.isOpen && (
<InboxSidebar
open={inbox.isOpen}
onOpenChange={inbox.onOpenChange}
mentions={inbox.mentions}
status={inbox.status}
totalUnreadCount={inbox.totalUnreadCount}
markAsRead={inbox.markAsRead}
markAllAsRead={inbox.markAllAsRead}
onCloseMobileSidebar={() => setMobileMenuOpen(false)}
/>
)}
</div> </div>
</TooltipProvider> </TooltipProvider>
</SidebarProvider>
); );
} }
// Desktop layout // Desktop layout
return ( return (
<SidebarProvider value={sidebarContextValue}>
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
<div className={cn("flex h-screen w-full gap-2 p-2 overflow-hidden bg-muted/40", className)}> <div
className={cn("flex h-screen w-full gap-2 p-2 overflow-hidden bg-muted/40", className)}
>
<div className="hidden md:flex overflow-hidden"> <div className="hidden md:flex overflow-hidden">
<IconRail <IconRail
searchSpaces={searchSpaces} searchSpaces={searchSpaces}
@ -145,7 +202,8 @@ export function LayoutShell({
/> />
</div> </div>
<div className="flex flex-1 rounded-xl border bg-background overflow-hidden"> {/* Main container with sidebar and content - relative for inbox positioning */}
<div className="relative flex flex-1 rounded-xl border bg-background overflow-hidden">
<Sidebar <Sidebar
searchSpace={searchSpace} searchSpace={searchSpace}
isCollapsed={isCollapsed} isCollapsed={isCollapsed}
@ -172,6 +230,21 @@ export function LayoutShell({
className="hidden md:flex border-r shrink-0" className="hidden md:flex border-r shrink-0"
/> />
{/* Docked Inbox Sidebar - renders as flex sibling between sidebar and content */}
{inbox?.isDocked && (
<InboxSidebar
open={inbox.isOpen}
onOpenChange={inbox.onOpenChange}
mentions={inbox.mentions}
status={inbox.status}
totalUnreadCount={inbox.totalUnreadCount}
markAsRead={inbox.markAsRead}
markAllAsRead={inbox.markAllAsRead}
isDocked={inbox.isDocked}
onDockedChange={inbox.onDockedChange}
/>
)}
<main className="flex-1 flex flex-col min-w-0"> <main className="flex-1 flex flex-col min-w-0">
<Header breadcrumb={breadcrumb} /> <Header breadcrumb={breadcrumb} />
@ -179,8 +252,24 @@ export function LayoutShell({
{children} {children}
</div> </div>
</main> </main>
{/* Floating Inbox Sidebar - positioned absolutely on top of content */}
{inbox && !inbox.isDocked && (
<InboxSidebar
open={inbox.isOpen}
onOpenChange={inbox.onOpenChange}
mentions={inbox.mentions}
status={inbox.status}
totalUnreadCount={inbox.totalUnreadCount}
markAsRead={inbox.markAsRead}
markAllAsRead={inbox.markAllAsRead}
isDocked={false}
onDockedChange={inbox.onDockedChange}
/>
)}
</div> </div>
</div> </div>
</TooltipProvider> </TooltipProvider>
</SidebarProvider>
); );
} }

View file

@ -7,6 +7,8 @@ import {
Check, Check,
CheckCheck, CheckCheck,
CheckCircle2, CheckCircle2,
ChevronLeft,
ChevronRight,
History, History,
Inbox, Inbox,
LayoutGrid, LayoutGrid,
@ -14,11 +16,12 @@ import {
Search, Search,
X, X,
} from "lucide-react"; } from "lucide-react";
import { useAtom } from "jotai";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { setCommentsCollapsedAtom, setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item"; import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -45,6 +48,11 @@ import { isConnectorIndexingMetadata, isNewMentionMetadata } from "@/contracts/t
import type { InboxItem } from "@/hooks/use-inbox"; import type { InboxItem } from "@/hooks/use-inbox";
import { useMediaQuery } from "@/hooks/use-media-query"; import { useMediaQuery } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useSidebarContextSafe } from "../../hooks";
// Sidebar width constants
const SIDEBAR_COLLAPSED_WIDTH = 60;
const SIDEBAR_EXPANDED_WIDTH = 240;
/** /**
* Get initials from name or email for avatar fallback * Get initials from name or email for avatar fallback
@ -104,6 +112,7 @@ function getConnectorTypeDisplayName(connectorType: string): string {
YOUTUBE_CONNECTOR: "YouTube", YOUTUBE_CONNECTOR: "YouTube",
CIRCLEBACK_CONNECTOR: "Circleback", CIRCLEBACK_CONNECTOR: "Circleback",
MCP_CONNECTOR: "MCP", MCP_CONNECTOR: "MCP",
OBSIDIAN_CONNECTOR: "Obsidian",
TAVILY_API: "Tavily", TAVILY_API: "Tavily",
SEARXNG_API: "SearXNG", SEARXNG_API: "SearXNG",
LINKUP_API: "Linkup", LINKUP_API: "Linkup",
@ -122,37 +131,55 @@ function getConnectorTypeDisplayName(connectorType: string): string {
type InboxTab = "mentions" | "status"; type InboxTab = "mentions" | "status";
type InboxFilter = "all" | "unread"; type InboxFilter = "all" | "unread";
interface InboxSidebarProps { // Tab-specific data source with independent pagination
open: boolean; interface TabDataSource {
onOpenChange: (open: boolean) => void; items: InboxItem[];
inboxItems: InboxItem[];
unreadCount: number; unreadCount: number;
loading: boolean; loading: boolean;
loadingMore?: boolean; loadingMore?: boolean;
hasMore?: boolean; hasMore?: boolean;
loadMore?: () => void; loadMore?: () => void;
}
interface InboxSidebarProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Mentions tab data source with independent pagination */
mentions: TabDataSource;
/** Status tab data source with independent pagination */
status: TabDataSource;
/** Combined unread count for mark all as read */
totalUnreadCount: number;
markAsRead: (id: number) => Promise<boolean>; markAsRead: (id: number) => Promise<boolean>;
markAllAsRead: () => Promise<boolean>; markAllAsRead: () => Promise<boolean>;
onCloseMobileSidebar?: () => void; onCloseMobileSidebar?: () => void;
/** Whether the inbox is docked (permanent) or floating */
isDocked?: boolean;
/** Callback to toggle docked state */
onDockedChange?: (docked: boolean) => void;
} }
export function InboxSidebar({ export function InboxSidebar({
open, open,
onOpenChange, onOpenChange,
inboxItems, mentions,
unreadCount, status,
loading, totalUnreadCount,
loadingMore = false,
hasMore = false,
loadMore,
markAsRead, markAsRead,
markAllAsRead, markAllAsRead,
onCloseMobileSidebar, onCloseMobileSidebar,
isDocked = false,
onDockedChange,
}: InboxSidebarProps) { }: InboxSidebarProps) {
const t = useTranslations("sidebar"); const t = useTranslations("sidebar");
const router = useRouter(); const router = useRouter();
const isMobile = !useMediaQuery("(min-width: 640px)"); const isMobile = !useMediaQuery("(min-width: 640px)");
// Comments collapsed state (desktop only, when docked)
const [, setCommentsCollapsed] = useAtom(setCommentsCollapsedAtom);
// Target comment for navigation - also ensures comments panel is visible
const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [activeTab, setActiveTab] = useState<InboxTab>("mentions"); const [activeTab, setActiveTab] = useState<InboxTab>("mentions");
const [activeFilter, setActiveFilter] = useState<InboxFilter>("all"); const [activeFilter, setActiveFilter] = useState<InboxFilter>("all");
@ -181,16 +208,18 @@ export function InboxSidebar({
return () => document.removeEventListener("keydown", handleEscape); return () => document.removeEventListener("keydown", handleEscape);
}, [open, onOpenChange]); }, [open, onOpenChange]);
// Only lock body scroll on mobile when inbox is open
useEffect(() => { useEffect(() => {
if (open) { if (!open || !isMobile) return;
// Store original overflow to restore on cleanup
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => { return () => {
document.body.style.overflow = ""; document.body.style.overflow = originalOverflow;
}; };
}, [open]); }, [open, isMobile]);
// Reset connector filter when switching away from status tab // Reset connector filter when switching away from status tab
useEffect(() => { useEffect(() => {
@ -199,19 +228,18 @@ export function InboxSidebar({
} }
}, [activeTab]); }, [activeTab]);
// Split items by type // Get current tab's data source - each tab has independent data and pagination
const mentionItems = useMemo( const currentDataSource = activeTab === "mentions" ? mentions : status;
() => inboxItems.filter((item) => item.type === "new_mention"), const { loading, loadingMore = false, hasMore = false, loadMore } = currentDataSource;
[inboxItems]
);
// Status tab includes: connector indexing, document processing // Status tab includes: connector indexing, document processing
// Filter to only show status notification types
const statusItems = useMemo( const statusItems = useMemo(
() => () =>
inboxItems.filter( status.items.filter(
(item) => item.type === "connector_indexing" || item.type === "document_processing" (item) => item.type === "connector_indexing" || item.type === "document_processing"
), ),
[inboxItems] [status.items]
); );
// Get unique connector types from status items for filtering // Get unique connector types from status items for filtering
@ -233,12 +261,12 @@ export function InboxSidebar({
})); }));
}, [statusItems]); }, [statusItems]);
// Get items for current tab // Get items for current tab - mentions use their source directly, status uses filtered items
const currentTabItems = activeTab === "mentions" ? mentionItems : statusItems; const displayItems = activeTab === "mentions" ? mentions.items : statusItems;
// Filter items based on filter type, connector filter, and search query // Filter items based on filter type, connector filter, and search query
const filteredItems = useMemo(() => { const filteredItems = useMemo(() => {
let items = currentTabItems; let items = displayItems;
// Apply read/unread filter // Apply read/unread filter
if (activeFilter === "unread") { if (activeFilter === "unread") {
@ -269,7 +297,7 @@ export function InboxSidebar({
} }
return items; return items;
}, [currentTabItems, activeFilter, activeTab, selectedConnector, searchQuery]); }, [displayItems, activeFilter, activeTab, selectedConnector, searchQuery]);
// Intersection Observer for infinite scroll with prefetching // Intersection Observer for infinite scroll with prefetching
// Only active when not searching (search results are client-side filtered) // Only active when not searching (search results are client-side filtered)
@ -295,16 +323,11 @@ export function InboxSidebar({
} }
return () => observer.disconnect(); return () => observer.disconnect();
}, [loadMore, hasMore, loadingMore, open, searchQuery, filteredItems.length]); }, [loadMore, hasMore, loadingMore, open, searchQuery]);
// Count unread items per tab // Use unread counts from data sources (more accurate than client-side counting)
const unreadMentionsCount = useMemo(() => { const unreadMentionsCount = mentions.unreadCount;
return mentionItems.filter((item) => !item.read).length; const unreadStatusCount = status.unreadCount;
}, [mentionItems]);
const unreadStatusCount = useMemo(() => {
return statusItems.filter((item) => !item.read).length;
}, [statusItems]);
const handleItemClick = useCallback( const handleItemClick = useCallback(
async (item: InboxItem) => { async (item: InboxItem) => {
@ -322,6 +345,12 @@ export function InboxSidebar({
const commentId = item.metadata.comment_id; const commentId = item.metadata.comment_id;
if (searchSpaceId && threadId) { if (searchSpaceId && threadId) {
// Pre-set target comment ID before navigation
// This also ensures comments panel is not collapsed
if (commentId) {
setTargetCommentId(commentId);
}
const url = commentId const url = commentId
? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}` ? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}`
: `/dashboard/${searchSpaceId}/new-chat/${threadId}`; : `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
@ -332,7 +361,7 @@ export function InboxSidebar({
} }
} }
}, },
[markAsRead, router, onOpenChange, onCloseMobileSidebar] [markAsRead, router, onOpenChange, onCloseMobileSidebar, setTargetCommentId]
); );
const handleMarkAllAsRead = useCallback(async () => { const handleMarkAllAsRead = useCallback(async () => {
@ -436,36 +465,21 @@ export function InboxSidebar({
}; };
}; };
// Get sidebar collapsed state from context (provided by LayoutShell)
const sidebarContext = useSidebarContextSafe();
const isCollapsed = sidebarContext?.isCollapsed ?? false;
// Calculate the left position for the inbox panel (relative to sidebar)
const sidebarWidth = isCollapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_EXPANDED_WIDTH;
if (!mounted) return null; if (!mounted) return null;
return createPortal( // Shared content component for both docked and floating modes
<AnimatePresence> const inboxContent = (
{open && (
<> <>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-70 bg-black/50"
onClick={() => onOpenChange(false)}
aria-hidden="true"
/>
<motion.div
initial={{ x: "-100%" }}
animate={{ x: 0 }}
exit={{ x: "-100%" }}
transition={{ type: "tween", duration: 0.3, ease: "easeOut" }}
className="fixed inset-y-0 left-0 z-70 w-90 bg-background shadow-xl flex flex-col pointer-events-auto isolate"
role="dialog"
aria-modal="true"
aria-label={t("inbox") || "Inbox"}
>
<div className="shrink-0 p-4 pb-2 space-y-3"> <div className="shrink-0 p-4 pb-2 space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Inbox className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">{t("inbox") || "Inbox"}</h2> <h2 className="text-lg font-semibold">{t("inbox") || "Inbox"}</h2>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@ -672,9 +686,7 @@ export function InboxSidebar({
{getConnectorIcon(connector.type, "h-4 w-4")} {getConnectorIcon(connector.type, "h-4 w-4")}
<span>{connector.displayName}</span> <span>{connector.displayName}</span>
</span> </span>
{selectedConnector === connector.type && ( {selectedConnector === connector.type && <Check className="h-4 w-4" />}
<Check className="h-4 w-4" />
)}
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
</> </>
@ -689,7 +701,7 @@ export function InboxSidebar({
size="icon" size="icon"
className="h-8 w-8 rounded-full" className="h-8 w-8 rounded-full"
onClick={handleMarkAllAsRead} onClick={handleMarkAllAsRead}
disabled={unreadCount === 0} disabled={totalUnreadCount === 0}
> >
<CheckCheck className="h-4 w-4 text-muted-foreground" /> <CheckCheck className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{t("mark_all_read") || "Mark all as read"}</span> <span className="sr-only">{t("mark_all_read") || "Mark all as read"}</span>
@ -699,6 +711,57 @@ export function InboxSidebar({
{t("mark_all_read") || "Mark all as read"} {t("mark_all_read") || "Mark all as read"}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
{/* Close button - mobile only */}
{isMobile && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => onOpenChange(false)}
>
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{t("close") || "Close"}</span>
</Button>
</TooltipTrigger>
<TooltipContent className="z-80">{t("close") || "Close"}</TooltipContent>
</Tooltip>
)}
{/* Dock/Undock button - desktop only */}
{!isMobile && onDockedChange && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => {
if (isDocked) {
// Collapse: show comments immediately, then close inbox
setCommentsCollapsed(false);
onDockedChange(false);
onOpenChange(false);
} else {
// Expand: hide comments immediately
setCommentsCollapsed(true);
onDockedChange(true);
}
}}
>
{isDocked ? (
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<span className="sr-only">{isDocked ? "Collapse panel" : "Expand panel"}</span>
</Button>
</TooltipTrigger>
<TooltipContent className="z-80">
{isDocked ? "Collapse panel" : "Expand panel"}
</TooltipContent>
</Tooltip>
)}
</div> </div>
</div> </div>
@ -819,9 +882,7 @@ export function InboxSidebar({
<span className="text-[10px] text-muted-foreground"> <span className="text-[10px] text-muted-foreground">
{formatTime(item.created_at)} {formatTime(item.created_at)}
</span> </span>
{!item.read && ( {!item.read && <span className="h-2 w-2 rounded-full bg-blue-500 shrink-0" />}
<span className="h-2 w-2 rounded-full bg-blue-500 shrink-0" />
)}
</div> </div>
</div> </div>
); );
@ -849,16 +910,70 @@ export function InboxSidebar({
<History className="h-12 w-12 mx-auto text-muted-foreground mb-3" /> <History className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
)} )}
<p className="text-sm text-muted-foreground">{getEmptyStateMessage().title}</p> <p className="text-sm text-muted-foreground">{getEmptyStateMessage().title}</p>
<p className="text-xs text-muted-foreground/70 mt-1"> <p className="text-xs text-muted-foreground/70 mt-1">{getEmptyStateMessage().hint}</p>
{getEmptyStateMessage().hint}
</p>
</div> </div>
)} )}
</div> </div>
</>
);
// DOCKED MODE: Render as a static flex child (no animation, no click-away)
if (isDocked && open && !isMobile) {
return (
<aside
className="h-full w-[360px] shrink-0 bg-background flex flex-col border-r"
aria-label={t("inbox") || "Inbox"}
>
{inboxContent}
</aside>
);
}
// FLOATING MODE: Render with animation and click-away layer
return (
<AnimatePresence>
{open && (
<>
{/* Click-away layer - only covers the content area, not the sidebar */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
style={{
left: isMobile ? 0 : sidebarWidth,
}}
className="absolute inset-y-0 right-0"
onClick={() => onOpenChange(false)}
aria-hidden="true"
/>
{/* Clip container - positioned at sidebar edge with overflow hidden */}
<div
style={{
left: isMobile ? 0 : sidebarWidth,
width: isMobile ? "100%" : 360,
}}
className={cn("absolute z-10 overflow-hidden pointer-events-none", "inset-y-0")}
>
<motion.div
initial={{ x: "-100%" }}
animate={{ x: 0 }}
exit={{ x: "-100%" }}
transition={{ type: "tween", duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
className={cn(
"h-full w-full bg-background flex flex-col pointer-events-auto",
"sm:border-r sm:shadow-xl"
)}
role="dialog"
aria-modal="true"
aria-label={t("inbox") || "Inbox"}
>
{inboxContent}
</motion.div> </motion.div>
</div>
</> </>
)} )}
</AnimatePresence>, </AnimatePresence>
document.body
); );
} }

View file

@ -149,16 +149,16 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN
return ( return (
<div <div
className={cn( className={cn(
"flex items-center gap-4 rounded-xl border border-destructive/20 bg-destructive/5 p-4", "flex items-center gap-3 sm:gap-4 rounded-xl border border-destructive/20 bg-destructive/5 p-3 sm:p-4",
className className
)} )}
> >
<div className="flex size-16 items-center justify-center rounded-lg bg-destructive/10"> <div className="flex size-12 sm:size-16 shrink-0 items-center justify-center rounded-lg bg-destructive/10">
<Volume2Icon className="size-8 text-destructive" /> <Volume2Icon className="size-6 sm:size-8 text-destructive" />
</div> </div>
<div className="flex-1"> <div className="flex-1 min-w-0">
<p className="font-medium text-destructive">{title}</p> <p className="font-medium text-destructive text-sm sm:text-base truncate">{title}</p>
<p className="text-destructive/70 text-sm">{error}</p> <p className="text-destructive/70 text-xs sm:text-sm">{error}</p>
</div> </div>
</div> </div>
); );
@ -168,7 +168,7 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN
<div <div
id={id} id={id}
className={cn( className={cn(
"group relative overflow-hidden rounded-xl border bg-gradient-to-br from-background to-muted/30 p-4 shadow-sm transition-all hover:shadow-md", "group relative overflow-hidden rounded-xl border bg-gradient-to-br from-background to-muted/30 p-3 sm:p-4 shadow-sm transition-all hover:shadow-md",
className className
)} )}
> >
@ -177,15 +177,15 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN
<track kind="captions" srcLang="en" label="English captions" default /> <track kind="captions" srcLang="en" label="English captions" default />
</audio> </audio>
<div className="flex gap-4"> <div className="flex gap-3 sm:gap-4">
{/* Artwork */} {/* Artwork */}
<div className="relative shrink-0"> <div className="relative shrink-0">
<div className="relative size-20 overflow-hidden rounded-lg bg-gradient-to-br from-primary/20 to-primary/5 shadow-inner"> <div className="relative size-14 sm:size-20 overflow-hidden rounded-lg bg-gradient-to-br from-primary/20 to-primary/5 shadow-inner">
{artwork ? ( {artwork ? (
<Image src={artwork} alt={title} fill className="object-cover" unoptimized /> <Image src={artwork} alt={title} fill className="object-cover" unoptimized />
) : ( ) : (
<div className="flex size-full items-center justify-center"> <div className="flex size-full items-center justify-center">
<Volume2Icon className="size-8 text-primary/50" /> <Volume2Icon className="size-6 sm:size-8 text-primary/50" />
</div> </div>
)} )}
</div> </div>
@ -195,14 +195,16 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN
<div className="flex min-w-0 flex-1 flex-col justify-between"> <div className="flex min-w-0 flex-1 flex-col justify-between">
{/* Title and description */} {/* Title and description */}
<div className="min-w-0"> <div className="min-w-0">
<h3 className="truncate font-semibold text-foreground">{title}</h3> <h3 className="truncate font-semibold text-foreground text-sm sm:text-base">{title}</h3>
{description && ( {description && (
<p className="mt-0.5 line-clamp-1 text-muted-foreground text-sm">{description}</p> <p className="mt-0.5 line-clamp-1 text-muted-foreground text-xs sm:text-sm">
{description}
</p>
)} )}
</div> </div>
{/* Progress bar */} {/* Progress bar */}
<div className="mt-2 space-y-1"> <div className="mt-1.5 sm:mt-2 space-y-0.5 sm:space-y-1">
<Slider <Slider
value={[currentTime]} value={[currentTime]}
max={duration || 100} max={duration || 100}
@ -211,7 +213,7 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN
className="cursor-pointer" className="cursor-pointer"
disabled={isLoading} disabled={isLoading}
/> />
<div className="flex justify-between text-muted-foreground text-xs"> <div className="flex justify-between text-muted-foreground text-[10px] sm:text-xs">
<span>{formatTime(currentTime)}</span> <span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span> <span>{formatTime(duration)}</span>
</div> </div>
@ -220,33 +222,37 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN
</div> </div>
{/* Controls */} {/* Controls */}
<div className="mt-3 flex items-center justify-between border-t pt-3"> <div className="mt-2 sm:mt-3 flex items-center justify-between border-t pt-2 sm:pt-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-1.5 sm:gap-2">
{/* Play/Pause button */} {/* Play/Pause button */}
<Button <Button
variant="default" variant="default"
size="sm" size="sm"
onClick={togglePlayPause} onClick={togglePlayPause}
disabled={isLoading} disabled={isLoading}
className="gap-2" className="gap-1.5 sm:gap-2 h-7 sm:h-8 px-2.5 sm:px-3 text-xs sm:text-sm"
> >
{isLoading ? ( {isLoading ? (
<div className="size-4 animate-spin rounded-full border-2 border-current border-t-transparent" /> <div className="size-3 sm:size-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : isPlaying ? ( ) : isPlaying ? (
<PauseIcon className="size-4" /> <PauseIcon className="size-3 sm:size-4" />
) : ( ) : (
<PlayIcon className="size-4" /> <PlayIcon className="size-3 sm:size-4" />
)} )}
{isPlaying ? "Pause" : "Play"} {isPlaying ? "Pause" : "Play"}
</Button> </Button>
{/* Volume control */} {/* Volume control */}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1 sm:gap-1.5">
<Button variant="ghost" size="icon" onClick={toggleMute} className="size-8"> <Button variant="ghost" size="icon" onClick={toggleMute} className="size-7 sm:size-8">
{isMuted ? <VolumeXIcon className="size-4" /> : <Volume2Icon className="size-4" />} {isMuted ? (
<VolumeXIcon className="size-3.5 sm:size-4" />
) : (
<Volume2Icon className="size-3.5 sm:size-4" />
)}
</Button> </Button>
{/* Custom volume bar - visually distinct from progress slider */} {/* Custom volume bar - visually distinct from progress slider */}
<div className="relative flex h-6 w-16 items-center"> <div className="relative flex h-6 w-12 sm:w-16 items-center">
<div className="relative h-1 w-full rounded-full bg-muted-foreground/20"> <div className="relative h-1 w-full rounded-full bg-muted-foreground/20">
<div <div
className="absolute left-0 top-0 h-full rounded-full bg-muted-foreground/60 transition-all" className="absolute left-0 top-0 h-full rounded-full bg-muted-foreground/60 transition-all"
@ -268,8 +274,13 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN
</div> </div>
{/* Download button */} {/* Download button */}
<Button variant="outline" size="sm" onClick={handleDownload} className="gap-2"> <Button
<DownloadIcon className="size-4" /> variant="outline"
size="sm"
onClick={handleDownload}
className="gap-1.5 sm:gap-2 h-7 sm:h-8 px-2.5 sm:px-3 text-xs sm:text-sm"
>
<DownloadIcon className="size-3 sm:size-4" />
Download Download
</Button> </Button>
</div> </div>

View file

@ -96,23 +96,27 @@ function parsePodcastDetails(data: unknown): { podcast_transcript?: PodcastTrans
*/ */
function PodcastGeneratingState({ title }: { title: string }) { function PodcastGeneratingState({ title }: { title: string }) {
return ( return (
<div className="my-4 overflow-hidden rounded-xl border border-primary/20 bg-gradient-to-br from-primary/5 to-primary/10 p-6"> <div className="my-4 overflow-hidden rounded-xl border border-primary/20 bg-gradient-to-br from-primary/5 to-primary/10 p-4 sm:p-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-3 sm:gap-4">
<div className="relative"> <div className="relative shrink-0">
<div className="flex size-16 items-center justify-center rounded-full bg-primary/20"> <div className="flex size-12 sm:size-16 items-center justify-center rounded-full bg-primary/20">
<MicIcon className="size-8 text-primary" /> <MicIcon className="size-6 sm:size-8 text-primary" />
</div> </div>
{/* Animated rings */} {/* Animated rings */}
<div className="absolute inset-1 animate-ping rounded-full bg-primary/20" /> <div className="absolute inset-1 animate-ping rounded-full bg-primary/20" />
</div> </div>
<div className="flex-1"> <div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground text-lg">{title}</h3> <h3 className="font-semibold text-foreground text-sm sm:text-lg leading-tight">
<div className="mt-2 flex items-center gap-2 text-muted-foreground"> {title}
<Spinner size="sm" /> </h3>
<span className="text-sm">Generating podcast. This may take a few minutes.</span> <div className="mt-1.5 sm:mt-2 flex items-center gap-1.5 sm:gap-2 text-muted-foreground">
<Spinner size="sm" className="size-3 sm:size-4" />
<span className="text-xs sm:text-sm">
Generating podcast. This may take a few minutes.
</span>
</div> </div>
<div className="mt-3"> <div className="mt-2 sm:mt-3">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-primary/10"> <div className="h-1 sm:h-1.5 w-full overflow-hidden rounded-full bg-primary/10">
<div className="h-full w-1/3 animate-pulse rounded-full bg-primary" /> <div className="h-full w-1/3 animate-pulse rounded-full bg-primary" />
</div> </div>
</div> </div>
@ -127,15 +131,17 @@ function PodcastGeneratingState({ title }: { title: string }) {
*/ */
function PodcastErrorState({ title, error }: { title: string; error: string }) { function PodcastErrorState({ title, error }: { title: string; error: string }) {
return ( return (
<div className="my-4 overflow-hidden rounded-xl border border-destructive/20 bg-destructive/5 p-6"> <div className="my-4 overflow-hidden rounded-xl border border-destructive/20 bg-destructive/5 p-4 sm:p-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-3 sm:gap-4">
<div className="flex size-16 shrink-0 items-center justify-center rounded-full bg-destructive/10"> <div className="flex size-12 sm:size-16 shrink-0 items-center justify-center rounded-full bg-destructive/10">
<AlertCircleIcon className="size-8 text-destructive" /> <AlertCircleIcon className="size-6 sm:size-8 text-destructive" />
</div> </div>
<div className="flex-1"> <div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground">{title}</h3> <h3 className="font-semibold text-foreground text-sm sm:text-base leading-tight">
<p className="mt-1 text-destructive text-sm">Failed to generate podcast</p> {title}
<p className="mt-2 text-muted-foreground text-sm">{error}</p> </h3>
<p className="mt-1 text-destructive text-xs sm:text-sm">Failed to generate podcast</p>
<p className="mt-1.5 sm:mt-2 text-muted-foreground text-xs sm:text-sm">{error}</p>
</div> </div>
</div> </div>
</div> </div>
@ -147,16 +153,18 @@ function PodcastErrorState({ title, error }: { title: string; error: string }) {
*/ */
function AudioLoadingState({ title }: { title: string }) { function AudioLoadingState({ title }: { title: string }) {
return ( return (
<div className="my-4 overflow-hidden rounded-xl border bg-muted/30 p-6"> <div className="my-4 overflow-hidden rounded-xl border bg-muted/30 p-4 sm:p-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-3 sm:gap-4">
<div className="flex size-16 items-center justify-center rounded-full bg-primary/10"> <div className="flex size-12 sm:size-16 shrink-0 items-center justify-center rounded-full bg-primary/10">
<MicIcon className="size-8 text-primary/50" /> <MicIcon className="size-6 sm:size-8 text-primary/50" />
</div> </div>
<div className="flex-1"> <div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground">{title}</h3> <h3 className="font-semibold text-foreground text-sm sm:text-base leading-tight">
<div className="mt-2 flex items-center gap-2 text-muted-foreground"> {title}
<Spinner size="sm" /> </h3>
<span className="text-sm">Loading audio...</span> <div className="mt-1.5 sm:mt-2 flex items-center gap-1.5 sm:gap-2 text-muted-foreground">
<Spinner size="sm" className="size-3 sm:size-4" />
<span className="text-xs sm:text-sm">Loading audio...</span>
</div> </div>
</div> </div>
</div> </div>
@ -274,13 +282,13 @@ function PodcastPlayer({
/> />
{/* Transcript section */} {/* Transcript section */}
{transcript && transcript.length > 0 && ( {transcript && transcript.length > 0 && (
<details className="mt-3 rounded-lg border bg-muted/30 p-3"> <details className="mt-2 sm:mt-3 rounded-lg border bg-muted/30 p-2.5 sm:p-3">
<summary className="cursor-pointer font-medium text-muted-foreground text-sm hover:text-foreground"> <summary className="cursor-pointer font-medium text-muted-foreground text-xs sm:text-sm hover:text-foreground">
View transcript ({transcript.length} entries) View transcript ({transcript.length} entries)
</summary> </summary>
<div className="mt-3 space-y-3 max-h-96 overflow-y-auto"> <div className="mt-2 sm:mt-3 space-y-2 sm:space-y-3 max-h-64 sm:max-h-96 overflow-y-auto">
{transcript.map((entry, idx) => ( {transcript.map((entry, idx) => (
<div key={`${idx}-${entry.speaker_id}`} className="text-sm"> <div key={`${idx}-${entry.speaker_id}`} className="text-xs sm:text-sm">
<span className="font-medium text-primary">Speaker {entry.speaker_id + 1}:</span>{" "} <span className="font-medium text-primary">Speaker {entry.speaker_id + 1}:</span>{" "}
<span className="text-muted-foreground">{entry.dialog}</span> <span className="text-muted-foreground">{entry.dialog}</span>
</div> </div>
@ -404,9 +412,9 @@ export const GeneratePodcastToolUI = makeAssistantToolUI<
if (status.type === "incomplete") { if (status.type === "incomplete") {
if (status.reason === "cancelled") { if (status.reason === "cancelled") {
return ( return (
<div className="my-4 rounded-xl border border-muted p-4 text-muted-foreground"> <div className="my-4 rounded-xl border border-muted p-3 sm:p-4 text-muted-foreground">
<p className="flex items-center gap-2"> <p className="flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm">
<MicIcon className="size-4" /> <MicIcon className="size-3.5 sm:size-4" />
<span className="line-through">Podcast generation cancelled</span> <span className="line-through">Podcast generation cancelled</span>
</p> </p>
</div> </div>
@ -437,16 +445,16 @@ export const GeneratePodcastToolUI = makeAssistantToolUI<
// (new: "generating", legacy: "already_generating") // (new: "generating", legacy: "already_generating")
if (result.status === "generating" || result.status === "already_generating") { if (result.status === "generating" || result.status === "already_generating") {
return ( return (
<div className="my-4 overflow-hidden rounded-xl border border-amber-500/20 bg-amber-500/5 p-4"> <div className="my-4 overflow-hidden rounded-xl border border-amber-500/20 bg-amber-500/5 p-3 sm:p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2.5 sm:gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-amber-500/20"> <div className="flex size-8 sm:size-10 shrink-0 items-center justify-center rounded-full bg-amber-500/20">
<MicIcon className="size-5 text-amber-500" /> <MicIcon className="size-4 sm:size-5 text-amber-500" />
</div> </div>
<div> <div className="min-w-0">
<p className="text-amber-600 dark:text-amber-400 text-sm font-medium"> <p className="text-amber-600 dark:text-amber-400 text-xs sm:text-sm font-medium">
Podcast already in progress Podcast already in progress
</p> </p>
<p className="text-muted-foreground text-xs mt-0.5"> <p className="text-muted-foreground text-[10px] sm:text-xs mt-0.5">
Please wait for the current podcast to complete. Please wait for the current podcast to complete.
</p> </p>
</div> </div>

View file

@ -318,9 +318,13 @@ export function useInbox(
try { try {
// STEP 1: Fetch server counts (total and recent) - guaranteed accurate // STEP 1: Fetch server counts (total and recent) - guaranteed accurate
console.log("[useInbox] Fetching unread count from server"); console.log(
"[useInbox] Fetching unread count from server",
typeFilter ? `for type: ${typeFilter}` : "for all types"
);
const serverCounts = await notificationsApiService.getUnreadCount( const serverCounts = await notificationsApiService.getUnreadCount(
searchSpaceId ?? undefined searchSpaceId ?? undefined,
typeFilter ?? undefined
); );
if (mounted) { if (mounted) {

View file

@ -2,6 +2,7 @@ import {
type GetNotificationsRequest, type GetNotificationsRequest,
type GetNotificationsResponse, type GetNotificationsResponse,
type GetUnreadCountResponse, type GetUnreadCountResponse,
type InboxItemTypeEnum,
getNotificationsRequest, getNotificationsRequest,
getNotificationsResponse, getNotificationsResponse,
getUnreadCountResponse, getUnreadCountResponse,
@ -92,12 +93,20 @@ class NotificationsApiService {
* Get unread notification count with split between total and recent * Get unread notification count with split between total and recent
* - total_unread: All unread notifications * - total_unread: All unread notifications
* - recent_unread: Unread within sync window (last 14 days) * - recent_unread: Unread within sync window (last 14 days)
* @param searchSpaceId - Optional search space ID to filter by
* @param type - Optional notification type to filter by (type-safe enum)
*/ */
getUnreadCount = async (searchSpaceId?: number): Promise<GetUnreadCountResponse> => { getUnreadCount = async (
searchSpaceId?: number,
type?: InboxItemTypeEnum
): Promise<GetUnreadCountResponse> => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (searchSpaceId !== undefined) { if (searchSpaceId !== undefined) {
params.append("search_space_id", String(searchSpaceId)); params.append("search_space_id", String(searchSpaceId));
} }
if (type) {
params.append("type", type);
}
const queryString = params.toString(); const queryString = params.toString();
return baseApiService.get( return baseApiService.get(

View file

@ -708,7 +708,8 @@
"all": "All", "all": "All",
"unread": "Unread", "unread": "Unread",
"connectors": "Connectors", "connectors": "Connectors",
"all_connectors": "All connectors" "all_connectors": "All connectors",
"close": "Close"
}, },
"errors": { "errors": {
"something_went_wrong": "Something went wrong", "something_went_wrong": "Something went wrong",

View file

@ -679,7 +679,7 @@
"system": "系统", "system": "系统",
"logout": "退出登录", "logout": "退出登录",
"inbox": "收件箱", "inbox": "收件箱",
"search_inbox": "搜索收件箱...", "search_inbox": "搜索收件箱",
"mark_all_read": "全部标记为已读", "mark_all_read": "全部标记为已读",
"mark_as_read": "标记为已读", "mark_as_read": "标记为已读",
"mentions": "提及", "mentions": "提及",
@ -693,7 +693,8 @@
"all": "全部", "all": "全部",
"unread": "未读", "unread": "未读",
"connectors": "连接器", "connectors": "连接器",
"all_connectors": "所有连接器" "all_connectors": "所有连接器",
"close": "关闭"
}, },
"errors": { "errors": {
"something_went_wrong": "出错了", "something_went_wrong": "出错了",