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 datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
@ -20,6 +21,9 @@ router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||||
# Must match frontend SYNC_WINDOW_DAYS in use-inbox.ts
|
# Must match frontend SYNC_WINDOW_DAYS in use-inbox.ts
|
||||||
SYNC_WINDOW_DAYS = 14
|
SYNC_WINDOW_DAYS = 14
|
||||||
|
|
||||||
|
# Valid notification types - must match frontend InboxItemTypeEnum
|
||||||
|
NotificationType = Literal["connector_indexing", "document_processing", "new_mention"]
|
||||||
|
|
||||||
|
|
||||||
class NotificationResponse(BaseModel):
|
class NotificationResponse(BaseModel):
|
||||||
"""Response model for a single notification."""
|
"""Response model for a single notification."""
|
||||||
|
|
@ -73,6 +77,9 @@ class UnreadCountResponse(BaseModel):
|
||||||
@router.get("/unread-count", response_model=UnreadCountResponse)
|
@router.get("/unread-count", response_model=UnreadCountResponse)
|
||||||
async def get_unread_count(
|
async def get_unread_count(
|
||||||
search_space_id: int | None = Query(None, description="Filter by search space ID"),
|
search_space_id: int | None = Query(None, description="Filter by search space ID"),
|
||||||
|
type_filter: NotificationType | None = Query(
|
||||||
|
None, alias="type", description="Filter by notification type"
|
||||||
|
),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
) -> UnreadCountResponse:
|
) -> UnreadCountResponse:
|
||||||
|
|
@ -103,6 +110,10 @@ async def get_unread_count(
|
||||||
| (Notification.search_space_id.is_(None))
|
| (Notification.search_space_id.is_(None))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Filter by notification type if provided
|
||||||
|
if type_filter:
|
||||||
|
base_filter.append(Notification.type == type_filter)
|
||||||
|
|
||||||
# Total unread count (all time)
|
# Total unread count (all time)
|
||||||
total_query = select(func.count(Notification.id)).where(*base_filter)
|
total_query = select(func.count(Notification.id)).where(*base_filter)
|
||||||
total_result = await session.execute(total_query)
|
total_result = await session.execute(total_query)
|
||||||
|
|
@ -125,7 +136,7 @@ async def get_unread_count(
|
||||||
@router.get("", response_model=NotificationListResponse)
|
@router.get("", response_model=NotificationListResponse)
|
||||||
async def list_notifications(
|
async def list_notifications(
|
||||||
search_space_id: int | None = Query(None, description="Filter by search space ID"),
|
search_space_id: int | None = Query(None, description="Filter by search space ID"),
|
||||||
type_filter: str | None = Query(
|
type_filter: NotificationType | None = Query(
|
||||||
None, alias="type", description="Filter by notification type"
|
None, alias="type", description="Filter by notification type"
|
||||||
),
|
),
|
||||||
before_date: str | None = Query(
|
before_date: str | None = Query(
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,11 @@ import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
import {
|
||||||
|
clearTargetCommentIdAtom,
|
||||||
|
currentThreadAtom,
|
||||||
|
setTargetCommentIdAtom,
|
||||||
|
} from "@/atoms/chat/current-thread.atom";
|
||||||
import {
|
import {
|
||||||
type MentionedDocumentInfo,
|
type MentionedDocumentInfo,
|
||||||
mentionedDocumentIdsAtom,
|
mentionedDocumentIdsAtom,
|
||||||
|
|
@ -158,6 +162,8 @@ export default function NewChatPage() {
|
||||||
const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom);
|
const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom);
|
||||||
const hydratePlanState = useSetAtom(hydratePlanStateAtom);
|
const hydratePlanState = useSetAtom(hydratePlanStateAtom);
|
||||||
const setCurrentThreadState = useSetAtom(currentThreadAtom);
|
const setCurrentThreadState = useSetAtom(currentThreadAtom);
|
||||||
|
const setTargetCommentId = useSetAtom(setTargetCommentIdAtom);
|
||||||
|
const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom);
|
||||||
|
|
||||||
// Get current user for author info in shared chats
|
// Get current user for author info in shared chats
|
||||||
const { data: currentUser } = useAtomValue(currentUserAtom);
|
const { data: currentUser } = useAtomValue(currentUserAtom);
|
||||||
|
|
@ -355,46 +361,33 @@ export default function NewChatPage() {
|
||||||
|
|
||||||
// Handle scroll to comment from URL query params (e.g., from inbox item click)
|
// Handle scroll to comment from URL query params (e.g., from inbox item click)
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const targetCommentId = searchParams.get("commentId");
|
const targetCommentIdParam = searchParams.get("commentId");
|
||||||
|
|
||||||
|
// Set target comment ID from URL param - the AssistantMessage and CommentItem
|
||||||
|
// components will handle scrolling and highlighting once comments are loaded
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!targetCommentId || isInitializing || messages.length === 0) return;
|
if (targetCommentIdParam && !isInitializing) {
|
||||||
|
const commentId = Number.parseInt(targetCommentIdParam, 10);
|
||||||
const tryScroll = () => {
|
if (!Number.isNaN(commentId)) {
|
||||||
const el = document.querySelector(`[data-comment-id="${targetCommentId}"]`);
|
setTargetCommentId(commentId);
|
||||||
if (el) {
|
}
|
||||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Try immediately
|
// Cleanup on unmount or when navigating away
|
||||||
if (tryScroll()) return;
|
return () => clearTargetCommentId();
|
||||||
|
}, [targetCommentIdParam, isInitializing, setTargetCommentId, clearTargetCommentId]);
|
||||||
// Retry every 200ms for up to 10 seconds
|
|
||||||
const intervalId = setInterval(() => {
|
|
||||||
if (tryScroll()) clearInterval(intervalId);
|
|
||||||
}, 200);
|
|
||||||
|
|
||||||
const timeoutId = setTimeout(() => clearInterval(intervalId), 10000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
};
|
|
||||||
}, [targetCommentId, isInitializing, messages.length]);
|
|
||||||
|
|
||||||
// Sync current thread state to atom
|
// Sync current thread state to atom
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentThreadState({
|
setCurrentThreadState((prev) => ({
|
||||||
|
...prev,
|
||||||
id: currentThread?.id ?? null,
|
id: currentThread?.id ?? null,
|
||||||
visibility: currentThread?.visibility ?? null,
|
visibility: currentThread?.visibility ?? null,
|
||||||
hasComments: currentThread?.has_comments ?? false,
|
hasComments: currentThread?.has_comments ?? false,
|
||||||
addingCommentToMessageId: null,
|
addingCommentToMessageId: null,
|
||||||
publicShareEnabled: currentThread?.public_share_enabled ?? false,
|
publicShareEnabled: currentThread?.public_share_enabled ?? false,
|
||||||
publicShareToken: currentThread?.public_share_token ?? null,
|
publicShareToken: currentThread?.public_share_token ?? null,
|
||||||
});
|
}));
|
||||||
}, [currentThread, setCurrentThreadState]);
|
}, [currentThread, setCurrentThreadState]);
|
||||||
|
|
||||||
// Cancel ongoing request
|
// Cancel ongoing request
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ interface CurrentThreadState {
|
||||||
visibility: ChatVisibility | null;
|
visibility: ChatVisibility | null;
|
||||||
hasComments: boolean;
|
hasComments: boolean;
|
||||||
addingCommentToMessageId: number | null;
|
addingCommentToMessageId: number | null;
|
||||||
|
/** Whether the right-side comments panel is collapsed (desktop only) */
|
||||||
|
commentsCollapsed: boolean;
|
||||||
publicShareEnabled: boolean;
|
publicShareEnabled: boolean;
|
||||||
publicShareToken: string | null;
|
publicShareToken: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -26,6 +28,7 @@ const initialState: CurrentThreadState = {
|
||||||
visibility: null,
|
visibility: null,
|
||||||
hasComments: false,
|
hasComments: false,
|
||||||
addingCommentToMessageId: null,
|
addingCommentToMessageId: null,
|
||||||
|
commentsCollapsed: false,
|
||||||
publicShareEnabled: false,
|
publicShareEnabled: false,
|
||||||
publicShareToken: null,
|
publicShareToken: null,
|
||||||
};
|
};
|
||||||
|
|
@ -38,6 +41,8 @@ export const commentsEnabledAtom = atom(
|
||||||
|
|
||||||
export const showCommentsGutterAtom = atom((get) => {
|
export const showCommentsGutterAtom = atom((get) => {
|
||||||
const thread = get(currentThreadAtom);
|
const thread = get(currentThreadAtom);
|
||||||
|
// Hide gutter if comments are collapsed
|
||||||
|
if (thread.commentsCollapsed) return false;
|
||||||
return (
|
return (
|
||||||
thread.visibility === "SEARCH_SPACE" &&
|
thread.visibility === "SEARCH_SPACE" &&
|
||||||
(thread.hasComments || thread.addingCommentToMessageId !== null)
|
(thread.hasComments || thread.addingCommentToMessageId !== null)
|
||||||
|
|
@ -59,3 +64,34 @@ export const setThreadVisibilityAtom = atom(null, (get, set, newVisibility: Chat
|
||||||
export const resetCurrentThreadAtom = atom(null, (_, set) => {
|
export const resetCurrentThreadAtom = atom(null, (_, set) => {
|
||||||
set(currentThreadAtom, initialState);
|
set(currentThreadAtom, initialState);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Atom to read whether comments panel is collapsed */
|
||||||
|
export const commentsCollapsedAtom = atom((get) => get(currentThreadAtom).commentsCollapsed);
|
||||||
|
|
||||||
|
/** Atom to toggle the comments collapsed state */
|
||||||
|
export const toggleCommentsCollapsedAtom = atom(null, (get, set) => {
|
||||||
|
const current = get(currentThreadAtom);
|
||||||
|
set(currentThreadAtom, { ...current, commentsCollapsed: !current.commentsCollapsed });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Atom to explicitly set the comments collapsed state */
|
||||||
|
export const setCommentsCollapsedAtom = atom(null, (get, set, collapsed: boolean) => {
|
||||||
|
set(currentThreadAtom, { ...get(currentThreadAtom), commentsCollapsed: collapsed });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Target comment ID to scroll to (from URL navigation or inbox click) */
|
||||||
|
export const targetCommentIdAtom = atom<number | null>(null);
|
||||||
|
|
||||||
|
/** Setter for target comment ID - also ensures comments are not collapsed */
|
||||||
|
export const setTargetCommentIdAtom = atom(null, (get, set, commentId: number | null) => {
|
||||||
|
// Ensure comments are not collapsed when navigating to a comment
|
||||||
|
if (commentId !== null) {
|
||||||
|
set(currentThreadAtom, { ...get(currentThreadAtom), commentsCollapsed: false });
|
||||||
|
}
|
||||||
|
set(targetCommentIdAtom, commentId);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Clear target after navigation completes */
|
||||||
|
export const clearTargetCommentIdAtom = atom(null, (_, set) => {
|
||||||
|
set(targetCommentIdAtom, null);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,16 @@ import {
|
||||||
MessagePrimitive,
|
MessagePrimitive,
|
||||||
useAssistantState,
|
useAssistantState,
|
||||||
} from "@assistant-ui/react";
|
} from "@assistant-ui/react";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||||
import { CheckIcon, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react";
|
import { CheckIcon, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useContext, useEffect, useRef, useState } from "react";
|
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
addingCommentToMessageIdAtom,
|
addingCommentToMessageIdAtom,
|
||||||
|
clearTargetCommentIdAtom,
|
||||||
|
commentsCollapsedAtom,
|
||||||
commentsEnabledAtom,
|
commentsEnabledAtom,
|
||||||
|
targetCommentIdAtom,
|
||||||
} from "@/atoms/chat/current-thread.atom";
|
} from "@/atoms/chat/current-thread.atom";
|
||||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||||
import { BranchPicker } from "@/components/assistant-ui/branch-picker";
|
import { BranchPicker } from "@/components/assistant-ui/branch-picker";
|
||||||
|
|
@ -102,6 +105,7 @@ export const AssistantMessage: FC = () => {
|
||||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||||
const dbMessageId = parseMessageId(messageId);
|
const dbMessageId = parseMessageId(messageId);
|
||||||
const commentsEnabled = useAtomValue(commentsEnabledAtom);
|
const commentsEnabled = useAtomValue(commentsEnabledAtom);
|
||||||
|
const commentsCollapsed = useAtomValue(commentsCollapsedAtom);
|
||||||
const [addingCommentToMessageId, setAddingCommentToMessageId] = useAtom(
|
const [addingCommentToMessageId, setAddingCommentToMessageId] = useAtom(
|
||||||
addingCommentToMessageIdAtom
|
addingCommentToMessageIdAtom
|
||||||
);
|
);
|
||||||
|
|
@ -115,11 +119,23 @@ export const AssistantMessage: FC = () => {
|
||||||
const isLastMessage = useAssistantState(({ message }) => message?.isLast ?? false);
|
const isLastMessage = useAssistantState(({ message }) => message?.isLast ?? false);
|
||||||
const isMessageStreaming = isThreadRunning && isLastMessage;
|
const isMessageStreaming = isThreadRunning && isLastMessage;
|
||||||
|
|
||||||
const { data: commentsData } = useComments({
|
const { data: commentsData, isSuccess: commentsLoaded } = useComments({
|
||||||
messageId: dbMessageId ?? 0,
|
messageId: dbMessageId ?? 0,
|
||||||
enabled: !!dbMessageId,
|
enabled: !!dbMessageId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Target comment navigation - read target from global atom
|
||||||
|
const targetCommentId = useAtomValue(targetCommentIdAtom);
|
||||||
|
const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom);
|
||||||
|
|
||||||
|
// Check if target comment belongs to this message (including replies)
|
||||||
|
const hasTargetComment = useMemo(() => {
|
||||||
|
if (!targetCommentId || !commentsData?.comments) return false;
|
||||||
|
return commentsData.comments.some(
|
||||||
|
(c) => c.id === targetCommentId || c.replies?.some((r) => r.id === targetCommentId)
|
||||||
|
);
|
||||||
|
}, [targetCommentId, commentsData]);
|
||||||
|
|
||||||
const commentCount = commentsData?.total_count ?? 0;
|
const commentCount = commentsData?.total_count ?? 0;
|
||||||
const hasComments = commentCount > 0;
|
const hasComments = commentCount > 0;
|
||||||
const isAddingComment = dbMessageId !== null && addingCommentToMessageId === dbMessageId;
|
const isAddingComment = dbMessageId !== null && addingCommentToMessageId === dbMessageId;
|
||||||
|
|
@ -144,6 +160,24 @@ export const AssistantMessage: FC = () => {
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Auto-open sheet on mobile/tablet when this message has the target comment
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasTargetComment && !isDesktop && commentsLoaded) {
|
||||||
|
setIsSheetOpen(true);
|
||||||
|
}
|
||||||
|
}, [hasTargetComment, isDesktop, commentsLoaded]);
|
||||||
|
|
||||||
|
// Scroll message into view when it contains target comment (desktop)
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasTargetComment && isDesktop && commentsLoaded && messageRef.current) {
|
||||||
|
// Small delay to ensure DOM is ready after comments render
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
messageRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
}, 100);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}, [hasTargetComment, isDesktop, commentsLoaded]);
|
||||||
|
|
||||||
const showCommentTrigger = searchSpaceId && commentsEnabled && !isMessageStreaming && dbMessageId;
|
const showCommentTrigger = searchSpaceId && commentsEnabled && !isMessageStreaming && dbMessageId;
|
||||||
|
|
||||||
// Determine sheet side based on screen size
|
// Determine sheet side based on screen size
|
||||||
|
|
@ -157,8 +191,8 @@ export const AssistantMessage: FC = () => {
|
||||||
>
|
>
|
||||||
<AssistantMessageInner />
|
<AssistantMessageInner />
|
||||||
|
|
||||||
{/* Desktop comment panel - only on lg screens and above */}
|
{/* Desktop comment panel - only on lg screens and above, hidden when collapsed */}
|
||||||
{searchSpaceId && commentsEnabled && !isMessageStreaming && (
|
{searchSpaceId && commentsEnabled && !isMessageStreaming && !commentsCollapsed && (
|
||||||
<div className="absolute left-full top-0 ml-4 hidden lg:block w-72">
|
<div className="absolute left-full top-0 ml-4 hidden lg:block w-72">
|
||||||
<div
|
<div
|
||||||
className={`sticky top-3 ${showCommentPanel ? "opacity-100" : "opacity-0 group-hover:opacity-100"} transition-opacity`}
|
className={`sticky top-3 ${showCommentPanel ? "opacity-100" : "opacity-0 group-hover:opacity-100"} transition-opacity`}
|
||||||
|
|
|
||||||
|
|
@ -252,13 +252,12 @@ const defaultComponents = memoizeMarkdownComponents({
|
||||||
<hr className={cn("aui-md-hr my-5 border-b", className)} {...props} />
|
<hr className={cn("aui-md-hr my-5 border-b", className)} {...props} />
|
||||||
),
|
),
|
||||||
table: ({ className, ...props }) => (
|
table: ({ className, ...props }) => (
|
||||||
|
<div className="aui-md-table-wrapper my-5 w-full overflow-x-auto">
|
||||||
<table
|
<table
|
||||||
className={cn(
|
className={cn("aui-md-table w-full min-w-max border-separate border-spacing-0", className)}
|
||||||
"aui-md-table my-5 w-full border-separate border-spacing-0 overflow-y-auto",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
th: ({ className, children, ...props }) => (
|
th: ({ className, children, ...props }) => (
|
||||||
<th
|
<th
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ const ThreadContent: FC<{ header?: React.ReactNode }> = ({ header }) => {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer sticky bottom-0 z-20 mx-auto mt-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-background pb-4 md:pb-6">
|
<ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer sticky bottom-0 z-10 mx-auto mt-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-background pb-4 md:pb-6">
|
||||||
<ThreadScrollToBottom />
|
<ThreadScrollToBottom />
|
||||||
<AssistantIf condition={({ thread }) => !thread.isEmpty}>
|
<AssistantIf condition={({ thread }) => !thread.isEmpty}>
|
||||||
<div className="fade-in slide-in-from-bottom-4 animate-in duration-500 ease-out fill-mode-both">
|
<div className="fade-in slide-in-from-bottom-4 animate-in duration-500 ease-out fill-mode-both">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
import { MessageSquare } from "lucide-react";
|
import { MessageSquare } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { clearTargetCommentIdAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -76,10 +79,9 @@ function renderMentions(content: string): React.ReactNode {
|
||||||
const mentionPattern = /@\{([^}]+)\}/g;
|
const mentionPattern = /@\{([^}]+)\}/g;
|
||||||
const parts: React.ReactNode[] = [];
|
const parts: React.ReactNode[] = [];
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
let match: RegExpExecArray | null;
|
|
||||||
|
|
||||||
while ((match = mentionPattern.exec(content)) !== null) {
|
for (const match of content.matchAll(mentionPattern)) {
|
||||||
if (match.index > lastIndex) {
|
if (match.index !== undefined && match.index > lastIndex) {
|
||||||
parts.push(content.slice(lastIndex, match.index));
|
parts.push(content.slice(lastIndex, match.index));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,7 +92,7 @@ function renderMentions(content: string): React.ReactNode {
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
lastIndex = match.index + match[0].length;
|
lastIndex = (match.index ?? 0) + match[0].length;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastIndex < content.length) {
|
if (lastIndex < content.length) {
|
||||||
|
|
@ -113,6 +115,37 @@ export function CommentItem({
|
||||||
members = [],
|
members = [],
|
||||||
membersLoading = false,
|
membersLoading = false,
|
||||||
}: CommentItemProps) {
|
}: CommentItemProps) {
|
||||||
|
const commentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isHighlighted, setIsHighlighted] = useState(false);
|
||||||
|
|
||||||
|
// Target comment navigation
|
||||||
|
const targetCommentId = useAtomValue(targetCommentIdAtom);
|
||||||
|
const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom);
|
||||||
|
|
||||||
|
const isTarget = targetCommentId === comment.id;
|
||||||
|
|
||||||
|
// Scroll into view and highlight when this is the target comment
|
||||||
|
useEffect(() => {
|
||||||
|
if (isTarget && commentRef.current) {
|
||||||
|
// Small delay to ensure DOM is ready
|
||||||
|
const scrollTimeoutId = setTimeout(() => {
|
||||||
|
commentRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
setIsHighlighted(true);
|
||||||
|
}, 150);
|
||||||
|
|
||||||
|
// Remove highlight and clear target after delay
|
||||||
|
const clearTimeoutId = setTimeout(() => {
|
||||||
|
setIsHighlighted(false);
|
||||||
|
clearTargetCommentId();
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(scrollTimeoutId);
|
||||||
|
clearTimeout(clearTimeoutId);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [isTarget, clearTargetCommentId]);
|
||||||
|
|
||||||
const displayName =
|
const displayName =
|
||||||
comment.author?.displayName || comment.author?.email.split("@")[0] || "Unknown";
|
comment.author?.displayName || comment.author?.email.split("@")[0] || "Unknown";
|
||||||
const email = comment.author?.email || "";
|
const email = comment.author?.email || "";
|
||||||
|
|
@ -122,7 +155,14 @@ export function CommentItem({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("group flex gap-3")} data-comment-id={comment.id}>
|
<div
|
||||||
|
ref={commentRef}
|
||||||
|
className={cn(
|
||||||
|
"group flex gap-3 rounded-lg p-1 -m-1 transition-all duration-300",
|
||||||
|
isHighlighted && "ring-2 ring-primary ring-offset-2 ring-offset-background"
|
||||||
|
)}
|
||||||
|
data-comment-id={comment.id}
|
||||||
|
>
|
||||||
<Avatar className="size-8 shrink-0">
|
<Avatar className="size-8 shrink-0">
|
||||||
{comment.author?.avatarUrl && (
|
{comment.author?.avatarUrl && (
|
||||||
<AvatarImage src={comment.author.avatarUrl} alt={displayName} />
|
<AvatarImage src={comment.author.avatarUrl} alt={displayName} />
|
||||||
|
|
|
||||||
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 { useSidebarState } from "./useSidebarState";
|
||||||
|
export { SidebarProvider, useSidebarContext, useSidebarContextSafe } from "./SidebarContext";
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,6 @@ import { CreateSearchSpaceDialog } from "../ui/dialogs";
|
||||||
import { LayoutShell } from "../ui/shell";
|
import { LayoutShell } from "../ui/shell";
|
||||||
import { AllPrivateChatsSidebar } from "../ui/sidebar/AllPrivateChatsSidebar";
|
import { AllPrivateChatsSidebar } from "../ui/sidebar/AllPrivateChatsSidebar";
|
||||||
import { AllSharedChatsSidebar } from "../ui/sidebar/AllSharedChatsSidebar";
|
import { AllSharedChatsSidebar } from "../ui/sidebar/AllSharedChatsSidebar";
|
||||||
import { InboxSidebar } from "../ui/sidebar/InboxSidebar";
|
|
||||||
|
|
||||||
interface LayoutDataProviderProps {
|
interface LayoutDataProviderProps {
|
||||||
searchSpaceId: string;
|
searchSpaceId: string;
|
||||||
|
|
@ -100,23 +99,60 @@ export function LayoutDataProvider({
|
||||||
|
|
||||||
// Inbox sidebar state
|
// Inbox sidebar state
|
||||||
const [isInboxSidebarOpen, setIsInboxSidebarOpen] = useState(false);
|
const [isInboxSidebarOpen, setIsInboxSidebarOpen] = useState(false);
|
||||||
|
const [isInboxDocked, setIsInboxDocked] = useState(false);
|
||||||
|
|
||||||
// Search space dialog state
|
// Search space dialog state
|
||||||
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
|
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
|
||||||
|
|
||||||
// Inbox hook
|
// Inbox hooks - separate data sources for mentions and status tabs
|
||||||
|
// This ensures each tab has independent pagination and data loading
|
||||||
const userId = user?.id ? String(user.id) : null;
|
const userId = user?.id ? String(user.id) : null;
|
||||||
|
|
||||||
|
// Mentions: Only fetch "new_mention" type notifications
|
||||||
const {
|
const {
|
||||||
inboxItems,
|
inboxItems: mentionItems,
|
||||||
unreadCount,
|
unreadCount: mentionUnreadCount,
|
||||||
loading: inboxLoading,
|
loading: mentionLoading,
|
||||||
loadingMore: inboxLoadingMore,
|
loadingMore: mentionLoadingMore,
|
||||||
hasMore: inboxHasMore,
|
hasMore: mentionHasMore,
|
||||||
loadMore: inboxLoadMore,
|
loadMore: mentionLoadMore,
|
||||||
markAsRead,
|
markAsRead: markMentionAsRead,
|
||||||
markAllAsRead,
|
markAllAsRead: markAllMentionsAsRead,
|
||||||
|
} = useInbox(userId, Number(searchSpaceId) || null, "new_mention");
|
||||||
|
|
||||||
|
// Status: Fetch all types (will be filtered client-side to status types)
|
||||||
|
// We pass null to get all, then InboxSidebar filters to status types
|
||||||
|
const {
|
||||||
|
inboxItems: statusItems,
|
||||||
|
unreadCount: statusUnreadCount,
|
||||||
|
loading: statusLoading,
|
||||||
|
loadingMore: statusLoadingMore,
|
||||||
|
hasMore: statusHasMore,
|
||||||
|
loadMore: statusLoadMore,
|
||||||
|
markAsRead: markStatusAsRead,
|
||||||
|
markAllAsRead: markAllStatusAsRead,
|
||||||
} = useInbox(userId, Number(searchSpaceId) || null, null);
|
} = useInbox(userId, Number(searchSpaceId) || null, null);
|
||||||
|
|
||||||
|
// Combined unread count for nav badge (mentions take priority for visibility)
|
||||||
|
const totalUnreadCount = mentionUnreadCount + statusUnreadCount;
|
||||||
|
|
||||||
|
// Unified mark as read that delegates to the correct hook
|
||||||
|
const markAsRead = useCallback(
|
||||||
|
async (id: number) => {
|
||||||
|
// Try both - one will succeed based on which list has the item
|
||||||
|
const mentionResult = await markMentionAsRead(id);
|
||||||
|
if (mentionResult) return true;
|
||||||
|
return markStatusAsRead(id);
|
||||||
|
},
|
||||||
|
[markMentionAsRead, markStatusAsRead]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mark all as read for both types
|
||||||
|
const markAllAsRead = useCallback(async () => {
|
||||||
|
await Promise.all([markAllMentionsAsRead(), markAllStatusAsRead()]);
|
||||||
|
return true;
|
||||||
|
}, [markAllMentionsAsRead, markAllStatusAsRead]);
|
||||||
|
|
||||||
// Delete dialogs state
|
// Delete dialogs state
|
||||||
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
|
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
|
||||||
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
|
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
|
||||||
|
|
@ -197,7 +233,7 @@ export function LayoutDataProvider({
|
||||||
url: "#inbox", // Special URL to indicate this is handled differently
|
url: "#inbox", // Special URL to indicate this is handled differently
|
||||||
icon: Inbox,
|
icon: Inbox,
|
||||||
isActive: isInboxSidebarOpen,
|
isActive: isInboxSidebarOpen,
|
||||||
badge: unreadCount > 0 ? formatInboxCount(unreadCount) : undefined,
|
badge: totalUnreadCount > 0 ? formatInboxCount(totalUnreadCount) : undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Documents",
|
title: "Documents",
|
||||||
|
|
@ -206,7 +242,7 @@ export function LayoutDataProvider({
|
||||||
isActive: pathname?.includes("/documents"),
|
isActive: pathname?.includes("/documents"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[searchSpaceId, pathname, isInboxSidebarOpen, unreadCount]
|
[searchSpaceId, pathname, isInboxSidebarOpen, totalUnreadCount]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
|
|
@ -298,9 +334,9 @@ export function LayoutDataProvider({
|
||||||
|
|
||||||
const handleNavItemClick = useCallback(
|
const handleNavItemClick = useCallback(
|
||||||
(item: NavItem) => {
|
(item: NavItem) => {
|
||||||
// Handle inbox specially - open sidebar instead of navigating
|
// Handle inbox specially - toggle sidebar instead of navigating
|
||||||
if (item.url === "#inbox") {
|
if (item.url === "#inbox") {
|
||||||
setIsInboxSidebarOpen(true);
|
setIsInboxSidebarOpen((prev) => !prev);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.push(item.url);
|
router.push(item.url);
|
||||||
|
|
@ -462,6 +498,32 @@ export function LayoutDataProvider({
|
||||||
theme={theme}
|
theme={theme}
|
||||||
setTheme={setTheme}
|
setTheme={setTheme}
|
||||||
isChatPage={isChatPage}
|
isChatPage={isChatPage}
|
||||||
|
inbox={{
|
||||||
|
isOpen: isInboxSidebarOpen,
|
||||||
|
onOpenChange: setIsInboxSidebarOpen,
|
||||||
|
// Separate data sources for each tab
|
||||||
|
mentions: {
|
||||||
|
items: mentionItems,
|
||||||
|
unreadCount: mentionUnreadCount,
|
||||||
|
loading: mentionLoading,
|
||||||
|
loadingMore: mentionLoadingMore,
|
||||||
|
hasMore: mentionHasMore,
|
||||||
|
loadMore: mentionLoadMore,
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
items: statusItems,
|
||||||
|
unreadCount: statusUnreadCount,
|
||||||
|
loading: statusLoading,
|
||||||
|
loadingMore: statusLoadingMore,
|
||||||
|
hasMore: statusHasMore,
|
||||||
|
loadMore: statusLoadMore,
|
||||||
|
},
|
||||||
|
totalUnreadCount,
|
||||||
|
markAsRead,
|
||||||
|
markAllAsRead,
|
||||||
|
isDocked: isInboxDocked,
|
||||||
|
onDockedChange: setIsInboxDocked,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</LayoutShell>
|
</LayoutShell>
|
||||||
|
|
@ -607,20 +669,6 @@ export function LayoutDataProvider({
|
||||||
searchSpaceId={searchSpaceId}
|
searchSpaceId={searchSpaceId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Inbox Sidebar */}
|
|
||||||
<InboxSidebar
|
|
||||||
open={isInboxSidebarOpen}
|
|
||||||
onOpenChange={setIsInboxSidebarOpen}
|
|
||||||
inboxItems={inboxItems}
|
|
||||||
unreadCount={unreadCount}
|
|
||||||
loading={inboxLoading}
|
|
||||||
loadingMore={inboxLoadingMore}
|
|
||||||
hasMore={inboxHasMore}
|
|
||||||
loadMore={inboxLoadMore}
|
|
||||||
markAsRead={markAsRead}
|
|
||||||
markAllAsRead={markAllAsRead}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Create Search Space Dialog */}
|
{/* Create Search Space Dialog */}
|
||||||
<CreateSearchSpaceDialog
|
<CreateSearchSpaceDialog
|
||||||
open={isCreateSearchSpaceDialogOpen}
|
open={isCreateSearchSpaceDialogOpen}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,43 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
import type { InboxItem } from "@/hooks/use-inbox";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useSidebarState } from "../../hooks";
|
import { SidebarProvider, useSidebarState } from "../../hooks";
|
||||||
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
|
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
|
||||||
import { Header } from "../header";
|
import { Header } from "../header";
|
||||||
import { IconRail } from "../icon-rail";
|
import { IconRail } from "../icon-rail";
|
||||||
import { MobileSidebar, MobileSidebarTrigger, Sidebar } from "../sidebar";
|
import { InboxSidebar, MobileSidebar, MobileSidebarTrigger, Sidebar } from "../sidebar";
|
||||||
|
|
||||||
|
// Tab-specific data source props
|
||||||
|
interface TabDataSource {
|
||||||
|
items: InboxItem[];
|
||||||
|
unreadCount: number;
|
||||||
|
loading: boolean;
|
||||||
|
loadingMore?: boolean;
|
||||||
|
hasMore?: boolean;
|
||||||
|
loadMore?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inbox-related props with separate data sources per tab
|
||||||
|
interface InboxProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
/** Mentions tab data source with independent pagination */
|
||||||
|
mentions: TabDataSource;
|
||||||
|
/** Status tab data source with independent pagination */
|
||||||
|
status: TabDataSource;
|
||||||
|
/** Combined unread count for nav badge */
|
||||||
|
totalUnreadCount: number;
|
||||||
|
markAsRead: (id: number) => Promise<boolean>;
|
||||||
|
markAllAsRead: () => Promise<boolean>;
|
||||||
|
/** Whether the inbox is docked (permanent) */
|
||||||
|
isDocked?: boolean;
|
||||||
|
/** Callback to change docked state */
|
||||||
|
onDockedChange?: (docked: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface LayoutShellProps {
|
interface LayoutShellProps {
|
||||||
searchSpaces: SearchSpace[];
|
searchSpaces: SearchSpace[];
|
||||||
|
|
@ -42,6 +71,8 @@ interface LayoutShellProps {
|
||||||
isChatPage?: boolean;
|
isChatPage?: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
// Inbox props
|
||||||
|
inbox?: InboxProps;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LayoutShell({
|
export function LayoutShell({
|
||||||
|
|
@ -76,14 +107,22 @@ export function LayoutShell({
|
||||||
isChatPage = false,
|
isChatPage = false,
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
|
inbox,
|
||||||
}: LayoutShellProps) {
|
}: LayoutShellProps) {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const { isCollapsed, toggleCollapsed } = useSidebarState(defaultCollapsed);
|
const { isCollapsed, setIsCollapsed, toggleCollapsed } = useSidebarState(defaultCollapsed);
|
||||||
|
|
||||||
|
// Memoize context value to prevent unnecessary re-renders
|
||||||
|
const sidebarContextValue = useMemo(
|
||||||
|
() => ({ isCollapsed, setIsCollapsed, toggleCollapsed }),
|
||||||
|
[isCollapsed, setIsCollapsed, toggleCollapsed]
|
||||||
|
);
|
||||||
|
|
||||||
// Mobile layout
|
// Mobile layout
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
|
<SidebarProvider value={sidebarContextValue}>
|
||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
<div className={cn("flex h-screen w-full flex-col bg-background", className)}>
|
<div className={cn("flex h-screen w-full flex-col bg-background", className)}>
|
||||||
<Header
|
<Header
|
||||||
|
|
@ -125,15 +164,33 @@ export function LayoutShell({
|
||||||
<main className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
|
<main className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* Mobile Inbox Sidebar - only render when open to avoid scroll blocking */}
|
||||||
|
{inbox?.isOpen && (
|
||||||
|
<InboxSidebar
|
||||||
|
open={inbox.isOpen}
|
||||||
|
onOpenChange={inbox.onOpenChange}
|
||||||
|
mentions={inbox.mentions}
|
||||||
|
status={inbox.status}
|
||||||
|
totalUnreadCount={inbox.totalUnreadCount}
|
||||||
|
markAsRead={inbox.markAsRead}
|
||||||
|
markAllAsRead={inbox.markAllAsRead}
|
||||||
|
onCloseMobileSidebar={() => setMobileMenuOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Desktop layout
|
// Desktop layout
|
||||||
return (
|
return (
|
||||||
|
<SidebarProvider value={sidebarContextValue}>
|
||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
<div className={cn("flex h-screen w-full gap-2 p-2 overflow-hidden bg-muted/40", className)}>
|
<div
|
||||||
|
className={cn("flex h-screen w-full gap-2 p-2 overflow-hidden bg-muted/40", className)}
|
||||||
|
>
|
||||||
<div className="hidden md:flex overflow-hidden">
|
<div className="hidden md:flex overflow-hidden">
|
||||||
<IconRail
|
<IconRail
|
||||||
searchSpaces={searchSpaces}
|
searchSpaces={searchSpaces}
|
||||||
|
|
@ -145,7 +202,8 @@ export function LayoutShell({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 rounded-xl border bg-background overflow-hidden">
|
{/* Main container with sidebar and content - relative for inbox positioning */}
|
||||||
|
<div className="relative flex flex-1 rounded-xl border bg-background overflow-hidden">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
searchSpace={searchSpace}
|
searchSpace={searchSpace}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
|
|
@ -172,6 +230,21 @@ export function LayoutShell({
|
||||||
className="hidden md:flex border-r shrink-0"
|
className="hidden md:flex border-r shrink-0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Docked Inbox Sidebar - renders as flex sibling between sidebar and content */}
|
||||||
|
{inbox?.isDocked && (
|
||||||
|
<InboxSidebar
|
||||||
|
open={inbox.isOpen}
|
||||||
|
onOpenChange={inbox.onOpenChange}
|
||||||
|
mentions={inbox.mentions}
|
||||||
|
status={inbox.status}
|
||||||
|
totalUnreadCount={inbox.totalUnreadCount}
|
||||||
|
markAsRead={inbox.markAsRead}
|
||||||
|
markAllAsRead={inbox.markAllAsRead}
|
||||||
|
isDocked={inbox.isDocked}
|
||||||
|
onDockedChange={inbox.onDockedChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<main className="flex-1 flex flex-col min-w-0">
|
<main className="flex-1 flex flex-col min-w-0">
|
||||||
<Header breadcrumb={breadcrumb} />
|
<Header breadcrumb={breadcrumb} />
|
||||||
|
|
||||||
|
|
@ -179,8 +252,24 @@ export function LayoutShell({
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* Floating Inbox Sidebar - positioned absolutely on top of content */}
|
||||||
|
{inbox && !inbox.isDocked && (
|
||||||
|
<InboxSidebar
|
||||||
|
open={inbox.isOpen}
|
||||||
|
onOpenChange={inbox.onOpenChange}
|
||||||
|
mentions={inbox.mentions}
|
||||||
|
status={inbox.status}
|
||||||
|
totalUnreadCount={inbox.totalUnreadCount}
|
||||||
|
markAsRead={inbox.markAsRead}
|
||||||
|
markAllAsRead={inbox.markAllAsRead}
|
||||||
|
isDocked={false}
|
||||||
|
onDockedChange={inbox.onDockedChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import {
|
||||||
Check,
|
Check,
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
History,
|
History,
|
||||||
Inbox,
|
Inbox,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
|
|
@ -14,11 +16,12 @@ import {
|
||||||
Search,
|
Search,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { setCommentsCollapsedAtom, setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
|
||||||
import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
|
import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -45,6 +48,11 @@ import { isConnectorIndexingMetadata, isNewMentionMetadata } from "@/contracts/t
|
||||||
import type { InboxItem } from "@/hooks/use-inbox";
|
import type { InboxItem } from "@/hooks/use-inbox";
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useSidebarContextSafe } from "../../hooks";
|
||||||
|
|
||||||
|
// Sidebar width constants
|
||||||
|
const SIDEBAR_COLLAPSED_WIDTH = 60;
|
||||||
|
const SIDEBAR_EXPANDED_WIDTH = 240;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get initials from name or email for avatar fallback
|
* Get initials from name or email for avatar fallback
|
||||||
|
|
@ -104,6 +112,7 @@ function getConnectorTypeDisplayName(connectorType: string): string {
|
||||||
YOUTUBE_CONNECTOR: "YouTube",
|
YOUTUBE_CONNECTOR: "YouTube",
|
||||||
CIRCLEBACK_CONNECTOR: "Circleback",
|
CIRCLEBACK_CONNECTOR: "Circleback",
|
||||||
MCP_CONNECTOR: "MCP",
|
MCP_CONNECTOR: "MCP",
|
||||||
|
OBSIDIAN_CONNECTOR: "Obsidian",
|
||||||
TAVILY_API: "Tavily",
|
TAVILY_API: "Tavily",
|
||||||
SEARXNG_API: "SearXNG",
|
SEARXNG_API: "SearXNG",
|
||||||
LINKUP_API: "Linkup",
|
LINKUP_API: "Linkup",
|
||||||
|
|
@ -122,37 +131,55 @@ function getConnectorTypeDisplayName(connectorType: string): string {
|
||||||
type InboxTab = "mentions" | "status";
|
type InboxTab = "mentions" | "status";
|
||||||
type InboxFilter = "all" | "unread";
|
type InboxFilter = "all" | "unread";
|
||||||
|
|
||||||
interface InboxSidebarProps {
|
// Tab-specific data source with independent pagination
|
||||||
open: boolean;
|
interface TabDataSource {
|
||||||
onOpenChange: (open: boolean) => void;
|
items: InboxItem[];
|
||||||
inboxItems: InboxItem[];
|
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
loadingMore?: boolean;
|
loadingMore?: boolean;
|
||||||
hasMore?: boolean;
|
hasMore?: boolean;
|
||||||
loadMore?: () => void;
|
loadMore?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InboxSidebarProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
/** Mentions tab data source with independent pagination */
|
||||||
|
mentions: TabDataSource;
|
||||||
|
/** Status tab data source with independent pagination */
|
||||||
|
status: TabDataSource;
|
||||||
|
/** Combined unread count for mark all as read */
|
||||||
|
totalUnreadCount: number;
|
||||||
markAsRead: (id: number) => Promise<boolean>;
|
markAsRead: (id: number) => Promise<boolean>;
|
||||||
markAllAsRead: () => Promise<boolean>;
|
markAllAsRead: () => Promise<boolean>;
|
||||||
onCloseMobileSidebar?: () => void;
|
onCloseMobileSidebar?: () => void;
|
||||||
|
/** Whether the inbox is docked (permanent) or floating */
|
||||||
|
isDocked?: boolean;
|
||||||
|
/** Callback to toggle docked state */
|
||||||
|
onDockedChange?: (docked: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InboxSidebar({
|
export function InboxSidebar({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
inboxItems,
|
mentions,
|
||||||
unreadCount,
|
status,
|
||||||
loading,
|
totalUnreadCount,
|
||||||
loadingMore = false,
|
|
||||||
hasMore = false,
|
|
||||||
loadMore,
|
|
||||||
markAsRead,
|
markAsRead,
|
||||||
markAllAsRead,
|
markAllAsRead,
|
||||||
onCloseMobileSidebar,
|
onCloseMobileSidebar,
|
||||||
|
isDocked = false,
|
||||||
|
onDockedChange,
|
||||||
}: InboxSidebarProps) {
|
}: InboxSidebarProps) {
|
||||||
const t = useTranslations("sidebar");
|
const t = useTranslations("sidebar");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isMobile = !useMediaQuery("(min-width: 640px)");
|
const isMobile = !useMediaQuery("(min-width: 640px)");
|
||||||
|
|
||||||
|
// Comments collapsed state (desktop only, when docked)
|
||||||
|
const [, setCommentsCollapsed] = useAtom(setCommentsCollapsedAtom);
|
||||||
|
// Target comment for navigation - also ensures comments panel is visible
|
||||||
|
const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom);
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [activeTab, setActiveTab] = useState<InboxTab>("mentions");
|
const [activeTab, setActiveTab] = useState<InboxTab>("mentions");
|
||||||
const [activeFilter, setActiveFilter] = useState<InboxFilter>("all");
|
const [activeFilter, setActiveFilter] = useState<InboxFilter>("all");
|
||||||
|
|
@ -181,16 +208,18 @@ export function InboxSidebar({
|
||||||
return () => document.removeEventListener("keydown", handleEscape);
|
return () => document.removeEventListener("keydown", handleEscape);
|
||||||
}, [open, onOpenChange]);
|
}, [open, onOpenChange]);
|
||||||
|
|
||||||
|
// Only lock body scroll on mobile when inbox is open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (!open || !isMobile) return;
|
||||||
|
|
||||||
|
// Store original overflow to restore on cleanup
|
||||||
|
const originalOverflow = document.body.style.overflow;
|
||||||
document.body.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
||||||
} else {
|
|
||||||
document.body.style.overflow = "";
|
|
||||||
}
|
|
||||||
return () => {
|
return () => {
|
||||||
document.body.style.overflow = "";
|
document.body.style.overflow = originalOverflow;
|
||||||
};
|
};
|
||||||
}, [open]);
|
}, [open, isMobile]);
|
||||||
|
|
||||||
// Reset connector filter when switching away from status tab
|
// Reset connector filter when switching away from status tab
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -199,19 +228,18 @@ export function InboxSidebar({
|
||||||
}
|
}
|
||||||
}, [activeTab]);
|
}, [activeTab]);
|
||||||
|
|
||||||
// Split items by type
|
// Get current tab's data source - each tab has independent data and pagination
|
||||||
const mentionItems = useMemo(
|
const currentDataSource = activeTab === "mentions" ? mentions : status;
|
||||||
() => inboxItems.filter((item) => item.type === "new_mention"),
|
const { loading, loadingMore = false, hasMore = false, loadMore } = currentDataSource;
|
||||||
[inboxItems]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Status tab includes: connector indexing, document processing
|
// Status tab includes: connector indexing, document processing
|
||||||
|
// Filter to only show status notification types
|
||||||
const statusItems = useMemo(
|
const statusItems = useMemo(
|
||||||
() =>
|
() =>
|
||||||
inboxItems.filter(
|
status.items.filter(
|
||||||
(item) => item.type === "connector_indexing" || item.type === "document_processing"
|
(item) => item.type === "connector_indexing" || item.type === "document_processing"
|
||||||
),
|
),
|
||||||
[inboxItems]
|
[status.items]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get unique connector types from status items for filtering
|
// Get unique connector types from status items for filtering
|
||||||
|
|
@ -233,12 +261,12 @@ export function InboxSidebar({
|
||||||
}));
|
}));
|
||||||
}, [statusItems]);
|
}, [statusItems]);
|
||||||
|
|
||||||
// Get items for current tab
|
// Get items for current tab - mentions use their source directly, status uses filtered items
|
||||||
const currentTabItems = activeTab === "mentions" ? mentionItems : statusItems;
|
const displayItems = activeTab === "mentions" ? mentions.items : statusItems;
|
||||||
|
|
||||||
// Filter items based on filter type, connector filter, and search query
|
// Filter items based on filter type, connector filter, and search query
|
||||||
const filteredItems = useMemo(() => {
|
const filteredItems = useMemo(() => {
|
||||||
let items = currentTabItems;
|
let items = displayItems;
|
||||||
|
|
||||||
// Apply read/unread filter
|
// Apply read/unread filter
|
||||||
if (activeFilter === "unread") {
|
if (activeFilter === "unread") {
|
||||||
|
|
@ -269,7 +297,7 @@ export function InboxSidebar({
|
||||||
}
|
}
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}, [currentTabItems, activeFilter, activeTab, selectedConnector, searchQuery]);
|
}, [displayItems, activeFilter, activeTab, selectedConnector, searchQuery]);
|
||||||
|
|
||||||
// Intersection Observer for infinite scroll with prefetching
|
// Intersection Observer for infinite scroll with prefetching
|
||||||
// Only active when not searching (search results are client-side filtered)
|
// Only active when not searching (search results are client-side filtered)
|
||||||
|
|
@ -295,16 +323,11 @@ export function InboxSidebar({
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [loadMore, hasMore, loadingMore, open, searchQuery, filteredItems.length]);
|
}, [loadMore, hasMore, loadingMore, open, searchQuery]);
|
||||||
|
|
||||||
// Count unread items per tab
|
// Use unread counts from data sources (more accurate than client-side counting)
|
||||||
const unreadMentionsCount = useMemo(() => {
|
const unreadMentionsCount = mentions.unreadCount;
|
||||||
return mentionItems.filter((item) => !item.read).length;
|
const unreadStatusCount = status.unreadCount;
|
||||||
}, [mentionItems]);
|
|
||||||
|
|
||||||
const unreadStatusCount = useMemo(() => {
|
|
||||||
return statusItems.filter((item) => !item.read).length;
|
|
||||||
}, [statusItems]);
|
|
||||||
|
|
||||||
const handleItemClick = useCallback(
|
const handleItemClick = useCallback(
|
||||||
async (item: InboxItem) => {
|
async (item: InboxItem) => {
|
||||||
|
|
@ -322,6 +345,12 @@ export function InboxSidebar({
|
||||||
const commentId = item.metadata.comment_id;
|
const commentId = item.metadata.comment_id;
|
||||||
|
|
||||||
if (searchSpaceId && threadId) {
|
if (searchSpaceId && threadId) {
|
||||||
|
// Pre-set target comment ID before navigation
|
||||||
|
// This also ensures comments panel is not collapsed
|
||||||
|
if (commentId) {
|
||||||
|
setTargetCommentId(commentId);
|
||||||
|
}
|
||||||
|
|
||||||
const url = commentId
|
const url = commentId
|
||||||
? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}`
|
? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}`
|
||||||
: `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
|
: `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
|
||||||
|
|
@ -332,7 +361,7 @@ export function InboxSidebar({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[markAsRead, router, onOpenChange, onCloseMobileSidebar]
|
[markAsRead, router, onOpenChange, onCloseMobileSidebar, setTargetCommentId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMarkAllAsRead = useCallback(async () => {
|
const handleMarkAllAsRead = useCallback(async () => {
|
||||||
|
|
@ -436,36 +465,21 @@ export function InboxSidebar({
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Get sidebar collapsed state from context (provided by LayoutShell)
|
||||||
|
const sidebarContext = useSidebarContextSafe();
|
||||||
|
const isCollapsed = sidebarContext?.isCollapsed ?? false;
|
||||||
|
|
||||||
|
// Calculate the left position for the inbox panel (relative to sidebar)
|
||||||
|
const sidebarWidth = isCollapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_EXPANDED_WIDTH;
|
||||||
|
|
||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
|
|
||||||
return createPortal(
|
// Shared content component for both docked and floating modes
|
||||||
<AnimatePresence>
|
const inboxContent = (
|
||||||
{open && (
|
|
||||||
<>
|
<>
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
className="fixed inset-0 z-70 bg-black/50"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ x: "-100%" }}
|
|
||||||
animate={{ x: 0 }}
|
|
||||||
exit={{ x: "-100%" }}
|
|
||||||
transition={{ type: "tween", duration: 0.3, ease: "easeOut" }}
|
|
||||||
className="fixed inset-y-0 left-0 z-70 w-90 bg-background shadow-xl flex flex-col pointer-events-auto isolate"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label={t("inbox") || "Inbox"}
|
|
||||||
>
|
|
||||||
<div className="shrink-0 p-4 pb-2 space-y-3">
|
<div className="shrink-0 p-4 pb-2 space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Inbox className="h-5 w-5 text-primary" />
|
|
||||||
<h2 className="text-lg font-semibold">{t("inbox") || "Inbox"}</h2>
|
<h2 className="text-lg font-semibold">{t("inbox") || "Inbox"}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
|
@ -672,9 +686,7 @@ export function InboxSidebar({
|
||||||
{getConnectorIcon(connector.type, "h-4 w-4")}
|
{getConnectorIcon(connector.type, "h-4 w-4")}
|
||||||
<span>{connector.displayName}</span>
|
<span>{connector.displayName}</span>
|
||||||
</span>
|
</span>
|
||||||
{selectedConnector === connector.type && (
|
{selectedConnector === connector.type && <Check className="h-4 w-4" />}
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|
@ -689,7 +701,7 @@ export function InboxSidebar({
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 rounded-full"
|
className="h-8 w-8 rounded-full"
|
||||||
onClick={handleMarkAllAsRead}
|
onClick={handleMarkAllAsRead}
|
||||||
disabled={unreadCount === 0}
|
disabled={totalUnreadCount === 0}
|
||||||
>
|
>
|
||||||
<CheckCheck className="h-4 w-4 text-muted-foreground" />
|
<CheckCheck className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="sr-only">{t("mark_all_read") || "Mark all as read"}</span>
|
<span className="sr-only">{t("mark_all_read") || "Mark all as read"}</span>
|
||||||
|
|
@ -699,6 +711,57 @@ export function InboxSidebar({
|
||||||
{t("mark_all_read") || "Mark all as read"}
|
{t("mark_all_read") || "Mark all as read"}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
{/* Close button - mobile only */}
|
||||||
|
{isMobile && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 rounded-full"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="sr-only">{t("close") || "Close"}</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="z-80">{t("close") || "Close"}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{/* Dock/Undock button - desktop only */}
|
||||||
|
{!isMobile && onDockedChange && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 rounded-full"
|
||||||
|
onClick={() => {
|
||||||
|
if (isDocked) {
|
||||||
|
// Collapse: show comments immediately, then close inbox
|
||||||
|
setCommentsCollapsed(false);
|
||||||
|
onDockedChange(false);
|
||||||
|
onOpenChange(false);
|
||||||
|
} else {
|
||||||
|
// Expand: hide comments immediately
|
||||||
|
setCommentsCollapsed(true);
|
||||||
|
onDockedChange(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDocked ? (
|
||||||
|
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span className="sr-only">{isDocked ? "Collapse panel" : "Expand panel"}</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="z-80">
|
||||||
|
{isDocked ? "Collapse panel" : "Expand panel"}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -819,9 +882,7 @@ export function InboxSidebar({
|
||||||
<span className="text-[10px] text-muted-foreground">
|
<span className="text-[10px] text-muted-foreground">
|
||||||
{formatTime(item.created_at)}
|
{formatTime(item.created_at)}
|
||||||
</span>
|
</span>
|
||||||
{!item.read && (
|
{!item.read && <span className="h-2 w-2 rounded-full bg-blue-500 shrink-0" />}
|
||||||
<span className="h-2 w-2 rounded-full bg-blue-500 shrink-0" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -849,16 +910,70 @@ export function InboxSidebar({
|
||||||
<History className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
<History className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
||||||
)}
|
)}
|
||||||
<p className="text-sm text-muted-foreground">{getEmptyStateMessage().title}</p>
|
<p className="text-sm text-muted-foreground">{getEmptyStateMessage().title}</p>
|
||||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
<p className="text-xs text-muted-foreground/70 mt-1">{getEmptyStateMessage().hint}</p>
|
||||||
{getEmptyStateMessage().hint}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
// DOCKED MODE: Render as a static flex child (no animation, no click-away)
|
||||||
|
if (isDocked && open && !isMobile) {
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className="h-full w-[360px] shrink-0 bg-background flex flex-col border-r"
|
||||||
|
aria-label={t("inbox") || "Inbox"}
|
||||||
|
>
|
||||||
|
{inboxContent}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// FLOATING MODE: Render with animation and click-away layer
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
{/* Click-away layer - only covers the content area, not the sidebar */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
style={{
|
||||||
|
left: isMobile ? 0 : sidebarWidth,
|
||||||
|
}}
|
||||||
|
className="absolute inset-y-0 right-0"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Clip container - positioned at sidebar edge with overflow hidden */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
left: isMobile ? 0 : sidebarWidth,
|
||||||
|
width: isMobile ? "100%" : 360,
|
||||||
|
}}
|
||||||
|
className={cn("absolute z-10 overflow-hidden pointer-events-none", "inset-y-0")}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ x: "-100%" }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
exit={{ x: "-100%" }}
|
||||||
|
transition={{ type: "tween", duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||||
|
className={cn(
|
||||||
|
"h-full w-full bg-background flex flex-col pointer-events-auto",
|
||||||
|
"sm:border-r sm:shadow-xl"
|
||||||
|
)}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t("inbox") || "Inbox"}
|
||||||
|
>
|
||||||
|
{inboxContent}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>,
|
</AnimatePresence>
|
||||||
document.body
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -149,16 +149,16 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-4 rounded-xl border border-destructive/20 bg-destructive/5 p-4",
|
"flex items-center gap-3 sm:gap-4 rounded-xl border border-destructive/20 bg-destructive/5 p-3 sm:p-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex size-16 items-center justify-center rounded-lg bg-destructive/10">
|
<div className="flex size-12 sm:size-16 shrink-0 items-center justify-center rounded-lg bg-destructive/10">
|
||||||
<Volume2Icon className="size-8 text-destructive" />
|
<Volume2Icon className="size-6 sm:size-8 text-destructive" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-medium text-destructive">{title}</p>
|
<p className="font-medium text-destructive text-sm sm:text-base truncate">{title}</p>
|
||||||
<p className="text-destructive/70 text-sm">{error}</p>
|
<p className="text-destructive/70 text-xs sm:text-sm">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -168,7 +168,7 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN
|
||||||
<div
|
<div
|
||||||
id={id}
|
id={id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative overflow-hidden rounded-xl border bg-gradient-to-br from-background to-muted/30 p-4 shadow-sm transition-all hover:shadow-md",
|
"group relative overflow-hidden rounded-xl border bg-gradient-to-br from-background to-muted/30 p-3 sm:p-4 shadow-sm transition-all hover:shadow-md",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -177,15 +177,15 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN
|
||||||
<track kind="captions" srcLang="en" label="English captions" default />
|
<track kind="captions" srcLang="en" label="English captions" default />
|
||||||
</audio>
|
</audio>
|
||||||
|
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-3 sm:gap-4">
|
||||||
{/* Artwork */}
|
{/* Artwork */}
|
||||||
<div className="relative shrink-0">
|
<div className="relative shrink-0">
|
||||||
<div className="relative size-20 overflow-hidden rounded-lg bg-gradient-to-br from-primary/20 to-primary/5 shadow-inner">
|
<div className="relative size-14 sm:size-20 overflow-hidden rounded-lg bg-gradient-to-br from-primary/20 to-primary/5 shadow-inner">
|
||||||
{artwork ? (
|
{artwork ? (
|
||||||
<Image src={artwork} alt={title} fill className="object-cover" unoptimized />
|
<Image src={artwork} alt={title} fill className="object-cover" unoptimized />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex size-full items-center justify-center">
|
<div className="flex size-full items-center justify-center">
|
||||||
<Volume2Icon className="size-8 text-primary/50" />
|
<Volume2Icon className="size-6 sm:size-8 text-primary/50" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -195,14 +195,16 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN
|
||||||
<div className="flex min-w-0 flex-1 flex-col justify-between">
|
<div className="flex min-w-0 flex-1 flex-col justify-between">
|
||||||
{/* Title and description */}
|
{/* Title and description */}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h3 className="truncate font-semibold text-foreground">{title}</h3>
|
<h3 className="truncate font-semibold text-foreground text-sm sm:text-base">{title}</h3>
|
||||||
{description && (
|
{description && (
|
||||||
<p className="mt-0.5 line-clamp-1 text-muted-foreground text-sm">{description}</p>
|
<p className="mt-0.5 line-clamp-1 text-muted-foreground text-xs sm:text-sm">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress bar */}
|
{/* Progress bar */}
|
||||||
<div className="mt-2 space-y-1">
|
<div className="mt-1.5 sm:mt-2 space-y-0.5 sm:space-y-1">
|
||||||
<Slider
|
<Slider
|
||||||
value={[currentTime]}
|
value={[currentTime]}
|
||||||
max={duration || 100}
|
max={duration || 100}
|
||||||
|
|
@ -211,7 +213,7 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between text-muted-foreground text-xs">
|
<div className="flex justify-between text-muted-foreground text-[10px] sm:text-xs">
|
||||||
<span>{formatTime(currentTime)}</span>
|
<span>{formatTime(currentTime)}</span>
|
||||||
<span>{formatTime(duration)}</span>
|
<span>{formatTime(duration)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -220,33 +222,37 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div className="mt-3 flex items-center justify-between border-t pt-3">
|
<div className="mt-2 sm:mt-3 flex items-center justify-between border-t pt-2 sm:pt-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||||
{/* Play/Pause button */}
|
{/* Play/Pause button */}
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={togglePlayPause}
|
onClick={togglePlayPause}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="gap-2"
|
className="gap-1.5 sm:gap-2 h-7 sm:h-8 px-2.5 sm:px-3 text-xs sm:text-sm"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="size-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
<div className="size-3 sm:size-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
) : isPlaying ? (
|
) : isPlaying ? (
|
||||||
<PauseIcon className="size-4" />
|
<PauseIcon className="size-3 sm:size-4" />
|
||||||
) : (
|
) : (
|
||||||
<PlayIcon className="size-4" />
|
<PlayIcon className="size-3 sm:size-4" />
|
||||||
)}
|
)}
|
||||||
{isPlaying ? "Pause" : "Play"}
|
{isPlaying ? "Pause" : "Play"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Volume control */}
|
{/* Volume control */}
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1 sm:gap-1.5">
|
||||||
<Button variant="ghost" size="icon" onClick={toggleMute} className="size-8">
|
<Button variant="ghost" size="icon" onClick={toggleMute} className="size-7 sm:size-8">
|
||||||
{isMuted ? <VolumeXIcon className="size-4" /> : <Volume2Icon className="size-4" />}
|
{isMuted ? (
|
||||||
|
<VolumeXIcon className="size-3.5 sm:size-4" />
|
||||||
|
) : (
|
||||||
|
<Volume2Icon className="size-3.5 sm:size-4" />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
{/* Custom volume bar - visually distinct from progress slider */}
|
{/* Custom volume bar - visually distinct from progress slider */}
|
||||||
<div className="relative flex h-6 w-16 items-center">
|
<div className="relative flex h-6 w-12 sm:w-16 items-center">
|
||||||
<div className="relative h-1 w-full rounded-full bg-muted-foreground/20">
|
<div className="relative h-1 w-full rounded-full bg-muted-foreground/20">
|
||||||
<div
|
<div
|
||||||
className="absolute left-0 top-0 h-full rounded-full bg-muted-foreground/60 transition-all"
|
className="absolute left-0 top-0 h-full rounded-full bg-muted-foreground/60 transition-all"
|
||||||
|
|
@ -268,8 +274,13 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Download button */}
|
{/* Download button */}
|
||||||
<Button variant="outline" size="sm" onClick={handleDownload} className="gap-2">
|
<Button
|
||||||
<DownloadIcon className="size-4" />
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="gap-1.5 sm:gap-2 h-7 sm:h-8 px-2.5 sm:px-3 text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
<DownloadIcon className="size-3 sm:size-4" />
|
||||||
Download
|
Download
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -96,23 +96,27 @@ function parsePodcastDetails(data: unknown): { podcast_transcript?: PodcastTrans
|
||||||
*/
|
*/
|
||||||
function PodcastGeneratingState({ title }: { title: string }) {
|
function PodcastGeneratingState({ title }: { title: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="my-4 overflow-hidden rounded-xl border border-primary/20 bg-gradient-to-br from-primary/5 to-primary/10 p-6">
|
<div className="my-4 overflow-hidden rounded-xl border border-primary/20 bg-gradient-to-br from-primary/5 to-primary/10 p-4 sm:p-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-3 sm:gap-4">
|
||||||
<div className="relative">
|
<div className="relative shrink-0">
|
||||||
<div className="flex size-16 items-center justify-center rounded-full bg-primary/20">
|
<div className="flex size-12 sm:size-16 items-center justify-center rounded-full bg-primary/20">
|
||||||
<MicIcon className="size-8 text-primary" />
|
<MicIcon className="size-6 sm:size-8 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
{/* Animated rings */}
|
{/* Animated rings */}
|
||||||
<div className="absolute inset-1 animate-ping rounded-full bg-primary/20" />
|
<div className="absolute inset-1 animate-ping rounded-full bg-primary/20" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-semibold text-foreground text-lg">{title}</h3>
|
<h3 className="font-semibold text-foreground text-sm sm:text-lg leading-tight">
|
||||||
<div className="mt-2 flex items-center gap-2 text-muted-foreground">
|
{title}
|
||||||
<Spinner size="sm" />
|
</h3>
|
||||||
<span className="text-sm">Generating podcast. This may take a few minutes.</span>
|
<div className="mt-1.5 sm:mt-2 flex items-center gap-1.5 sm:gap-2 text-muted-foreground">
|
||||||
|
<Spinner size="sm" className="size-3 sm:size-4" />
|
||||||
|
<span className="text-xs sm:text-sm">
|
||||||
|
Generating podcast. This may take a few minutes.
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3">
|
<div className="mt-2 sm:mt-3">
|
||||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-primary/10">
|
<div className="h-1 sm:h-1.5 w-full overflow-hidden rounded-full bg-primary/10">
|
||||||
<div className="h-full w-1/3 animate-pulse rounded-full bg-primary" />
|
<div className="h-full w-1/3 animate-pulse rounded-full bg-primary" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -127,15 +131,17 @@ function PodcastGeneratingState({ title }: { title: string }) {
|
||||||
*/
|
*/
|
||||||
function PodcastErrorState({ title, error }: { title: string; error: string }) {
|
function PodcastErrorState({ title, error }: { title: string; error: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="my-4 overflow-hidden rounded-xl border border-destructive/20 bg-destructive/5 p-6">
|
<div className="my-4 overflow-hidden rounded-xl border border-destructive/20 bg-destructive/5 p-4 sm:p-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-3 sm:gap-4">
|
||||||
<div className="flex size-16 shrink-0 items-center justify-center rounded-full bg-destructive/10">
|
<div className="flex size-12 sm:size-16 shrink-0 items-center justify-center rounded-full bg-destructive/10">
|
||||||
<AlertCircleIcon className="size-8 text-destructive" />
|
<AlertCircleIcon className="size-6 sm:size-8 text-destructive" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-semibold text-foreground">{title}</h3>
|
<h3 className="font-semibold text-foreground text-sm sm:text-base leading-tight">
|
||||||
<p className="mt-1 text-destructive text-sm">Failed to generate podcast</p>
|
{title}
|
||||||
<p className="mt-2 text-muted-foreground text-sm">{error}</p>
|
</h3>
|
||||||
|
<p className="mt-1 text-destructive text-xs sm:text-sm">Failed to generate podcast</p>
|
||||||
|
<p className="mt-1.5 sm:mt-2 text-muted-foreground text-xs sm:text-sm">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -147,16 +153,18 @@ function PodcastErrorState({ title, error }: { title: string; error: string }) {
|
||||||
*/
|
*/
|
||||||
function AudioLoadingState({ title }: { title: string }) {
|
function AudioLoadingState({ title }: { title: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="my-4 overflow-hidden rounded-xl border bg-muted/30 p-6">
|
<div className="my-4 overflow-hidden rounded-xl border bg-muted/30 p-4 sm:p-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-3 sm:gap-4">
|
||||||
<div className="flex size-16 items-center justify-center rounded-full bg-primary/10">
|
<div className="flex size-12 sm:size-16 shrink-0 items-center justify-center rounded-full bg-primary/10">
|
||||||
<MicIcon className="size-8 text-primary/50" />
|
<MicIcon className="size-6 sm:size-8 text-primary/50" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-semibold text-foreground">{title}</h3>
|
<h3 className="font-semibold text-foreground text-sm sm:text-base leading-tight">
|
||||||
<div className="mt-2 flex items-center gap-2 text-muted-foreground">
|
{title}
|
||||||
<Spinner size="sm" />
|
</h3>
|
||||||
<span className="text-sm">Loading audio...</span>
|
<div className="mt-1.5 sm:mt-2 flex items-center gap-1.5 sm:gap-2 text-muted-foreground">
|
||||||
|
<Spinner size="sm" className="size-3 sm:size-4" />
|
||||||
|
<span className="text-xs sm:text-sm">Loading audio...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -274,13 +282,13 @@ function PodcastPlayer({
|
||||||
/>
|
/>
|
||||||
{/* Transcript section */}
|
{/* Transcript section */}
|
||||||
{transcript && transcript.length > 0 && (
|
{transcript && transcript.length > 0 && (
|
||||||
<details className="mt-3 rounded-lg border bg-muted/30 p-3">
|
<details className="mt-2 sm:mt-3 rounded-lg border bg-muted/30 p-2.5 sm:p-3">
|
||||||
<summary className="cursor-pointer font-medium text-muted-foreground text-sm hover:text-foreground">
|
<summary className="cursor-pointer font-medium text-muted-foreground text-xs sm:text-sm hover:text-foreground">
|
||||||
View transcript ({transcript.length} entries)
|
View transcript ({transcript.length} entries)
|
||||||
</summary>
|
</summary>
|
||||||
<div className="mt-3 space-y-3 max-h-96 overflow-y-auto">
|
<div className="mt-2 sm:mt-3 space-y-2 sm:space-y-3 max-h-64 sm:max-h-96 overflow-y-auto">
|
||||||
{transcript.map((entry, idx) => (
|
{transcript.map((entry, idx) => (
|
||||||
<div key={`${idx}-${entry.speaker_id}`} className="text-sm">
|
<div key={`${idx}-${entry.speaker_id}`} className="text-xs sm:text-sm">
|
||||||
<span className="font-medium text-primary">Speaker {entry.speaker_id + 1}:</span>{" "}
|
<span className="font-medium text-primary">Speaker {entry.speaker_id + 1}:</span>{" "}
|
||||||
<span className="text-muted-foreground">{entry.dialog}</span>
|
<span className="text-muted-foreground">{entry.dialog}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -404,9 +412,9 @@ export const GeneratePodcastToolUI = makeAssistantToolUI<
|
||||||
if (status.type === "incomplete") {
|
if (status.type === "incomplete") {
|
||||||
if (status.reason === "cancelled") {
|
if (status.reason === "cancelled") {
|
||||||
return (
|
return (
|
||||||
<div className="my-4 rounded-xl border border-muted p-4 text-muted-foreground">
|
<div className="my-4 rounded-xl border border-muted p-3 sm:p-4 text-muted-foreground">
|
||||||
<p className="flex items-center gap-2">
|
<p className="flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm">
|
||||||
<MicIcon className="size-4" />
|
<MicIcon className="size-3.5 sm:size-4" />
|
||||||
<span className="line-through">Podcast generation cancelled</span>
|
<span className="line-through">Podcast generation cancelled</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -437,16 +445,16 @@ export const GeneratePodcastToolUI = makeAssistantToolUI<
|
||||||
// (new: "generating", legacy: "already_generating")
|
// (new: "generating", legacy: "already_generating")
|
||||||
if (result.status === "generating" || result.status === "already_generating") {
|
if (result.status === "generating" || result.status === "already_generating") {
|
||||||
return (
|
return (
|
||||||
<div className="my-4 overflow-hidden rounded-xl border border-amber-500/20 bg-amber-500/5 p-4">
|
<div className="my-4 overflow-hidden rounded-xl border border-amber-500/20 bg-amber-500/5 p-3 sm:p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2.5 sm:gap-3">
|
||||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-amber-500/20">
|
<div className="flex size-8 sm:size-10 shrink-0 items-center justify-center rounded-full bg-amber-500/20">
|
||||||
<MicIcon className="size-5 text-amber-500" />
|
<MicIcon className="size-4 sm:size-5 text-amber-500" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<p className="text-amber-600 dark:text-amber-400 text-sm font-medium">
|
<p className="text-amber-600 dark:text-amber-400 text-xs sm:text-sm font-medium">
|
||||||
Podcast already in progress
|
Podcast already in progress
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground text-xs mt-0.5">
|
<p className="text-muted-foreground text-[10px] sm:text-xs mt-0.5">
|
||||||
Please wait for the current podcast to complete.
|
Please wait for the current podcast to complete.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -318,9 +318,13 @@ export function useInbox(
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// STEP 1: Fetch server counts (total and recent) - guaranteed accurate
|
// STEP 1: Fetch server counts (total and recent) - guaranteed accurate
|
||||||
console.log("[useInbox] Fetching unread count from server");
|
console.log(
|
||||||
|
"[useInbox] Fetching unread count from server",
|
||||||
|
typeFilter ? `for type: ${typeFilter}` : "for all types"
|
||||||
|
);
|
||||||
const serverCounts = await notificationsApiService.getUnreadCount(
|
const serverCounts = await notificationsApiService.getUnreadCount(
|
||||||
searchSpaceId ?? undefined
|
searchSpaceId ?? undefined,
|
||||||
|
typeFilter ?? undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import {
|
||||||
type GetNotificationsRequest,
|
type GetNotificationsRequest,
|
||||||
type GetNotificationsResponse,
|
type GetNotificationsResponse,
|
||||||
type GetUnreadCountResponse,
|
type GetUnreadCountResponse,
|
||||||
|
type InboxItemTypeEnum,
|
||||||
getNotificationsRequest,
|
getNotificationsRequest,
|
||||||
getNotificationsResponse,
|
getNotificationsResponse,
|
||||||
getUnreadCountResponse,
|
getUnreadCountResponse,
|
||||||
|
|
@ -92,12 +93,20 @@ class NotificationsApiService {
|
||||||
* Get unread notification count with split between total and recent
|
* Get unread notification count with split between total and recent
|
||||||
* - total_unread: All unread notifications
|
* - total_unread: All unread notifications
|
||||||
* - recent_unread: Unread within sync window (last 14 days)
|
* - recent_unread: Unread within sync window (last 14 days)
|
||||||
|
* @param searchSpaceId - Optional search space ID to filter by
|
||||||
|
* @param type - Optional notification type to filter by (type-safe enum)
|
||||||
*/
|
*/
|
||||||
getUnreadCount = async (searchSpaceId?: number): Promise<GetUnreadCountResponse> => {
|
getUnreadCount = async (
|
||||||
|
searchSpaceId?: number,
|
||||||
|
type?: InboxItemTypeEnum
|
||||||
|
): Promise<GetUnreadCountResponse> => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (searchSpaceId !== undefined) {
|
if (searchSpaceId !== undefined) {
|
||||||
params.append("search_space_id", String(searchSpaceId));
|
params.append("search_space_id", String(searchSpaceId));
|
||||||
}
|
}
|
||||||
|
if (type) {
|
||||||
|
params.append("type", type);
|
||||||
|
}
|
||||||
const queryString = params.toString();
|
const queryString = params.toString();
|
||||||
|
|
||||||
return baseApiService.get(
|
return baseApiService.get(
|
||||||
|
|
|
||||||
|
|
@ -708,7 +708,8 @@
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"unread": "Unread",
|
"unread": "Unread",
|
||||||
"connectors": "Connectors",
|
"connectors": "Connectors",
|
||||||
"all_connectors": "All connectors"
|
"all_connectors": "All connectors",
|
||||||
|
"close": "Close"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"something_went_wrong": "Something went wrong",
|
"something_went_wrong": "Something went wrong",
|
||||||
|
|
|
||||||
|
|
@ -679,7 +679,7 @@
|
||||||
"system": "系统",
|
"system": "系统",
|
||||||
"logout": "退出登录",
|
"logout": "退出登录",
|
||||||
"inbox": "收件箱",
|
"inbox": "收件箱",
|
||||||
"search_inbox": "搜索收件箱...",
|
"search_inbox": "搜索收件箱",
|
||||||
"mark_all_read": "全部标记为已读",
|
"mark_all_read": "全部标记为已读",
|
||||||
"mark_as_read": "标记为已读",
|
"mark_as_read": "标记为已读",
|
||||||
"mentions": "提及",
|
"mentions": "提及",
|
||||||
|
|
@ -693,7 +693,8 @@
|
||||||
"all": "全部",
|
"all": "全部",
|
||||||
"unread": "未读",
|
"unread": "未读",
|
||||||
"connectors": "连接器",
|
"connectors": "连接器",
|
||||||
"all_connectors": "所有连接器"
|
"all_connectors": "所有连接器",
|
||||||
|
"close": "关闭"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"something_went_wrong": "出错了",
|
"something_went_wrong": "出错了",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue