mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-31 19:45:15 +02:00
Merge pull request #747 from AnishSarkar22/feat/inbox
feat: Move inbox to a new area
This commit is contained in:
commit
8b0b4a2c4d
18 changed files with 1106 additions and 670 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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`}
|
||||
|
|
|
|||
|
|
@ -252,13 +252,12 @@ const defaultComponents = memoizeMarkdownComponents({
|
|||
<hr className={cn("aui-md-hr my-5 border-b", className)} {...props} />
|
||||
),
|
||||
table: ({ className, ...props }) => (
|
||||
<table
|
||||
className={cn(
|
||||
"aui-md-table my-5 w-full border-separate border-spacing-0 overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<div className="aui-md-table-wrapper my-5 w-full overflow-x-auto">
|
||||
<table
|
||||
className={cn("aui-md-table w-full min-w-max border-separate border-spacing-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
th: ({ className, children, ...props }) => (
|
||||
<th
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
36
surfsense_web/components/layout/hooks/SidebarContext.tsx
Normal file
36
surfsense_web/components/layout/hooks/SidebarContext.tsx
Normal 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);
|
||||
}
|
||||
|
|
@ -1 +1,2 @@
|
|||
export { useSidebarState } from "./useSidebarState";
|
||||
export { SidebarProvider, useSidebarContext, useSidebarContextSafe } from "./SidebarContext";
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,111 +107,169 @@ 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 (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div className={cn("flex h-screen w-full flex-col bg-background", className)}>
|
||||
<Header
|
||||
breadcrumb={breadcrumb}
|
||||
mobileMenuTrigger={<MobileSidebarTrigger onClick={() => setMobileMenuOpen(true)} />}
|
||||
/>
|
||||
<SidebarProvider value={sidebarContextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div className={cn("flex h-screen w-full flex-col bg-background", className)}>
|
||||
<Header
|
||||
breadcrumb={breadcrumb}
|
||||
mobileMenuTrigger={<MobileSidebarTrigger onClick={() => setMobileMenuOpen(true)} />}
|
||||
/>
|
||||
|
||||
<MobileSidebar
|
||||
isOpen={mobileMenuOpen}
|
||||
onOpenChange={setMobileMenuOpen}
|
||||
searchSpaces={searchSpaces}
|
||||
activeSearchSpaceId={activeSearchSpaceId}
|
||||
onSearchSpaceSelect={onSearchSpaceSelect}
|
||||
onSearchSpaceDelete={onSearchSpaceDelete}
|
||||
onSearchSpaceSettings={onSearchSpaceSettings}
|
||||
onAddSearchSpace={onAddSearchSpace}
|
||||
searchSpace={searchSpace}
|
||||
navItems={navItems}
|
||||
onNavItemClick={onNavItemClick}
|
||||
chats={chats}
|
||||
sharedChats={sharedChats}
|
||||
activeChatId={activeChatId}
|
||||
onNewChat={onNewChat}
|
||||
onChatSelect={onChatSelect}
|
||||
onChatDelete={onChatDelete}
|
||||
onChatArchive={onChatArchive}
|
||||
onViewAllSharedChats={onViewAllSharedChats}
|
||||
onViewAllPrivateChats={onViewAllPrivateChats}
|
||||
user={user}
|
||||
onSettings={onSettings}
|
||||
onManageMembers={onManageMembers}
|
||||
onUserSettings={onUserSettings}
|
||||
onLogout={onLogout}
|
||||
pageUsage={pageUsage}
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
/>
|
||||
<MobileSidebar
|
||||
isOpen={mobileMenuOpen}
|
||||
onOpenChange={setMobileMenuOpen}
|
||||
searchSpaces={searchSpaces}
|
||||
activeSearchSpaceId={activeSearchSpaceId}
|
||||
onSearchSpaceSelect={onSearchSpaceSelect}
|
||||
onSearchSpaceDelete={onSearchSpaceDelete}
|
||||
onSearchSpaceSettings={onSearchSpaceSettings}
|
||||
onAddSearchSpace={onAddSearchSpace}
|
||||
searchSpace={searchSpace}
|
||||
navItems={navItems}
|
||||
onNavItemClick={onNavItemClick}
|
||||
chats={chats}
|
||||
sharedChats={sharedChats}
|
||||
activeChatId={activeChatId}
|
||||
onNewChat={onNewChat}
|
||||
onChatSelect={onChatSelect}
|
||||
onChatDelete={onChatDelete}
|
||||
onChatArchive={onChatArchive}
|
||||
onViewAllSharedChats={onViewAllSharedChats}
|
||||
onViewAllPrivateChats={onViewAllPrivateChats}
|
||||
user={user}
|
||||
onSettings={onSettings}
|
||||
onManageMembers={onManageMembers}
|
||||
onUserSettings={onUserSettings}
|
||||
onLogout={onLogout}
|
||||
pageUsage={pageUsage}
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
/>
|
||||
|
||||
<main className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<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 (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<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}
|
||||
activeSearchSpaceId={activeSearchSpaceId}
|
||||
onSearchSpaceSelect={onSearchSpaceSelect}
|
||||
onSearchSpaceDelete={onSearchSpaceDelete}
|
||||
onSearchSpaceSettings={onSearchSpaceSettings}
|
||||
onAddSearchSpace={onAddSearchSpace}
|
||||
/>
|
||||
<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="hidden md:flex overflow-hidden">
|
||||
<IconRail
|
||||
searchSpaces={searchSpaces}
|
||||
activeSearchSpaceId={activeSearchSpaceId}
|
||||
onSearchSpaceSelect={onSearchSpaceSelect}
|
||||
onSearchSpaceDelete={onSearchSpaceDelete}
|
||||
onSearchSpaceSettings={onSearchSpaceSettings}
|
||||
onAddSearchSpace={onAddSearchSpace}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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}
|
||||
onToggleCollapse={toggleCollapsed}
|
||||
navItems={navItems}
|
||||
onNavItemClick={onNavItemClick}
|
||||
chats={chats}
|
||||
sharedChats={sharedChats}
|
||||
activeChatId={activeChatId}
|
||||
onNewChat={onNewChat}
|
||||
onChatSelect={onChatSelect}
|
||||
onChatDelete={onChatDelete}
|
||||
onChatArchive={onChatArchive}
|
||||
onViewAllSharedChats={onViewAllSharedChats}
|
||||
onViewAllPrivateChats={onViewAllPrivateChats}
|
||||
user={user}
|
||||
onSettings={onSettings}
|
||||
onManageMembers={onManageMembers}
|
||||
onUserSettings={onUserSettings}
|
||||
onLogout={onLogout}
|
||||
pageUsage={pageUsage}
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
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} />
|
||||
|
||||
<div className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
|
||||
{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>
|
||||
|
||||
<div className="flex flex-1 rounded-xl border bg-background overflow-hidden">
|
||||
<Sidebar
|
||||
searchSpace={searchSpace}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggleCollapse={toggleCollapsed}
|
||||
navItems={navItems}
|
||||
onNavItemClick={onNavItemClick}
|
||||
chats={chats}
|
||||
sharedChats={sharedChats}
|
||||
activeChatId={activeChatId}
|
||||
onNewChat={onNewChat}
|
||||
onChatSelect={onChatSelect}
|
||||
onChatDelete={onChatDelete}
|
||||
onChatArchive={onChatArchive}
|
||||
onViewAllSharedChats={onViewAllSharedChats}
|
||||
onViewAllPrivateChats={onViewAllPrivateChats}
|
||||
user={user}
|
||||
onSettings={onSettings}
|
||||
onManageMembers={onManageMembers}
|
||||
onUserSettings={onUserSettings}
|
||||
onLogout={onLogout}
|
||||
pageUsage={pageUsage}
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
className="hidden md:flex border-r shrink-0"
|
||||
/>
|
||||
|
||||
<main className="flex-1 flex flex-col min-w-0">
|
||||
<Header breadcrumb={breadcrumb} />
|
||||
|
||||
<div className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</TooltipProvider>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "出错了",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue