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 }) => (
|
||||||
<table
|
<div className="aui-md-table-wrapper my-5 w-full overflow-x-auto">
|
||||||
className={cn(
|
<table
|
||||||
"aui-md-table my-5 w-full border-separate border-spacing-0 overflow-y-auto",
|
className={cn("aui-md-table w-full min-w-max border-separate border-spacing-0", className)}
|
||||||
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,111 +107,169 @@ 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 (
|
||||||
<TooltipProvider delayDuration={0}>
|
<SidebarProvider value={sidebarContextValue}>
|
||||||
<div className={cn("flex h-screen w-full flex-col bg-background", className)}>
|
<TooltipProvider delayDuration={0}>
|
||||||
<Header
|
<div className={cn("flex h-screen w-full flex-col bg-background", className)}>
|
||||||
breadcrumb={breadcrumb}
|
<Header
|
||||||
mobileMenuTrigger={<MobileSidebarTrigger onClick={() => setMobileMenuOpen(true)} />}
|
breadcrumb={breadcrumb}
|
||||||
/>
|
mobileMenuTrigger={<MobileSidebarTrigger onClick={() => setMobileMenuOpen(true)} />}
|
||||||
|
/>
|
||||||
|
|
||||||
<MobileSidebar
|
<MobileSidebar
|
||||||
isOpen={mobileMenuOpen}
|
isOpen={mobileMenuOpen}
|
||||||
onOpenChange={setMobileMenuOpen}
|
onOpenChange={setMobileMenuOpen}
|
||||||
searchSpaces={searchSpaces}
|
searchSpaces={searchSpaces}
|
||||||
activeSearchSpaceId={activeSearchSpaceId}
|
activeSearchSpaceId={activeSearchSpaceId}
|
||||||
onSearchSpaceSelect={onSearchSpaceSelect}
|
onSearchSpaceSelect={onSearchSpaceSelect}
|
||||||
onSearchSpaceDelete={onSearchSpaceDelete}
|
onSearchSpaceDelete={onSearchSpaceDelete}
|
||||||
onSearchSpaceSettings={onSearchSpaceSettings}
|
onSearchSpaceSettings={onSearchSpaceSettings}
|
||||||
onAddSearchSpace={onAddSearchSpace}
|
onAddSearchSpace={onAddSearchSpace}
|
||||||
searchSpace={searchSpace}
|
searchSpace={searchSpace}
|
||||||
navItems={navItems}
|
navItems={navItems}
|
||||||
onNavItemClick={onNavItemClick}
|
onNavItemClick={onNavItemClick}
|
||||||
chats={chats}
|
chats={chats}
|
||||||
sharedChats={sharedChats}
|
sharedChats={sharedChats}
|
||||||
activeChatId={activeChatId}
|
activeChatId={activeChatId}
|
||||||
onNewChat={onNewChat}
|
onNewChat={onNewChat}
|
||||||
onChatSelect={onChatSelect}
|
onChatSelect={onChatSelect}
|
||||||
onChatDelete={onChatDelete}
|
onChatDelete={onChatDelete}
|
||||||
onChatArchive={onChatArchive}
|
onChatArchive={onChatArchive}
|
||||||
onViewAllSharedChats={onViewAllSharedChats}
|
onViewAllSharedChats={onViewAllSharedChats}
|
||||||
onViewAllPrivateChats={onViewAllPrivateChats}
|
onViewAllPrivateChats={onViewAllPrivateChats}
|
||||||
user={user}
|
user={user}
|
||||||
onSettings={onSettings}
|
onSettings={onSettings}
|
||||||
onManageMembers={onManageMembers}
|
onManageMembers={onManageMembers}
|
||||||
onUserSettings={onUserSettings}
|
onUserSettings={onUserSettings}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
pageUsage={pageUsage}
|
pageUsage={pageUsage}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
setTheme={setTheme}
|
setTheme={setTheme}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
|
<main className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
|
||||||
</TooltipProvider>
|
{/* 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
|
// Desktop layout
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={0}>
|
<SidebarProvider value={sidebarContextValue}>
|
||||||
<div className={cn("flex h-screen w-full gap-2 p-2 overflow-hidden bg-muted/40", className)}>
|
<TooltipProvider delayDuration={0}>
|
||||||
<div className="hidden md:flex overflow-hidden">
|
<div
|
||||||
<IconRail
|
className={cn("flex h-screen w-full gap-2 p-2 overflow-hidden bg-muted/40", className)}
|
||||||
searchSpaces={searchSpaces}
|
>
|
||||||
activeSearchSpaceId={activeSearchSpaceId}
|
<div className="hidden md:flex overflow-hidden">
|
||||||
onSearchSpaceSelect={onSearchSpaceSelect}
|
<IconRail
|
||||||
onSearchSpaceDelete={onSearchSpaceDelete}
|
searchSpaces={searchSpaces}
|
||||||
onSearchSpaceSettings={onSearchSpaceSettings}
|
activeSearchSpaceId={activeSearchSpaceId}
|
||||||
onAddSearchSpace={onAddSearchSpace}
|
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>
|
||||||
|
</TooltipProvider>
|
||||||
<div className="flex flex-1 rounded-xl border bg-background overflow-hidden">
|
</SidebarProvider>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 (
|
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