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 typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
@ -20,6 +21,9 @@ router = APIRouter(prefix="/notifications", tags=["notifications"])
# Must match frontend SYNC_WINDOW_DAYS in use-inbox.ts
SYNC_WINDOW_DAYS = 14
# Valid notification types - must match frontend InboxItemTypeEnum
NotificationType = Literal["connector_indexing", "document_processing", "new_mention"]
class NotificationResponse(BaseModel):
"""Response model for a single notification."""
@ -73,6 +77,9 @@ class UnreadCountResponse(BaseModel):
@router.get("/unread-count", response_model=UnreadCountResponse)
async def get_unread_count(
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),
session: AsyncSession = Depends(get_async_session),
) -> UnreadCountResponse:
@ -103,6 +110,10 @@ async def get_unread_count(
| (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_query = select(func.count(Notification.id)).where(*base_filter)
total_result = await session.execute(total_query)
@ -125,7 +136,7 @@ async def get_unread_count(
@router.get("", response_model=NotificationListResponse)
async def list_notifications(
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"
),
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 { toast } from "sonner";
import { z } from "zod";
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
import {
clearTargetCommentIdAtom,
currentThreadAtom,
setTargetCommentIdAtom,
} from "@/atoms/chat/current-thread.atom";
import {
type MentionedDocumentInfo,
mentionedDocumentIdsAtom,
@ -158,6 +162,8 @@ export default function NewChatPage() {
const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom);
const hydratePlanState = useSetAtom(hydratePlanStateAtom);
const setCurrentThreadState = useSetAtom(currentThreadAtom);
const setTargetCommentId = useSetAtom(setTargetCommentIdAtom);
const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom);
// Get current user for author info in shared chats
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)
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(() => {
if (!targetCommentId || isInitializing || messages.length === 0) return;
const tryScroll = () => {
const el = document.querySelector(`[data-comment-id="${targetCommentId}"]`);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
return true;
if (targetCommentIdParam && !isInitializing) {
const commentId = Number.parseInt(targetCommentIdParam, 10);
if (!Number.isNaN(commentId)) {
setTargetCommentId(commentId);
}
}
return false;
};
// Try immediately
if (tryScroll()) return;
// 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]);
// Cleanup on unmount or when navigating away
return () => clearTargetCommentId();
}, [targetCommentIdParam, isInitializing, setTargetCommentId, clearTargetCommentId]);
// Sync current thread state to atom
useEffect(() => {
setCurrentThreadState({
setCurrentThreadState((prev) => ({
...prev,
id: currentThread?.id ?? null,
visibility: currentThread?.visibility ?? null,
hasComments: currentThread?.has_comments ?? false,
addingCommentToMessageId: null,
publicShareEnabled: currentThread?.public_share_enabled ?? false,
publicShareToken: currentThread?.public_share_token ?? null,
});
}));
}, [currentThread, setCurrentThreadState]);
// Cancel ongoing request

View file

@ -17,6 +17,8 @@ interface CurrentThreadState {
visibility: ChatVisibility | null;
hasComments: boolean;
addingCommentToMessageId: number | null;
/** Whether the right-side comments panel is collapsed (desktop only) */
commentsCollapsed: boolean;
publicShareEnabled: boolean;
publicShareToken: string | null;
}
@ -26,6 +28,7 @@ const initialState: CurrentThreadState = {
visibility: null,
hasComments: false,
addingCommentToMessageId: null,
commentsCollapsed: false,
publicShareEnabled: false,
publicShareToken: null,
};
@ -38,6 +41,8 @@ export const commentsEnabledAtom = atom(
export const showCommentsGutterAtom = atom((get) => {
const thread = get(currentThreadAtom);
// Hide gutter if comments are collapsed
if (thread.commentsCollapsed) return false;
return (
thread.visibility === "SEARCH_SPACE" &&
(thread.hasComments || thread.addingCommentToMessageId !== null)
@ -59,3 +64,34 @@ export const setThreadVisibilityAtom = atom(null, (get, set, newVisibility: Chat
export const resetCurrentThreadAtom = atom(null, (_, set) => {
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,
useAssistantState,
} 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 type { FC } from "react";
import { useContext, useEffect, useRef, useState } from "react";
import { useContext, useEffect, useMemo, useRef, useState } from "react";
import {
addingCommentToMessageIdAtom,
clearTargetCommentIdAtom,
commentsCollapsedAtom,
commentsEnabledAtom,
targetCommentIdAtom,
} from "@/atoms/chat/current-thread.atom";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { BranchPicker } from "@/components/assistant-ui/branch-picker";
@ -102,6 +105,7 @@ export const AssistantMessage: FC = () => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const dbMessageId = parseMessageId(messageId);
const commentsEnabled = useAtomValue(commentsEnabledAtom);
const commentsCollapsed = useAtomValue(commentsCollapsedAtom);
const [addingCommentToMessageId, setAddingCommentToMessageId] = useAtom(
addingCommentToMessageIdAtom
);
@ -115,11 +119,23 @@ export const AssistantMessage: FC = () => {
const isLastMessage = useAssistantState(({ message }) => message?.isLast ?? false);
const isMessageStreaming = isThreadRunning && isLastMessage;
const { data: commentsData } = useComments({
const { data: commentsData, isSuccess: commentsLoaded } = useComments({
messageId: dbMessageId ?? 0,
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 hasComments = commentCount > 0;
const isAddingComment = dbMessageId !== null && addingCommentToMessageId === dbMessageId;
@ -144,6 +160,24 @@ export const AssistantMessage: FC = () => {
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;
// Determine sheet side based on screen size
@ -157,8 +191,8 @@ export const AssistantMessage: FC = () => {
>
<AssistantMessageInner />
{/* Desktop comment panel - only on lg screens and above */}
{searchSpaceId && commentsEnabled && !isMessageStreaming && (
{/* Desktop comment panel - only on lg screens and above, hidden when collapsed */}
{searchSpaceId && commentsEnabled && !isMessageStreaming && !commentsCollapsed && (
<div className="absolute left-full top-0 ml-4 hidden lg:block w-72">
<div
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} />
),
table: ({ className, ...props }) => (
<div className="aui-md-table-wrapper my-5 w-full overflow-x-auto">
<table
className={cn(
"aui-md-table my-5 w-full border-separate border-spacing-0 overflow-y-auto",
className
)}
className={cn("aui-md-table w-full min-w-max border-separate border-spacing-0", className)}
{...props}
/>
</div>
),
th: ({ className, children, ...props }) => (
<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 />
<AssistantIf condition={({ thread }) => !thread.isEmpty}>
<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";
import { useAtomValue, useSetAtom } from "jotai";
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 { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@ -76,10 +79,9 @@ function renderMentions(content: string): React.ReactNode {
const mentionPattern = /@\{([^}]+)\}/g;
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = mentionPattern.exec(content)) !== null) {
if (match.index > lastIndex) {
for (const match of content.matchAll(mentionPattern)) {
if (match.index !== undefined && match.index > lastIndex) {
parts.push(content.slice(lastIndex, match.index));
}
@ -90,7 +92,7 @@ function renderMentions(content: string): React.ReactNode {
</span>
);
lastIndex = match.index + match[0].length;
lastIndex = (match.index ?? 0) + match[0].length;
}
if (lastIndex < content.length) {
@ -113,6 +115,37 @@ export function CommentItem({
members = [],
membersLoading = false,
}: 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 =
comment.author?.displayName || comment.author?.email.split("@")[0] || "Unknown";
const email = comment.author?.email || "";
@ -122,7 +155,14 @@ export function CommentItem({
};
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">
{comment.author?.avatarUrl && (
<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 { SidebarProvider, useSidebarContext, useSidebarContextSafe } from "./SidebarContext";

View file

@ -32,7 +32,6 @@ import { CreateSearchSpaceDialog } from "../ui/dialogs";
import { LayoutShell } from "../ui/shell";
import { AllPrivateChatsSidebar } from "../ui/sidebar/AllPrivateChatsSidebar";
import { AllSharedChatsSidebar } from "../ui/sidebar/AllSharedChatsSidebar";
import { InboxSidebar } from "../ui/sidebar/InboxSidebar";
interface LayoutDataProviderProps {
searchSpaceId: string;
@ -100,23 +99,60 @@ export function LayoutDataProvider({
// Inbox sidebar state
const [isInboxSidebarOpen, setIsInboxSidebarOpen] = useState(false);
const [isInboxDocked, setIsInboxDocked] = useState(false);
// Search space dialog state
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;
// Mentions: Only fetch "new_mention" type notifications
const {
inboxItems,
unreadCount,
loading: inboxLoading,
loadingMore: inboxLoadingMore,
hasMore: inboxHasMore,
loadMore: inboxLoadMore,
markAsRead,
markAllAsRead,
inboxItems: mentionItems,
unreadCount: mentionUnreadCount,
loading: mentionLoading,
loadingMore: mentionLoadingMore,
hasMore: mentionHasMore,
loadMore: mentionLoadMore,
markAsRead: markMentionAsRead,
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);
// 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
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
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
icon: Inbox,
isActive: isInboxSidebarOpen,
badge: unreadCount > 0 ? formatInboxCount(unreadCount) : undefined,
badge: totalUnreadCount > 0 ? formatInboxCount(totalUnreadCount) : undefined,
},
{
title: "Documents",
@ -206,7 +242,7 @@ export function LayoutDataProvider({
isActive: pathname?.includes("/documents"),
},
],
[searchSpaceId, pathname, isInboxSidebarOpen, unreadCount]
[searchSpaceId, pathname, isInboxSidebarOpen, totalUnreadCount]
);
// Handlers
@ -298,9 +334,9 @@ export function LayoutDataProvider({
const handleNavItemClick = useCallback(
(item: NavItem) => {
// Handle inbox specially - open sidebar instead of navigating
// Handle inbox specially - toggle sidebar instead of navigating
if (item.url === "#inbox") {
setIsInboxSidebarOpen(true);
setIsInboxSidebarOpen((prev) => !prev);
return;
}
router.push(item.url);
@ -462,6 +498,32 @@ export function LayoutDataProvider({
theme={theme}
setTheme={setTheme}
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}
</LayoutShell>
@ -607,20 +669,6 @@ export function LayoutDataProvider({
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 */}
<CreateSearchSpaceDialog
open={isCreateSearchSpaceDialogOpen}

View file

@ -1,14 +1,43 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import { TooltipProvider } from "@/components/ui/tooltip";
import type { InboxItem } from "@/hooks/use-inbox";
import { useIsMobile } from "@/hooks/use-mobile";
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 { Header } from "../header";
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 {
searchSpaces: SearchSpace[];
@ -42,6 +71,8 @@ interface LayoutShellProps {
isChatPage?: boolean;
children: React.ReactNode;
className?: string;
// Inbox props
inbox?: InboxProps;
}
export function LayoutShell({
@ -76,14 +107,22 @@ export function LayoutShell({
isChatPage = false,
children,
className,
inbox,
}: LayoutShellProps) {
const isMobile = useIsMobile();
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
if (isMobile) {
return (
<SidebarProvider value={sidebarContextValue}>
<TooltipProvider delayDuration={0}>
<div className={cn("flex h-screen w-full flex-col bg-background", className)}>
<Header
@ -125,15 +164,33 @@ export function LayoutShell({
<main className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
{children}
</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>
</TooltipProvider>
</SidebarProvider>
);
}
// Desktop layout
return (
<SidebarProvider value={sidebarContextValue}>
<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">
<IconRail
searchSpaces={searchSpaces}
@ -145,7 +202,8 @@ export function LayoutShell({
/>
</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
searchSpace={searchSpace}
isCollapsed={isCollapsed}
@ -172,6 +230,21 @@ export function LayoutShell({
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">
<Header breadcrumb={breadcrumb} />
@ -179,8 +252,24 @@ export function LayoutShell({
{children}
</div>
</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>
</TooltipProvider>
</SidebarProvider>
);
}

View file

@ -7,6 +7,8 @@ import {
Check,
CheckCheck,
CheckCircle2,
ChevronLeft,
ChevronRight,
History,
Inbox,
LayoutGrid,
@ -14,11 +16,12 @@ import {
Search,
X,
} from "lucide-react";
import { useAtom } from "jotai";
import { AnimatePresence, motion } from "motion/react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
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 { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
@ -45,6 +48,11 @@ import { isConnectorIndexingMetadata, isNewMentionMetadata } from "@/contracts/t
import type { InboxItem } from "@/hooks/use-inbox";
import { useMediaQuery } from "@/hooks/use-media-query";
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
@ -104,6 +112,7 @@ function getConnectorTypeDisplayName(connectorType: string): string {
YOUTUBE_CONNECTOR: "YouTube",
CIRCLEBACK_CONNECTOR: "Circleback",
MCP_CONNECTOR: "MCP",
OBSIDIAN_CONNECTOR: "Obsidian",
TAVILY_API: "Tavily",
SEARXNG_API: "SearXNG",
LINKUP_API: "Linkup",
@ -122,37 +131,55 @@ function getConnectorTypeDisplayName(connectorType: string): string {
type InboxTab = "mentions" | "status";
type InboxFilter = "all" | "unread";
interface InboxSidebarProps {
open: boolean;
onOpenChange: (open: boolean) => void;
inboxItems: InboxItem[];
// Tab-specific data source with independent pagination
interface TabDataSource {
items: InboxItem[];
unreadCount: number;
loading: boolean;
loadingMore?: boolean;
hasMore?: boolean;
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>;
markAllAsRead: () => Promise<boolean>;
onCloseMobileSidebar?: () => void;
/** Whether the inbox is docked (permanent) or floating */
isDocked?: boolean;
/** Callback to toggle docked state */
onDockedChange?: (docked: boolean) => void;
}
export function InboxSidebar({
open,
onOpenChange,
inboxItems,
unreadCount,
loading,
loadingMore = false,
hasMore = false,
loadMore,
mentions,
status,
totalUnreadCount,
markAsRead,
markAllAsRead,
onCloseMobileSidebar,
isDocked = false,
onDockedChange,
}: InboxSidebarProps) {
const t = useTranslations("sidebar");
const router = useRouter();
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 [activeTab, setActiveTab] = useState<InboxTab>("mentions");
const [activeFilter, setActiveFilter] = useState<InboxFilter>("all");
@ -181,16 +208,18 @@ export function InboxSidebar({
return () => document.removeEventListener("keydown", handleEscape);
}, [open, onOpenChange]);
// Only lock body scroll on mobile when inbox is open
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";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
document.body.style.overflow = originalOverflow;
};
}, [open]);
}, [open, isMobile]);
// Reset connector filter when switching away from status tab
useEffect(() => {
@ -199,19 +228,18 @@ export function InboxSidebar({
}
}, [activeTab]);
// Split items by type
const mentionItems = useMemo(
() => inboxItems.filter((item) => item.type === "new_mention"),
[inboxItems]
);
// Get current tab's data source - each tab has independent data and pagination
const currentDataSource = activeTab === "mentions" ? mentions : status;
const { loading, loadingMore = false, hasMore = false, loadMore } = currentDataSource;
// Status tab includes: connector indexing, document processing
// Filter to only show status notification types
const statusItems = useMemo(
() =>
inboxItems.filter(
status.items.filter(
(item) => item.type === "connector_indexing" || item.type === "document_processing"
),
[inboxItems]
[status.items]
);
// Get unique connector types from status items for filtering
@ -233,12 +261,12 @@ export function InboxSidebar({
}));
}, [statusItems]);
// Get items for current tab
const currentTabItems = activeTab === "mentions" ? mentionItems : statusItems;
// Get items for current tab - mentions use their source directly, status uses filtered items
const displayItems = activeTab === "mentions" ? mentions.items : statusItems;
// Filter items based on filter type, connector filter, and search query
const filteredItems = useMemo(() => {
let items = currentTabItems;
let items = displayItems;
// Apply read/unread filter
if (activeFilter === "unread") {
@ -269,7 +297,7 @@ export function InboxSidebar({
}
return items;
}, [currentTabItems, activeFilter, activeTab, selectedConnector, searchQuery]);
}, [displayItems, activeFilter, activeTab, selectedConnector, searchQuery]);
// Intersection Observer for infinite scroll with prefetching
// Only active when not searching (search results are client-side filtered)
@ -295,16 +323,11 @@ export function InboxSidebar({
}
return () => observer.disconnect();
}, [loadMore, hasMore, loadingMore, open, searchQuery, filteredItems.length]);
}, [loadMore, hasMore, loadingMore, open, searchQuery]);
// Count unread items per tab
const unreadMentionsCount = useMemo(() => {
return mentionItems.filter((item) => !item.read).length;
}, [mentionItems]);
const unreadStatusCount = useMemo(() => {
return statusItems.filter((item) => !item.read).length;
}, [statusItems]);
// Use unread counts from data sources (more accurate than client-side counting)
const unreadMentionsCount = mentions.unreadCount;
const unreadStatusCount = status.unreadCount;
const handleItemClick = useCallback(
async (item: InboxItem) => {
@ -322,6 +345,12 @@ export function InboxSidebar({
const commentId = item.metadata.comment_id;
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
? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}`
: `/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 () => {
@ -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;
return createPortal(
<AnimatePresence>
{open && (
// Shared content component for both docked and floating modes
const inboxContent = (
<>
<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="flex items-center justify-between">
<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>
</div>
<div className="flex items-center gap-1">
@ -672,9 +686,7 @@ export function InboxSidebar({
{getConnectorIcon(connector.type, "h-4 w-4")}
<span>{connector.displayName}</span>
</span>
{selectedConnector === connector.type && (
<Check className="h-4 w-4" />
)}
{selectedConnector === connector.type && <Check className="h-4 w-4" />}
</DropdownMenuItem>
))}
</>
@ -689,7 +701,7 @@ export function InboxSidebar({
size="icon"
className="h-8 w-8 rounded-full"
onClick={handleMarkAllAsRead}
disabled={unreadCount === 0}
disabled={totalUnreadCount === 0}
>
<CheckCheck className="h-4 w-4 text-muted-foreground" />
<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"}
</TooltipContent>
</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>
@ -819,9 +882,7 @@ export function InboxSidebar({
<span className="text-[10px] text-muted-foreground">
{formatTime(item.created_at)}
</span>
{!item.read && (
<span className="h-2 w-2 rounded-full bg-blue-500 shrink-0" />
)}
{!item.read && <span className="h-2 w-2 rounded-full bg-blue-500 shrink-0" />}
</div>
</div>
);
@ -849,16 +910,70 @@ export function InboxSidebar({
<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-xs text-muted-foreground/70 mt-1">
{getEmptyStateMessage().hint}
</p>
<p className="text-xs text-muted-foreground/70 mt-1">{getEmptyStateMessage().hint}</p>
</div>
)}
</div>
</motion.div>
</>
)}
</AnimatePresence>,
document.body
);
// 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>
</div>
</>
)}
</AnimatePresence>
);
}

View file

@ -149,16 +149,16 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN
return (
<div
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
)}
>
<div className="flex size-16 items-center justify-center rounded-lg bg-destructive/10">
<Volume2Icon className="size-8 text-destructive" />
<div className="flex size-12 sm:size-16 shrink-0 items-center justify-center rounded-lg bg-destructive/10">
<Volume2Icon className="size-6 sm:size-8 text-destructive" />
</div>
<div className="flex-1">
<p className="font-medium text-destructive">{title}</p>
<p className="text-destructive/70 text-sm">{error}</p>
<div className="flex-1 min-w-0">
<p className="font-medium text-destructive text-sm sm:text-base truncate">{title}</p>
<p className="text-destructive/70 text-xs sm:text-sm">{error}</p>
</div>
</div>
);
@ -168,7 +168,7 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN
<div
id={id}
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
)}
>
@ -177,15 +177,15 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN
<track kind="captions" srcLang="en" label="English captions" default />
</audio>
<div className="flex gap-4">
<div className="flex gap-3 sm:gap-4">
{/* Artwork */}
<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 ? (
<Image src={artwork} alt={title} fill className="object-cover" unoptimized />
) : (
<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>
@ -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">
{/* Title and description */}
<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 && (
<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>
{/* 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
value={[currentTime]}
max={duration || 100}
@ -211,7 +213,7 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN
className="cursor-pointer"
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(duration)}</span>
</div>
@ -220,33 +222,37 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN
</div>
{/* Controls */}
<div className="mt-3 flex items-center justify-between border-t pt-3">
<div className="flex items-center gap-2">
<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-1.5 sm:gap-2">
{/* Play/Pause button */}
<Button
variant="default"
size="sm"
onClick={togglePlayPause}
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 ? (
<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 ? (
<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"}
</Button>
{/* Volume control */}
<div className="flex items-center gap-1.5">
<Button variant="ghost" size="icon" onClick={toggleMute} className="size-8">
{isMuted ? <VolumeXIcon className="size-4" /> : <Volume2Icon className="size-4" />}
<div className="flex items-center gap-1 sm:gap-1.5">
<Button variant="ghost" size="icon" onClick={toggleMute} className="size-7 sm:size-8">
{isMuted ? (
<VolumeXIcon className="size-3.5 sm:size-4" />
) : (
<Volume2Icon className="size-3.5 sm:size-4" />
)}
</Button>
{/* 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="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>
{/* Download button */}
<Button variant="outline" size="sm" onClick={handleDownload} className="gap-2">
<DownloadIcon className="size-4" />
<Button
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
</Button>
</div>

View file

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

View file

@ -318,9 +318,13 @@ export function useInbox(
try {
// 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(
searchSpaceId ?? undefined
searchSpaceId ?? undefined,
typeFilter ?? undefined
);
if (mounted) {

View file

@ -2,6 +2,7 @@ import {
type GetNotificationsRequest,
type GetNotificationsResponse,
type GetUnreadCountResponse,
type InboxItemTypeEnum,
getNotificationsRequest,
getNotificationsResponse,
getUnreadCountResponse,
@ -92,12 +93,20 @@ class NotificationsApiService {
* Get unread notification count with split between total and recent
* - total_unread: All unread notifications
* - 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();
if (searchSpaceId !== undefined) {
params.append("search_space_id", String(searchSpaceId));
}
if (type) {
params.append("type", type);
}
const queryString = params.toString();
return baseApiService.get(

View file

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

View file

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